From acb85a8c7894bae6ac22733da43778233152eb80 Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:29:18 +0200 Subject: [PATCH 01/94] android version issue on apk, working on release-on-tag github action --- .github/workflows/create_release_on_tag.yml | 23 ++++++++++++++++----- android/app/build.gradle | 15 ++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/create_release_on_tag.yml b/.github/workflows/create_release_on_tag.yml index 61407422..c1cf0c60 100644 --- a/.github/workflows/create_release_on_tag.yml +++ b/.github/workflows/create_release_on_tag.yml @@ -10,15 +10,18 @@ jobs: name: Create Release and Upload APK runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@master - - - name: Set up JDK 1.8 - uses: actions/setup-java@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: distribution: 'adopt' # See 'Supported distributions' for available options java-version: '17' + # - name: Set up JDK 17 + # uses: actions/setup-java@v4 + # with: + # distribution: 'adopt' # See 'Supported distributions' for available options + # java-version: '17' + - name: Cache Flutter id: cache-flutter uses: actions/cache@v3 @@ -28,6 +31,16 @@ jobs: ~/.pub-cache key: flutter-${{ hashFiles('**/pubspec.lock') }} + - name: Cache Gradle + id: cache-gradle + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper/ + key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + + - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} name: Install Flutter run: git clone https://github.com/flutter/flutter.git --depth 1 -b stable $FOLDER diff --git a/android/app/build.gradle b/android/app/build.gradle index e70b25af..e92b3fa4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -5,7 +5,7 @@ plugins { } def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') +def localPropertiesFile = project.rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) @@ -13,7 +13,7 @@ if (localPropertiesFile.exists()) { } def keyProperties = new Properties() -def keyPropertiesFile = rootProject.file('key.properties') +def keyPropertiesFile = project.rootProject.file('key.properties') def signingConfigExists = false if (keyPropertiesFile.exists()) { @@ -23,15 +23,8 @@ if (keyPropertiesFile.exists()) { } } -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') ?: '1' +def flutterVersionName = localProperties.getProperty('flutter.versionName') ?: '1.0' android { namespace "dk.carp.studies_app" From a2a44040519e630b380fc3a453d416ac755c53f9 Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Mon, 20 Oct 2025 11:27:28 +0300 Subject: [PATCH 02/94] fixing missing status top bar in ios, minor visual details --- ios/Podfile.lock | 4 +-- ios/Runner/Info.plist | 2 -- lib/ui/cards/steps_card.dart | 2 +- lib/ui/pages/study_details_page.dart | 1 + pubspec.lock | 48 ++++++++++++++-------------- 5 files changed, 28 insertions(+), 29 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0384b5ed..0e352af5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -97,7 +97,7 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - - SwiftProtobuf (1.31.1) + - SwiftProtobuf (1.32.0) - url_launcher_ios (0.0.1): - Flutter - video_player_avfoundation (0.0.1): @@ -279,7 +279,7 @@ SPEC CHECKSUMS: sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - SwiftProtobuf: e02f51c8c2df5845657aee2d4de9d61bf50ef788 + SwiftProtobuf: 81e341191afbddd64aa031bd12862dccfab2f639 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 301e663d..9570d6a3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -115,7 +115,5 @@ UIViewControllerBasedStatusBarAppearance - UIStatusBarHidden - diff --git a/lib/ui/cards/steps_card.dart b/lib/ui/cards/steps_card.dart index f8caef84..d48823b8 100644 --- a/lib/ui/cards/steps_card.dart +++ b/lib/ui/cards/steps_card.dart @@ -37,7 +37,7 @@ class StepsCardWidgetState extends State { Row( children: [ Text( - '$_step', + _step > 0 ? '$_step' : '0', style: dataVizCardTitleNumber.copyWith( color: Theme.of(context).extension()!.grey900!, ), diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index b4a6102b..3270e39f 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -249,6 +249,7 @@ class StudyDetailsPage extends StatelessWidget { Widget _buildSectionCard(BuildContext context, List children) { return Card( + margin: EdgeInsets.zero, color: Theme.of(context).extension()!.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), diff --git a/pubspec.lock b/pubspec.lock index 4598dacb..885a040f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -205,10 +205,10 @@ packages: dependency: "direct main" description: name: camera_android_camerax - sha256: d04649fab70a5d586a7b26d5ac26f700656b6aab26a16bfd5a29302589b7a973 + sha256: "92dcc36e8ff2fa1ea3acdbb609ca2976cded55dceb719b4869c124c6d011f110" url: "https://pub.dev" source: hosted - version: "0.6.23" + version: "0.6.23+2" camera_avfoundation: dependency: transitive description: @@ -429,10 +429,10 @@ packages: dependency: "direct main" description: name: country_code_picker - sha256: c9e8c012472c8060f5a705dabef223677801cd9eb49e684aecee705edeceacc8 + sha256: f0411f4833b6f98e8b7215f4fa3813bcc88e50f13925f70a170dbd36e3e447f5 url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.1" coverage: dependency: transitive description: @@ -565,10 +565,10 @@ packages: dependency: transitive description: name: exception_templates - sha256: "517f7c770da690073663f867ee2057ae2f4ffb28edae9da9faa624aa29ac76eb" + sha256: "57adef649aa2a99a5b324a921355ee9214472a007ca257cbec2f3abae005c93e" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.3.2" expandable: dependency: "direct main" description: @@ -706,10 +706,10 @@ packages: dependency: transitive description: name: flutter_local_notifications - sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5" + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" url: "https://pub.dev" source: hosted - version: "19.4.2" + version: "19.5.0" flutter_local_notifications_linux: dependency: transitive description: @@ -743,10 +743,10 @@ packages: dependency: "direct main" description: name: flutter_plugin_android_lifecycle - sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" url: "https://pub.dev" source: hosted - version: "2.0.30" + version: "2.0.32" flutter_secure_storage: dependency: transitive description: @@ -1057,10 +1057,10 @@ packages: dependency: transitive description: name: lazy_memo - sha256: dcb30b4184a6d767e1d779d74ce784d752d38313b8fb4bad6b659ae7af4bb34d + sha256: f3f4afe9c4ccf0f29082213c5319a3711041446fc41cd325a9bf91724d4ea9c8 url: "https://pub.dev" source: hosted - version: "0.2.3" + version: "0.2.5" leak_tracker: dependency: transitive description: @@ -1433,10 +1433,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 url: "https://pub.dev" source: hosted - version: "2.2.18" + version: "2.2.20" path_provider_foundation: dependency: transitive description: @@ -1609,10 +1609,10 @@ packages: dependency: "direct main" description: name: qr_code_scanner_plus - sha256: a0f1ac8e13299b3db2646635f252fe2ec67222b848b24ed34d11052faf080bfa + sha256: "41f4a834a48d670d25e3917cb9f1dbb4742298a0b4ab60d82416b295b73931e1" url: "https://pub.dev" source: hosted - version: "2.0.12" + version: "2.0.13" recase: dependency: transitive description: @@ -1697,10 +1697,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "0b0f98d535319cb5cdd4f65783c2a54ee6d417a2f093dbb18be3e36e4c3d181f" + sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" url: "https://pub.dev" source: hosted - version: "2.4.14" + version: "2.4.15" shared_preferences_foundation: dependency: transitive description: @@ -2054,10 +2054,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b + sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" url: "https://pub.dev" source: hosted - version: "6.3.23" + version: "6.3.24" url_launcher_ios: dependency: transitive description: @@ -2158,10 +2158,10 @@ packages: dependency: transitive description: name: video_player_android - sha256: "6cfe0b1e102522eda1e139b82bf00602181c5844fd2885340f595fb213d74842" + sha256: cf768d02924b91e333e2bc1ff928528f57d686445874f383bafab12d0bdfc340 url: "https://pub.dev" source: hosted - version: "2.8.14" + version: "2.8.17" video_player_avfoundation: dependency: transitive description: @@ -2174,10 +2174,10 @@ packages: dependency: transitive description: name: video_player_platform_interface - sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a + sha256: "9e372520573311055cb353b9a0da1c9d72b094b7ba01b8ecc66f28473553793b" url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.5.0" video_player_web: dependency: transitive description: From a068946b2e193053810db606e09f7133c183dcf8 Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Mon, 20 Oct 2025 12:31:35 +0300 Subject: [PATCH 03/94] fixing persistent messages through different studies --- lib/ui/pages/study_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index f96e3a42..33f44fc4 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -68,9 +68,9 @@ class StudyPageState extends State { if (LocalSettings().isAnonymous) { items.add(AnonymousCard()); } - if (bloc.messages.isNotEmpty) { + if (widget.model.messages.isEmpty) { items.add(_buildAnnouncementsTitle(context)); - items.addAll(bloc.messages.map((message) { + items.addAll(widget.model.messages.map((message) { return _announcementCard(context, message); }).toList()); } From 0b6bc9575f78cb3d0ee056e16c8ca34c84a7dc5d Mon Sep 17 00:00:00 2001 From: bardram Date: Fri, 24 Oct 2025 09:31:31 +0200 Subject: [PATCH 04/94] fix of ! operator --- lib/ui/pages/task_list_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 1aec37ac..ddef55c6 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -45,7 +45,7 @@ class TaskListPageState extends State with TickerProviderStateMixin { late TabController _tabController; - bool? showParticipantDataCard = false; + bool showParticipantDataCard = false; @override void initState() { @@ -159,7 +159,7 @@ class TaskListPageState extends State ), ), ), - if (showParticipantDataCard!) + if (showParticipantDataCard) SliverToBoxAdapter( child: _buildParticipantDataCard(), ), From c1ea94e6f474f862f60de95431296f2b9aa78813 Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Fri, 24 Oct 2025 12:23:19 +0300 Subject: [PATCH 05/94] announcements page fix, message details page ui changes, removing unused code, ui changes to bluetoth device list, status card ui improvements, improvement of notifications not going to task, heart rate tool tip ui fix --- ios/Podfile.lock | 8 +- lib/blocs/app_bloc.dart | 1 + lib/carp_study_app.dart | 18 +-- lib/data/local_settings.dart | 15 +-- lib/ui/cards/heart_rate_card.dart | 2 +- lib/ui/pages/device_list_page.dart | 2 +- ...evices_page.bluetooth_connection_page.dart | 106 +++++++++++++----- lib/ui/pages/home_page.dart | 4 +- lib/ui/pages/message_details_page.dart | 47 ++++---- lib/ui/pages/study_page.dart | 29 +++-- lib/ui/tasks/participant_data_page.dart | 1 - pubspec.lock | 52 ++++----- 12 files changed, 171 insertions(+), 114 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0e352af5..faec3eff 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -267,7 +267,7 @@ SPEC CHECKSUMS: oidc_ios: 16966cad509ce6850ca4ca1216c5138bef2a8726 open_settings_plus: d19f91e8a04649358a51c19b484ce2e637149d70 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 pedometer: 1c5eaab0c6bce8eb7651f7095553b5081c9d06ed permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PhoneNumberKit: ec00ab8cef5342c1dc49fadb99d23fa7e66cf0ef @@ -277,11 +277,11 @@ SPEC CHECKSUMS: RxSwift: 4e28be97cbcfeee614af26d83415febbf2bf6f45 screen_state: 52d6e997d31bddba6417c60d9cdd22effd0320a7 sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftProtobuf: 81e341191afbddd64aa031bd12862dccfab2f639 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 PODFILE CHECKSUM: 0f233b2493d660073cf18073d2b24e7b319ab4a8 diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 18eca055..cbde5069 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -380,6 +380,7 @@ class StudyAppBLoC extends ChangeNotifier { /// the Study Page of the app. Future refreshMessages() async { try { + _messages.clear(); _messages = await messageManager.getMessages(); _messages.sort((m1, m2) => m2.timestamp.compareTo(m1.timestamp)); info('Message list refreshed - count: ${_messages.length}'); diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 296c2360..cc59400d 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -108,6 +108,15 @@ class CarpStudyAppState extends State { transitionsBuilder: bottomNavigationBarAnimation, ), ), + GoRoute( + path: '/task/:taskId', + parentNavigatorKey: _shellNavigatorKey, + builder: (context, state) { + final taskId = state.pathParameters['taskId'] ?? ''; + final task = AppTaskController().getUserTask(taskId); + return task?.widget ?? const ErrorPage(); + }, + ), ], ), GoRoute( @@ -123,15 +132,6 @@ class CarpStudyAppState extends State { builder: (context, state) => ParticipantDataPage( model: bloc.appViewModel.participantDataPageViewModel), ), - GoRoute( - path: '/task/:taskId', - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { - final taskId = state.pathParameters['taskId'] ?? ''; - final task = AppTaskController().getUserTask(taskId); - return task?.widget ?? const ErrorPage(); - }, - ), GoRoute( path: InformedConsentPage.route, parentNavigatorKey: _rootNavigatorKey, diff --git a/lib/data/local_settings.dart b/lib/data/local_settings.dart index 086461d5..a5194c1a 100644 --- a/lib/data/local_settings.dart +++ b/lib/data/local_settings.dart @@ -14,9 +14,6 @@ class LocalSettings { /// See https://developer.android.com/health-and-fitness/guides/health-connect/develop/get-started#get-client static const healthConnectPackageName = 'com.google.android.apps.healthdata'; - bool isExpectedParticipantDataSet = false; - bool hasUserSeenDeviceConnectionInstructions = false; - // Keys for storing in shared preferences static const String userKey = 'user'; static const String participantKey = 'participant'; @@ -26,8 +23,6 @@ class LocalSettings { Participant? _participant; SmartphoneStudy? _study; - bool hasSeenBluetoothConnectionInstructions = false; - static final LocalSettings _instance = LocalSettings._(); factory LocalSettings() => _instance; LocalSettings._() : super(); @@ -109,11 +104,13 @@ class LocalSettings { ); } - bool get hasSeenConnectionInstructions => - hasSeenBluetoothConnectionInstructions; + bool get hasSeenBluetoothConnectionInstructions => + Settings() + .preferences + ?.getBool('hasSeenBluetoothConnectionInstructions') ?? + false; - set hasSeenConnectionInstructions(bool seen) { - hasSeenBluetoothConnectionInstructions = seen; + set hasSeenBluetoothConnectionInstructions(bool seen) { Settings().preferences?.setBool( 'hasSeenBluetoothConnectionInstructions', seen, diff --git a/lib/ui/cards/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index abe6290a..8784ac47 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -169,7 +169,7 @@ class HeartRateCardWidgetState extends State enabled: true, touchTooltipData: BarTouchTooltipData( fitInsideHorizontally: true, - // tooltipBgColor: Theme.of(context).primaryColorLight, + fitInsideVertically: true, getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( '', diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index d392f148..21bf78fc 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -356,7 +356,7 @@ class DeviceListPageState extends State { if (disconnect) await device.disconnectFromDevice(); } else { final hasSeenInstructions = - LocalSettings().hasSeenConnectionInstructions; + LocalSettings().hasSeenBluetoothConnectionInstructions; Navigator.push( context, MaterialPageRoute( diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 9d952dfc..aa1319c8 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -41,16 +41,20 @@ class _BluetoothConnectionPageState extends State { BluetoothDevice? selectedDevice; int selected = 40; + /// Set of normalized UUIDs (no dashes, lower-case) to filter discovered devices by + /// If empty, no UUID filtering is applied. + final Set _filterUuids = {}; + @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Stack( children: [ Container( - color: Theme.of(context).colorScheme.secondary, child: Column( children: [ Padding( @@ -312,34 +316,48 @@ class _BluetoothConnectionPageState extends State { child: StreamBuilder>( stream: FlutterBluePlus.scanResults, initialData: const [], - builder: (context, snapshot) => SingleChildScrollView( - padding: const EdgeInsets.only(top: 16), - child: Column( - children: snapshot.data! - .where( - (element) => element.device.platformName.isNotEmpty) - .toList() - .asMap() - .entries - .map( - (bluetoothDevice) => ListTile( - selected: bluetoothDevice.key == selected, - title: Text( - bluetoothDevice.value.device.platformName, - style: healthServiceConnectMessageStyle, + builder: (context, snapshot) => Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 16), + child: Column( + children: snapshot.data! + .where((element) => + element.device.platformName.isNotEmpty && + _matchesUuid(element, _filterUuids)) + .toList() + .asMap() + .entries + .map( + (bluetoothDevice) => StudiesMaterial( + // hasBorder: true, + backgroundColor: + Theme.of(context).extension()!.grey50!, + child: InkWell( + child: ListTile( + selected: bluetoothDevice.key == selected, + title: Text( + bluetoothDevice.value.device.platformName, + style: + healthServiceConnectMessageStyle.copyWith( + fontSize: 20, + ), + ), + selectedTileColor: Theme.of(context) + .primaryColor + .withValues(alpha: 0.2), + ), + onTap: () { + selectedDevice = bluetoothDevice.value.device; + setState(() { + selected = bluetoothDevice.key; + }); + }, + ), ), - selectedTileColor: Theme.of(context) - .primaryColor - .withValues(alpha: 0.2), - onTap: () { - selectedDevice = bluetoothDevice.value.device; - setState(() { - selected = bluetoothDevice.key; - }); - }, - ), - ) - .toList(), + ) + .toList(), + ), ), ), ), @@ -383,6 +401,34 @@ class _BluetoothConnectionPageState extends State { ); } + /// Returns true if [scanResult] advertises any UUID present in [filterUuids]. + /// If [filterUuids] is empty, always returns true. + bool _matchesUuid(ScanResult scanResult, Set filterUuids) { + if (filterUuids.isEmpty) return true; + + // Normalize helper: remove dashes and lowercase + String normalize(String u) => u.replaceAll('-', '').toLowerCase(); + + try { + // FlutterBluePlus ScanResult contains advertisementData with serviceUuids + final adv = scanResult.advertisementData; + final serviceUuids = adv.serviceUuids; + for (var u in serviceUuids) { + final us = u.toString(); + if (filterUuids.contains(normalize(us))) return true; + } + + // Also check device id (remoteId) as fallback + final devId = scanResult.device.remoteId.id; + if (filterUuids.contains(normalize(devId))) return true; + } catch (_) { + // If structure differs, fall back to allowing the device + return true; + } + + return false; + } + Widget connectionInstructions(DeviceViewModel device, BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; AssetImage? assetImage; @@ -420,8 +466,8 @@ class _BluetoothConnectionPageState extends State { Image connectionImage = Image( image: assetImage, - width: MediaQuery.of(context).size.height * 0.5, - height: MediaQuery.of(context).size.height * 0.5, + width: MediaQuery.of(context).size.height * 0.3, + height: MediaQuery.of(context).size.height * 0.3, ); return Column( children: [ diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index aa6aacc8..1257b9a2 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -96,7 +96,9 @@ class HomePageState extends State { AppTaskController().userTaskEvents.listen((userTask) { if (userTask.state == UserTaskState.notified) { userTask.onStart(); - if (userTask.hasWidget) context.push('/task/${userTask.id}'); + if (userTask.hasWidget) { + _rootNavigatorKey.currentContext?.push('/task/${userTask.id}'); + } } }); diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 93817864..09d7ec2e 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -52,19 +52,32 @@ class MessageDetailsPage extends StatelessWidget { } }, ), - Material( - color: CACHET.DEPLOYMENT_DEPLOYING, - borderRadius: BorderRadius.circular(100.0), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - locale.translate(message.type - .toString() - .split('.') - .last - .toLowerCase()), - style: aboutCardSubtitleStyle.copyWith( - color: Colors.white)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Text(locale.translate(message.title!), + style: aboutCardTitleStyle.copyWith( + color: Theme.of(context) + .extension()! + .grey900)), + ), + Spacer(), + Padding( + padding: const EdgeInsets.only(right: 24), + child: Material( + color: CACHET.DEPLOYMENT_DEPLOYING, + borderRadius: BorderRadius.circular(100.0), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, vertical: 6.0), + child: Text( + locale.translate(message.type + .toString() + .split('.') + .last + .toLowerCase()), + style: aboutCardSubtitleStyle.copyWith( + color: Colors.white)), + ), ), ), ], @@ -74,14 +87,6 @@ class MessageDetailsPage extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text(locale.translate(message.title!), - style: aboutCardTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900)), - ), message.subTitle != null ? Padding( padding: const EdgeInsets.symmetric( diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 33f44fc4..5ea3f90f 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -68,9 +68,12 @@ class StudyPageState extends State { if (LocalSettings().isAnonymous) { items.add(AnonymousCard()); } - if (widget.model.messages.isEmpty) { + if (widget.model.messages.isNotEmpty) { items.add(_buildAnnouncementsTitle(context)); - items.addAll(widget.model.messages.map((message) { + // Show newest announcements first: sort by timestamp descending + final messages = List.from(widget.model.messages) + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + items.addAll(messages.map((message) { return _announcementCard(context, message); }).toList()); } @@ -215,15 +218,27 @@ class StudyPageState extends State { future: bloc.studyDeploymentStatus, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return Container(); + return StudiesMaterial( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); } else if (snapshot.hasError) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22), - child: Text('Error: ${snapshot.error}'), + return StudiesMaterial( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Text( + 'Error: ${snapshot.error}', + textAlign: TextAlign.center, + ), + ), ); // Show an error message if the future fails } else if (!snapshot.hasData || snapshot.data == null) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 28), child: Center( child: CircularProgressIndicator(), ), diff --git a/lib/ui/tasks/participant_data_page.dart b/lib/ui/tasks/participant_data_page.dart index c3397326..adb2ae4f 100644 --- a/lib/ui/tasks/participant_data_page.dart +++ b/lib/ui/tasks/participant_data_page.dart @@ -774,7 +774,6 @@ class ParticipantDataPageState extends State { participantData, bloc.study!.participantRoleName, ); - LocalSettings().hasSeenConnectionInstructions = true; } Future _showCancelConfirmationDialog() { diff --git a/pubspec.lock b/pubspec.lock index 885a040f..441dc359 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.1.0" build_resolvers: dependency: transitive description: @@ -197,26 +197,26 @@ packages: dependency: "direct main" description: name: camera - sha256: d6ec2cbdbe2fa8f5e0d07d8c06368fe4effa985a4a5ddade9cc58a8cd849557d + sha256: "87a27e0553e3432119c1c2f6e4b9a1bbf7d2c660552b910bfa59185a9facd632" url: "https://pub.dev" source: hosted - version: "0.11.2" + version: "0.11.2+1" camera_android_camerax: dependency: "direct main" description: name: camera_android_camerax - sha256: "92dcc36e8ff2fa1ea3acdbb609ca2976cded55dceb719b4869c124c6d011f110" + sha256: b68b638e5e0ede21155e670493ac568981a8f56c5f636d720935a916a1c5a0ef url: "https://pub.dev" source: hosted - version: "0.6.23+2" + version: "0.6.24" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "397f44f8a63c8c0a474668d500f9739d4f2bc45ac2b21801194b7d29260f03ee" + sha256: "75bd22c0cf97d89a528d505e0f10bc8a0d08f0e218ca999812af1076c72d5907" url: "https://pub.dev" source: hosted - version: "0.9.22+1" + version: "0.9.22+3" camera_platform_interface: dependency: transitive description: @@ -1105,10 +1105,10 @@ packages: dependency: transitive description: name: list_operators - sha256: "795b1a2b3fe689008907e92ddab0e965434dd1f02e7321cd65858d553c0740bf" + sha256: "480e6726f44c9fc5dd0dbcdec192bfcaad24d4edef366abea572fbbaf6f96575" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.5.1" location: dependency: transitive description: @@ -1441,10 +1441,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" path_provider_linux: dependency: transitive description: @@ -1657,10 +1657,10 @@ packages: dependency: transitive description: name: sample_statistics - sha256: "115b076b61bd680975d620c02b47a3b56fa779d31eaac5d2b8896279d45f25a8" + sha256: ba47e4a81f57fc1968472daf392c22ffc5142c42e93a2e9b899e569c3843168a url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_state: dependency: transitive description: @@ -1705,10 +1705,10 @@ packages: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_linux: dependency: transitive description: @@ -1789,14 +1789,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" - simulated_annealing: - dependency: transitive - description: - name: simulated_annealing - sha256: "5fac4326f446780a01fe0703a6be2d98dfca4a93d71e49c782f5574da22755c9" - url: "https://pub.dev" - source: hosted - version: "0.4.0" sky_engine: dependency: transitive description: flutter @@ -2062,10 +2054,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" url: "https://pub.dev" source: hosted - version: "6.3.4" + version: "6.3.5" url_launcher_linux: dependency: transitive description: @@ -2078,10 +2070,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.4" url_launcher_platform_interface: dependency: transitive description: @@ -2166,10 +2158,10 @@ packages: dependency: transitive description: name: video_player_avfoundation - sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd + sha256: "19ed1162a7a5520e7d7791e0b7b73ba03161b6a69428b82e4689e435b325432d" url: "https://pub.dev" source: hosted - version: "2.8.4" + version: "2.8.5" video_player_platform_interface: dependency: transitive description: From 694a6526bc67a820080a429f6dd6be32aac79fe1 Mon Sep 17 00:00:00 2001 From: bardram Date: Fri, 24 Oct 2025 11:31:21 +0200 Subject: [PATCH 06/94] Fix of "day in study" in #523 --- lib/view_models/tasklist_page_model.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/view_models/tasklist_page_model.dart b/lib/view_models/tasklist_page_model.dart index 1a03b570..258d5727 100644 --- a/lib/view_models/tasklist_page_model.dart +++ b/lib/view_models/tasklist_page_model.dart @@ -34,8 +34,14 @@ class TaskListPageViewModel extends ViewModel { Stream get userTaskEvents => AppTaskController().userTaskEvents; /// The number of days the user has been part of this study. - int get daysInStudy => (bloc.studyStartTimestamp != null) - ? DateTime.now().difference(bloc.studyStartTimestamp!).inDays + 1 + /// + /// 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 => (Sensing().studyDeploymentStatus != null) + ? DateTime.now() + .difference(Sensing().studyDeploymentStatus!.createdOn) + .inDays : 0; /// The number of tasks completed so far. From 62c8ee8140d10cfb5684b6cf009de34edfedf2c0 Mon Sep 17 00:00:00 2001 From: bardram Date: Fri, 24 Oct 2025 11:44:24 +0200 Subject: [PATCH 07/94] Small update to translations --- assets/lang/da.json | 4 ++-- assets/lang/en.json | 6 +++--- assets/lang/es.json | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/assets/lang/da.json b/assets/lang/da.json index e08855bb..1d44c86c 100644 --- a/assets/lang/da.json +++ b/assets/lang/da.json @@ -97,9 +97,9 @@ "pages.profile.device_role": "Engedsrolle", "pages.profile.contact": "Kontakt forsker", "pages.profile.privacy": "Fortrolighedspolitik", - "pages.profile.study_website": "Studiw Hjemmeside", + "pages.profile.study_website": "Hjemmeside for studiet", "pages.profile.leave_study": "Forlad studie", - "pages.profile.log_out": "Log ud", + "pages.profile.log_out": "Forlad studie & 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 aae2fb09..1dce60f2 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -110,9 +110,9 @@ "pages.profile.device_id": "Device ID", "pages.profile.contact": "Contact researcher", "pages.profile.privacy": "Privacy policy", - "pages.profile.study_website": "Study Website", + "pages.profile.study_website": "Study website", "pages.profile.leave_study": "Leave study", - "pages.profile.log_out": "Log out", + "pages.profile.log_out": "Leave study & Log 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", @@ -244,4 +244,4 @@ "tasks.participant_data.phone_number.country": "Country code", "tasks.participant_data.phone_number.phone_number": "Phone No.", "tasks.participant_data.review.title": "Review" -} +} \ No newline at end of file diff --git a/assets/lang/es.json b/assets/lang/es.json index b4f811b1..b9bf74db 100644 --- a/assets/lang/es.json +++ b/assets/lang/es.json @@ -58,8 +58,9 @@ "pages.profile.account_id": "Id Usuario", "pages.profile.contact": "Contactar investigador", "pages.profile.privacy": "Política de privacidad", + "pages.profile.study_website": "Página web del estudio", "pages.profile.leave_study": "Abandonar el estudio", - "pages.profile.log_out": "Cerrar sesión", + "pages.profile.log_out": "Cerrar estudio & 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", @@ -121,4 +122,4 @@ "pages.devices.connection.step.confirm.title": "está conectado!", "pages.devices.connection.step.confirm.1": "El", "pages.devices.connection.step.confirm.2": " ha sido connectado satisfactoriamente al estudio y está listo para empezar a detectar." -} +} \ No newline at end of file From 84efaedb52e74d4f13408e2b6e42fe10b330e7ea Mon Sep 17 00:00:00 2001 From: bardram Date: Fri, 24 Oct 2025 12:52:29 +0200 Subject: [PATCH 08/94] Fix of cached data across studies - #523 --- lib/blocs/app_bloc.dart | 11 +++++------ lib/view_models/view_model.dart | 7 ++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 18eca055..9df60d06 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -478,14 +478,13 @@ class StudyAppBLoC extends ChangeNotifier { /// * resetting the informed consent flow /// * returning the user to select an invitation for another study /// - /// Note that study deployment information and data is not removed from the - /// phone. This is stored for later access. Or if the same deployment is - /// re-deployed on the phone, data from the previous deployment will be - /// available. + /// Note that study deployment information and data is removed from the + /// phone. If the same deployment is re-deployed on the phone, data from the + /// previous deployment will NOT be available. Future leaveStudy() async { - debug('$runtimeType --------- LEAVING STUDY ------------'); + info('Leaving study $study'); - // save and clear the UI data models + // clear the UI data models appViewModel.clear(); // stop sensing and remove all deployment info diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 447535f5..4428256b 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -15,11 +15,12 @@ abstract class ViewModel extends ChangeNotifier { _controller = ctrl; } - /// Called when this view model is to clear its state (e.g., cached data). + /// Clear this view model, i.e. delete all data incl. cached data. @mustCallSuper void clear() {} - /// Called when this view model is disposed and no longer used. + /// Called when this view model is disposed. Typically on app exit, incl. when + /// closed by the OS. @override @mustCallSuper void dispose() { @@ -92,7 +93,7 @@ abstract class SerializableViewModel extends ViewModel { _filename = null; _persistenceTimer?.cancel(); _persistenceTimer = null; - save(); + delete(); } @override From f2f1b76363a464817a9ea397e58be10bf31f9aba Mon Sep 17 00:00:00 2001 From: bardram Date: Fri, 24 Oct 2025 16:58:57 +0200 Subject: [PATCH 09/94] Update view_model.dart --- lib/view_models/view_model.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 4428256b..9585bb84 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -128,7 +128,7 @@ abstract class SerializableViewModel extends ViewModel { return success; } - /// Permanently delete the [model]. + /// Permanently delete the cached [model]. /// Returns true if successful, false otherwise. bool delete() { bool success = true; From be5497161183f9e4c27421337304d3eb3ab1d7e4 Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:01:25 +0200 Subject: [PATCH 10/94] adding new images for health connection page --- assets/instructions/apple_health_preview.png | Bin 0 -> 151089 bytes .../google_health_connect_preview.png | Bin 0 -> 159361 bytes .../devices_page.health_service_connect1.dart | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 assets/instructions/apple_health_preview.png create mode 100644 assets/instructions/google_health_connect_preview.png diff --git a/assets/instructions/apple_health_preview.png b/assets/instructions/apple_health_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..89113a45e18f41d3b0841b99fd4ab21def93d07f GIT binary patch literal 151089 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∪B2-EQKNA=9!te4apBWHVKDEQKMdp~<+QX;3 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�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 bk=)4p=JU8Rdo;ZK2X|?z>Z;T#y^8ujSSVE$ literal 0 HcmV?d00001 diff --git a/assets/instructions/google_health_connect_preview.png b/assets/instructions/google_health_connect_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..b0cb0528b8cfd5991185e4bf67e0b06319120a9d GIT binary patch literal 159361 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 Date: Wed, 29 Oct 2025 13:40:56 +0200 Subject: [PATCH 11/94] fixing status bar color appearing darker than page, removing unnecessary health connect page, changing UI to use icons instead of words for announcement cards --- lib/carp_study_app.dart | 7 ++ lib/main.dart | 3 +- lib/ui/pages/device_list_page.dart | 2 +- ... devices_page.health_service_connect.dart} | 103 +++++++--------- .../devices_page.health_service_connect1.dart | 116 ------------------ lib/ui/pages/study_page.dart | 17 ++- pubspec.lock | 12 +- 7 files changed, 70 insertions(+), 190 deletions(-) rename lib/ui/pages/{devices_page.health_service_connect2.dart => devices_page.health_service_connect.dart} (62%) delete mode 100644 lib/ui/pages/devices_page.health_service_connect1.dart diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index cc59400d..2df8ad56 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -184,6 +184,13 @@ class CarpStudyAppState extends State { @override Widget build(BuildContext context) { final carpColors = Theme.of(context).extension(); + + // Apply system overlay style after frame so Theme.of(context) is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + )); + }); return MaterialApp.router( scaffoldMessengerKey: bloc.scaffoldKey, supportedLocales: const [ diff --git a/lib/main.dart b/lib/main.dart index 904ffa4f..692acc18 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -105,8 +105,7 @@ part 'ui/pages/devices_page.enable_bluetooth_dialog.dart'; part 'ui/pages/devices_page.bluetooth_connection_page.dart'; part 'ui/pages/devices_page.disconnection_dialog.dart'; part 'ui/pages/devices_page.list_title.dart'; -part 'ui/pages/devices_page.health_service_connect1.dart'; -part 'ui/pages/devices_page.health_service_connect2.dart'; +part 'ui/pages/devices_page.health_service_connect.dart'; part 'ui/tasks/audio_task_page.dart'; part 'ui/tasks/audio_page.dart'; diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 21bf78fc..71543979 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -320,7 +320,7 @@ class DeviceListPageState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => HealthServiceConnectPage1()), + builder: (context) => HealthServiceConnectPage()), ); } else { await service.deviceManager.requestPermissions(); diff --git a/lib/ui/pages/devices_page.health_service_connect2.dart b/lib/ui/pages/devices_page.health_service_connect.dart similarity index 62% rename from lib/ui/pages/devices_page.health_service_connect2.dart rename to lib/ui/pages/devices_page.health_service_connect.dart index 9d79643d..88dd4bd6 100644 --- a/lib/ui/pages/devices_page.health_service_connect2.dart +++ b/lib/ui/pages/devices_page.health_service_connect.dart @@ -1,7 +1,7 @@ part of carp_study_app; -class HealthServiceConnectPage2 extends StatelessWidget { - const HealthServiceConnectPage2({super.key}); +class HealthServiceConnectPage extends StatelessWidget { + const HealthServiceConnectPage({super.key}); @override Widget build(BuildContext context) { @@ -14,15 +14,15 @@ class HealthServiceConnectPage2 extends StatelessWidget { .first; return Scaffold( + backgroundColor: Theme.of(context).extension()!.grey100, body: SafeArea( child: Container( - color: Theme.of(context).colorScheme.secondary, child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), - child: const CarpAppBar(hasProfileIcon: true), + child: const CarpAppBar(), ), Expanded( child: Padding( @@ -30,25 +30,15 @@ class HealthServiceConnectPage2 extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Image.asset( - Platform.isAndroid - ? 'assets/instructions/google_health_connect_icon.png' - : 'assets/instructions/apple_health_icon.png', - height: 250, - width: 250, + Expanded( + child: Center( + child: Image.asset( + Platform.isAndroid + ? 'assets/instructions/google_health_connect_preview.png' + : 'assets/instructions/apple_health_preview.png', + fit: BoxFit.contain, + width: double.infinity, + ), ), ), const SizedBox(height: 20), @@ -108,39 +98,6 @@ class HealthServiceConnectPage2 extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - child: const Text("Cancel"), - onPressed: () { - Navigator.pop(context); - }, - ), - ElevatedButton( - child: const Text( - "Next", - style: TextStyle( - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, vertical: 12), - ), - onPressed: () async { - await healthServive.deviceManager - .requestPermissions(); - await healthServive.deviceManager.connect(); - - Navigator.pop(context); - }, - ), - ], - ), ], ), ), @@ -149,6 +106,40 @@ class HealthServiceConnectPage2 extends StatelessWidget { ), ), ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.symmetric(vertical: 26), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OutlinedButton( + child: const Text("Cancel"), + onPressed: () { + Navigator.pop(context); + }, + ), + ElevatedButton( + child: const Text( + "Next", + style: TextStyle( + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).extension()!.primary, + padding: + const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + ), + onPressed: () async { + await healthServive.deviceManager.requestPermissions(); + await healthServive.deviceManager.connect(); + + Navigator.pop(context); + }, + ), + ], + ), + ), ); } } diff --git a/lib/ui/pages/devices_page.health_service_connect1.dart b/lib/ui/pages/devices_page.health_service_connect1.dart deleted file mode 100644 index ee71a047..00000000 --- a/lib/ui/pages/devices_page.health_service_connect1.dart +++ /dev/null @@ -1,116 +0,0 @@ -part of carp_study_app; - -class HealthServiceConnectPage1 extends StatelessWidget { - const HealthServiceConnectPage1({super.key}); - - @override - Widget build(BuildContext context) { - RPLocalizations locale = RPLocalizations.of(context)!; - return Scaffold( - body: SafeArea( - child: Container( - color: Theme.of(context).colorScheme.secondary, - child: Column( - children: [ - Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), - child: const CarpAppBar(hasProfileIcon: true), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Image.asset( - Platform.isAndroid - ? 'assets/instructions/google_health_connect_preview.png' - : 'assets/instructions/apple_health_preview.png', - height: 250, - width: 250, - ), - ), - const SizedBox(height: 20), - Text( - Platform.isAndroid - ? locale.translate( - "pages.devices.type.health.instructions.page1.android") - : locale.translate( - "pages.devices.type.health.instructions.page1.ios"), - style: healthServiceConnectTitleStyle.copyWith( - color: Theme.of(context) - .extension()! - .primary), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - Text( - "${locale.translate("pages.devices.type.health.instructions.page1.part1")} " - "${Platform.isAndroid ? locale.translate("pages.devices.type.health.instructions.page1.android") : locale.translate("pages.devices.type.health.instructions.page1.ios")} " - "${locale.translate("pages.devices.type.health.instructions.page1.part2")}", - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context) - .extension()! - .grey900), - textAlign: TextAlign.center, - ), - const SizedBox(height: 30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - child: const Text("Cancel"), - onPressed: () { - Navigator.pop(context); - }, - ), - ElevatedButton( - child: Text( - locale.translate("Next"), - style: TextStyle( - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, vertical: 12), - ), - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - HealthServiceConnectPage2()), - ); - }, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 5ea3f90f..f89af343 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -395,15 +395,8 @@ class StudyPageState extends State { color: CACHET.DEPLOYMENT_DEPLOYING, borderRadius: BorderRadius.circular(100.0), child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - locale.translate(message.type - .toString() - .split('.') - .last - .toLowerCase()), - style: aboutCardSubtitleStyle.copyWith( - color: Colors.white)), + padding: const EdgeInsets.all(4.0), + child: messageTypeIcon[message.type], ), ), ], @@ -504,6 +497,12 @@ class StudyPageState extends State { StudyDeploymentStatusTypes.Running: 'pages.about.status.running.message', StudyDeploymentStatusTypes.Stopped: 'pages.about.status.stopped.message', }; + + static Map messageTypeIcon = { + MessageType.announcement: Icon(Icons.campaign, color: Colors.white), + MessageType.news: Icon(Icons.newspaper, color: Colors.white), + MessageType.article: Icon(Icons.article, color: Colors.white), + }; } extension CopyWithAdditional on DateTime { diff --git a/pubspec.lock b/pubspec.lock index 441dc359..540487e1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -197,10 +197,10 @@ packages: dependency: "direct main" description: name: camera - sha256: "87a27e0553e3432119c1c2f6e4b9a1bbf7d2c660552b910bfa59185a9facd632" + sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab url: "https://pub.dev" source: hosted - version: "0.11.2+1" + version: "0.11.3" camera_android_camerax: dependency: "direct main" description: @@ -213,10 +213,10 @@ packages: dependency: transitive description: name: camera_avfoundation - sha256: "75bd22c0cf97d89a528d505e0f10bc8a0d08f0e218ca999812af1076c72d5907" + sha256: "34bcd5db30e52414f1f0783c5e3f566909fab14141a21b3b576c78bd35382bf6" url: "https://pub.dev" source: hosted - version: "0.9.22+3" + version: "0.9.22+4" camera_platform_interface: dependency: transitive description: @@ -2166,10 +2166,10 @@ packages: dependency: transitive description: name: video_player_platform_interface - sha256: "9e372520573311055cb353b9a0da1c9d72b094b7ba01b8ecc66f28473553793b" + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.0" video_player_web: dependency: transitive description: From 8dc2dfdc99a52d7e6a26f825f352f4368a3b046c Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:16:18 +0100 Subject: [PATCH 12/94] informed consent from backend instead of locally, reverting notifications code --- android/app/build.gradle | 4 +-- lib/blocs/app_bloc.dart | 4 ++- lib/carp_study_app.dart | 18 +++++----- lib/data/carp_backend.dart | 7 ++++ ...evices_page.bluetooth_connection_page.dart | 2 +- lib/ui/tasks/camera_page.dart | 2 +- pubspec.lock | 36 ++++++++----------- pubspec.yaml | 2 +- 8 files changed, 38 insertions(+), 37 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index e92b3fa4..bc2192c5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -81,10 +81,10 @@ android { debugSymbolLevel 'SYMBOL_TABLE' } if (signingConfigExists) { - logger.error('storeFile found, signing with release build.') + logger.info('storeFile found, signing with release build.') signingConfig signingConfigs.release } else { - logger.error('No storeFile found or null. Skipping signing of release build.') + logger.info('No storeFile found or null. Skipping signing of release build.') signingConfig signingConfigs.debug } } diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index cbde5069..2f8a8377 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -352,7 +352,9 @@ class StudyAppBLoC extends ChangeNotifier { /// Has the informed consent been accepted by the user? bool get hasInformedConsentBeenAccepted => - LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; + backend.getInformedConsentByRole( + study!.studyDeploymentId, study!.participantRoleName) != + null; set hasInformedConsentBeenAccepted(bool accepted) { var participant = LocalSettings().participant; diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 2df8ad56..4d5cba81 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -108,15 +108,6 @@ class CarpStudyAppState extends State { transitionsBuilder: bottomNavigationBarAnimation, ), ), - GoRoute( - path: '/task/:taskId', - parentNavigatorKey: _shellNavigatorKey, - builder: (context, state) { - final taskId = state.pathParameters['taskId'] ?? ''; - final task = AppTaskController().getUserTask(taskId); - return task?.widget ?? const ErrorPage(); - }, - ), ], ), GoRoute( @@ -132,6 +123,15 @@ class CarpStudyAppState extends State { builder: (context, state) => ParticipantDataPage( model: bloc.appViewModel.participantDataPageViewModel), ), + GoRoute( + path: '/task/:taskId', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) { + final taskId = state.pathParameters['taskId'] ?? ''; + final task = AppTaskController().getUserTask(taskId); + return task?.widget ?? const ErrorPage(); + }, + ), GoRoute( path: InformedConsentPage.route, parentNavigatorKey: _rootNavigatorKey, diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index 6f100fc3..3db20440 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -232,4 +232,11 @@ class CarpBackend { return uploadedConsent; } + + Future? getInformedConsentByRole( + String studyDeploymentId, String? role) async { + return await CarpParticipationService() + .participation(studyDeploymentId) + .getInformedConsentByRole(role); + } } diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index aa1319c8..02b9db9d 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -419,7 +419,7 @@ class _BluetoothConnectionPageState extends State { } // Also check device id (remoteId) as fallback - final devId = scanResult.device.remoteId.id; + final devId = scanResult.device.remoteId.str; if (filterUuids.contains(normalize(devId))) return true; } catch (_) { // If structure differs, fall back to allowing the device diff --git a/lib/ui/tasks/camera_page.dart b/lib/ui/tasks/camera_page.dart index 07ecc94a..eaef6e53 100644 --- a/lib/ui/tasks/camera_page.dart +++ b/lib/ui/tasks/camera_page.dart @@ -101,7 +101,7 @@ class CameraPageState extends State { } } - void stopRecording(details) async { + void stopRecording(dynamic details) async { try { var video = await _cameraController.stopVideoRecording(); diff --git a/pubspec.lock b/pubspec.lock index 540487e1..eb591be0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -221,10 +221,10 @@ packages: dependency: transitive description: name: camera_platform_interface - sha256: ea1ef6ba79cdbed93df2d3eeef11542a90dec24dbcd9cde574926b86d7a09a10 + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" camera_web: dependency: transitive description: @@ -293,10 +293,10 @@ packages: dependency: "direct main" description: name: carp_movesense_package - sha256: ecf454337a4ef6ce2ce71516bb7544fb659c2fb64562b6f4ce3e303d1cc07f0c + sha256: e17094a42fae084d26c7759459493389016d9efc9f1555c7a5044eec1469d87d url: "https://pub.dev" source: hosted - version: "1.7.5" + version: "1.7.6" carp_polar_package: dependency: "direct main" description: @@ -453,18 +453,18 @@ packages: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" crypto_keys_plus: dependency: transitive description: @@ -823,10 +823,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + sha256: "055de8921be7b8e8b98a233c7a5ef84b3a6fcc32f46f1ebf5b9bb3576d108355" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -1609,10 +1609,10 @@ packages: dependency: "direct main" description: name: qr_code_scanner_plus - sha256: "41f4a834a48d670d25e3917cb9f1dbb4742298a0b4ab60d82416b295b73931e1" + sha256: b764e5004251c58d9dee0c295e6006e05bd8d249e78ac3383abdb5afe0a996cd url: "https://pub.dev" source: hosted - version: "2.0.13" + version: "2.0.14" recase: dependency: transitive description: @@ -1834,14 +1834,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" sqflite: dependency: transitive description: @@ -2102,10 +2094,10 @@ packages: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.2" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 28c79c5e..9e33afbf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,7 +69,7 @@ dependency_overrides: # carp_core: # path: ../carp/carp.sensing-flutter/carp_core/ # carp_mobile_sensing: - # path: ../carp/carp.sensing-flutter/carp_mobile_sensing/ + # path: ../carp.sensing-flutter/carp_mobile_sensing/ # carp_context_package: # path: ../carp/carp.sensing-flutter/packages/carp_context_package/ # carp_connectivity_package: From b45ca926fd909cb4cc26cc2a00d4802405e9c1ea Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:03:06 +0100 Subject: [PATCH 13/94] moving to carp_themes_package instead of research package theme --- CHANGELOG.md | 4 +- android/build.gradle | 2 - lib/carp_study_app.dart | 10 +-- lib/main.dart | 1 + lib/ui/cards/activity_card.dart | 39 +++++------ lib/ui/cards/anonymous_card.dart | 9 ++- lib/ui/cards/distance_card.dart | 18 ++--- lib/ui/cards/heart_rate_card.dart | 23 ++++--- lib/ui/cards/media_card.dart | 8 +-- lib/ui/cards/mobility_card.dart | 27 ++++---- lib/ui/cards/scoreboard_card.dart | 38 ++++++----- lib/ui/cards/steps_card.dart | 18 ++--- lib/ui/cards/study_progress_card.dart | 4 +- lib/ui/cards/survey_card.dart | 13 ++-- lib/ui/carp_study_style.dart | 58 ++++++++-------- lib/ui/pages/data_visualization_page.dart | 10 +-- lib/ui/pages/device_list_page.dart | 33 ++++----- .../devices_page.authorization_dialog.dart | 2 +- ...evices_page.bluetooth_connection_page.dart | 31 ++++----- .../devices_page.disconnection_dialog.dart | 2 +- .../devices_page.enable_bluetooth_dialog.dart | 6 +- .../devices_page.health_service_connect.dart | 24 +++---- lib/ui/pages/devices_page.list_title.dart | 4 +- lib/ui/pages/enable_connection_dialog.dart | 12 ++-- lib/ui/pages/home_page.dart | 7 +- ...me_page.install_health_connect_dialog.dart | 2 +- lib/ui/pages/invitation_list_page.dart | 23 ++++--- lib/ui/pages/invitation_page.dart | 11 +-- lib/ui/pages/message_details_page.dart | 17 +++-- lib/ui/pages/process_message_page.dart | 6 +- lib/ui/pages/profile_page.dart | 17 +++-- lib/ui/pages/study_details_page.dart | 67 ++++++++++--------- lib/ui/pages/study_page.dart | 61 +++++++++-------- lib/ui/pages/task_list_page.dart | 32 ++++----- lib/ui/tasks/audio_page.dart | 32 ++++----- lib/ui/tasks/audio_task_page.dart | 11 +-- lib/ui/tasks/camera_task_page.dart | 8 +-- lib/ui/tasks/display_picture_page.dart | 6 +- lib/ui/tasks/participant_data_page.dart | 19 +++--- lib/ui/widgets/charts_legend.dart | 4 +- lib/ui/widgets/details_banner.dart | 2 +- lib/ui/widgets/dialog_title.dart | 2 +- lib/ui/widgets/horizontal_bar.dart | 6 +- lib/ui/widgets/location_permission_page.dart | 11 +-- lib/ui/widgets/location_usage_dialog.dart | 4 +- pubspec.lock | 16 +++-- pubspec.yaml | 3 +- 47 files changed, 396 insertions(+), 367 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1760de8a..b4174efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ -## 4.1.1 +## 4.2.0 -- small visual fixes +* moving to carp_themes_package instead of research package themes ## 4.1.0 diff --git a/android/build.gradle b/android/build.gradle index a9e61289..1c67b3aa 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -12,8 +12,6 @@ allprojects { rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { project.evaluationDependsOn(':app') } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 4d5cba81..43ac5389 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -183,7 +183,7 @@ class CarpStudyAppState extends State { @override Widget build(BuildContext context) { - final carpColors = Theme.of(context).extension(); + final studyAppColors = Theme.of(context).extension(); // Apply system overlay style after frame so Theme.of(context) is ready WidgetsBinding.instance.addPostFrameCallback((_) { @@ -218,14 +218,14 @@ class CarpStudyAppState extends State { return supportedLocales.first; // default to EN }, locale: bloc.localization?.locale, - theme: researchPackageTheme.copyWith( + theme: carpTheme.copyWith( extensions: [ - researchPackageTheme.extension()!.copyWith( - primary: carpColors?.primary, + carpTheme.extension()!.copyWith( + primary: studyAppColors?.primary, ), ], ), - darkTheme: researchPackageDarkTheme, + darkTheme: carpDarkTheme, debugShowCheckedModeBanner: true, routerConfig: _router, ); diff --git a/lib/main.dart b/lib/main.dart index 692acc18..778eef6e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -55,6 +55,7 @@ import 'package:cognition_package/cognition_package.dart'; import 'package:carp_health_package/health_package.dart'; // import 'package:health/health.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/util.dart'; diff --git a/lib/ui/cards/activity_card.dart b/lib/ui/cards/activity_card.dart index 51bb84c8..68fe931f 100644 --- a/lib/ui/cards/activity_card.dart +++ b/lib/ui/cards/activity_card.dart @@ -63,7 +63,7 @@ class ActivityCardState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -72,16 +72,16 @@ class ActivityCardState extends State { children: [ Text( '${_walk! + _run! + _cycle!}', - style: dataVizCardTitleNumber.copyWith( - color: Theme.of(context).extension()!.grey900!, + 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)}', - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ), @@ -91,8 +91,8 @@ class ActivityCardState extends State { 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: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), Spacer(), @@ -117,7 +117,7 @@ class ActivityCardState extends State { children: [ Text( '$_walk', - style: dataVizCardBottomNumber.copyWith( + style: fs22fw700.copyWith( color: widget.colors[0], ), ), @@ -125,9 +125,9 @@ class ActivityCardState extends State { padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.activity.walking'), - style: dataVizCardBottomText.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey800), ), ), @@ -141,7 +141,7 @@ class ActivityCardState extends State { padding: const EdgeInsets.only(left: 8.0), child: Text( '$_run', - style: dataVizCardBottomNumber.copyWith( + style: fs12fw700.copyWith( color: widget.colors[1], ), ), @@ -150,9 +150,9 @@ class ActivityCardState extends State { padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.activity.running'), - style: dataVizCardBottomText.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey800), ), ), @@ -165,7 +165,7 @@ class ActivityCardState extends State { children: [ Text( '$_cycle', - style: dataVizCardBottomNumber.copyWith( + style: fs22fw700.copyWith( color: widget.colors[2], ), ), @@ -173,9 +173,10 @@ class ActivityCardState extends State { padding: const EdgeInsets.only(left: 4.0), child: Text( locale.translate('cards.activity.cycling'), - style: dataVizCardBottomText.copyWith( - color: - Theme.of(context).extension()!.grey800, + style: fs12fw700.copyWith( + color: Theme.of(context) + .extension()! + .grey800, ), ), ), @@ -303,8 +304,8 @@ class ActivityCardState extends State { value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs14ls1.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ); diff --git a/lib/ui/cards/anonymous_card.dart b/lib/ui/cards/anonymous_card.dart index 1c935295..c135d51a 100644 --- a/lib/ui/cards/anonymous_card.dart +++ b/lib/ui/cards/anonymous_card.dart @@ -8,7 +8,7 @@ class AnonymousCard extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; return Card( - color: Theme.of(context).extension()!.grey50, + color: Theme.of(context).extension()!.grey50, elevation: 0, margin: const EdgeInsets.all(16.0), shape: RoundedRectangleBorder( @@ -45,8 +45,7 @@ class AnonymousCard extends StatelessWidget { child: Text( locale.translate('pages.about.anonymous.anonymous'), maxLines: 2, - style: aboutCardSubtitleStyle.copyWith( - color: CACHET.ANONYMOUS), + style: fs16fw600.copyWith(color: CACHET.ANONYMOUS), ), ), ], @@ -56,9 +55,9 @@ class AnonymousCard extends StatelessWidget { padding: const EdgeInsets.only(left: 16.0), child: Text( locale.translate('pages.about.anonymous.message'), - style: aboutCardSubtitleStyle.copyWith( + style: fs16fw600.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, fontSize: 14, ), diff --git a/lib/ui/cards/distance_card.dart b/lib/ui/cards/distance_card.dart index 92e06851..857ad3ae 100644 --- a/lib/ui/cards/distance_card.dart +++ b/lib/ui/cards/distance_card.dart @@ -33,7 +33,7 @@ class _DistanceCardState extends State { @override Widget build(BuildContext context) { return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -42,16 +42,16 @@ class _DistanceCardState extends State { children: [ Text( _distance, - style: dataVizCardTitleNumber.copyWith( - color: Theme.of(context).extension()!.grey900!, + style: fs28fw700.copyWith( + color: Theme.of(context).extension()!.grey900!, ), ), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( 'km ${_getDayName(touchedIndex)}', - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ), @@ -61,8 +61,8 @@ class _DistanceCardState extends State { 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: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), Spacer(), @@ -174,8 +174,8 @@ class _DistanceCardState extends State { value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs14ls1.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ); diff --git a/lib/ui/cards/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index 8784ac47..570fc7de 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -42,7 +42,7 @@ class HeartRateCardWidgetState extends State @override Widget build(BuildContext context) { return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -86,7 +86,7 @@ class HeartRateCardWidgetState extends State min == null || max == null ? '-' : '${(min.toInt())} - ${(max.toInt())}', - style: heartRateNumberStyle, + style: fs28fw700, ), ), Padding( @@ -95,9 +95,9 @@ class HeartRateCardWidgetState extends State min == null || max == null ? '' : locale.translate('cards.heartrate.bpm'), - style: heartRateBPMTextStyle.copyWith( + style: fs10fw700.copyWith( fontSize: 12, - color: Theme.of(context).extension()!.grey600, + color: Theme.of(context).extension()!.grey600, ), ), ), @@ -122,9 +122,9 @@ class HeartRateCardWidgetState extends State child: currentHeartRate != null ? Text( currentHeartRate.toStringAsFixed(0), - style: heartRateNumberStyle, + style: fs28fw700, ) - : Text('-', style: heartRateNumberStyle), + : Text('-', style: fs28fw700), ), Padding( padding: const EdgeInsets.only(bottom: 14), @@ -145,8 +145,9 @@ class HeartRateCardWidgetState extends State ), Text( locale.translate('cards.heartrate.bpm'), - style: heartRateBPMTextStyle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs10fw700.copyWith( + color: + Theme.of(context).extension()!.grey600, ), ), ], @@ -210,7 +211,7 @@ class HeartRateCardWidgetState extends State ), ), ], - heartRateNumberStyle, + fs28fw700, ); }, ), @@ -310,8 +311,8 @@ class HeartRateCardWidgetState extends State value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs14ls1.copyWith( + color: Theme.of(context).extension()!.grey600, ), maxLines: 1, ), diff --git a/lib/ui/cards/media_card.dart b/lib/ui/cards/media_card.dart index 1fe94b1c..c69d3c0f 100644 --- a/lib/ui/cards/media_card.dart +++ b/lib/ui/cards/media_card.dart @@ -19,7 +19,7 @@ class MediaCardWidgetState extends State { } return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -34,7 +34,7 @@ class MediaCardWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 5), - Text('$total MEDIA', style: dataCardTitleStyle), + Text('$total MEDIA', style: fs16fw400ls1), Column( children: widget.modelsList .asMap() @@ -47,8 +47,8 @@ class MediaCardWidgetState extends State { const SizedBox(height: 15), Text( '${entry.value.tasksDone} ${locale.translate('cards.${entry.value.taskType}.title')}', - style: dataCardTitleStyle.copyWith( - fontSize: 14), + style: + fs16fw400ls1.copyWith(fontSize: 14), ), LayoutBuilder(builder: (BuildContext context, diff --git a/lib/ui/cards/mobility_card.dart b/lib/ui/cards/mobility_card.dart index ee506136..20278945 100644 --- a/lib/ui/cards/mobility_card.dart +++ b/lib/ui/cards/mobility_card.dart @@ -30,7 +30,7 @@ class _MobilityCardState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -39,7 +39,7 @@ class _MobilityCardState extends State { children: [ Text( '$_homestay%', - style: dataVizCardTitleNumber.copyWith( + style: fs28fw700.copyWith( color: widget.colors[0], ), ), @@ -47,8 +47,9 @@ class _MobilityCardState extends State { padding: const EdgeInsets.only(left: 4.0), child: Text( "${locale.translate('cards.mobility.homestay')} ${_getDayName(touchedIndex)}", - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey900!, + style: fs12fw700.copyWith( + color: + Theme.of(context).extension()!.grey900!, ), ), ), @@ -58,8 +59,8 @@ class _MobilityCardState extends State { 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: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), Spacer(), @@ -81,7 +82,7 @@ class _MobilityCardState extends State { children: [ Text( '$_places', - style: dataVizCardBottomNumber.copyWith( + style: fs22fw700.copyWith( color: widget.colors[0], ), ), @@ -89,9 +90,9 @@ class _MobilityCardState extends State { padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.mobility.places'), - style: dataVizCardBottomText.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey800), ), ), @@ -206,8 +207,8 @@ class _MobilityCardState extends State { value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs14ls1.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ); @@ -221,8 +222,8 @@ class _MobilityCardState extends State { value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs14ls1.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ); diff --git a/lib/ui/cards/scoreboard_card.dart b/lib/ui/cards/scoreboard_card.dart index 7ea6e62d..8a65d23d 100644 --- a/lib/ui/cards/scoreboard_card.dart +++ b/lib/ui/cards/scoreboard_card.dart @@ -54,41 +54,45 @@ class ScoreboardPersistentHeaderDelegate List childrenDays = [ Text(model.daysInStudy.toString(), - style: scoreNumberStyle.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, - scoreNumberStyleSmall.fontSize!, scoreNumberStyle.fontSize!), - color: Theme.of(context).extension()!.grey900)), + 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: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), + 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: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey900)), ) ]; List childrenTasks = [ Text(model.taskCompleted.toString(), - style: scoreNumberStyle.copyWith( - fontSize: calculateScrollAwareSizing(shrinkOffset, - scoreNumberStyleSmall.fontSize!, scoreNumberStyle.fontSize!), - color: Theme.of(context).extension()!.primary)), + 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: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.primary)), + 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: scoreTextStyle.copyWith( - color: Theme.of(context).extension()!.primary)), + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.primary)), ), ) ]; @@ -96,7 +100,7 @@ class ScoreboardPersistentHeaderDelegate return Container( height: height, decoration: BoxDecoration( - color: Theme.of(context).extension()!.white, + color: Theme.of(context).extension()!.white, borderRadius: BorderRadius.circular(8), // Rounded corners ), child: StreamBuilder( diff --git a/lib/ui/cards/steps_card.dart b/lib/ui/cards/steps_card.dart index d48823b8..ffb40e14 100644 --- a/lib/ui/cards/steps_card.dart +++ b/lib/ui/cards/steps_card.dart @@ -29,7 +29,7 @@ class StepsCardWidgetState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -38,16 +38,16 @@ class StepsCardWidgetState extends State { children: [ Text( _step > 0 ? '$_step' : '0', - style: dataVizCardTitleNumber.copyWith( - color: Theme.of(context).extension()!.grey900!, + 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)}', - style: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ), @@ -57,8 +57,8 @@ class StepsCardWidgetState extends State { 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: dataVizCardTitleText.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs12fw700.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), Spacer(), @@ -172,8 +172,8 @@ class StepsCardWidgetState extends State { value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: dataCardRightTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs14ls1.copyWith( + color: Theme.of(context).extension()!.grey600, ), ), ); diff --git a/lib/ui/cards/study_progress_card.dart b/lib/ui/cards/study_progress_card.dart index c74e4cf6..800b6b46 100644 --- a/lib/ui/cards/study_progress_card.dart +++ b/lib/ui/cards/study_progress_card.dart @@ -19,7 +19,7 @@ class StudyProgressCardWidgetState extends State { widget.model.updateProgress(); return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: StreamBuilder( @@ -33,7 +33,7 @@ class StudyProgressCardWidgetState extends State { mainAxisAlignment: MainAxisAlignment.start, children: [ Text(locale.translate('cards.study_progress.title'), - style: dataCardTitleStyle), + style: fs16fw400ls1), ], ), ), diff --git a/lib/ui/cards/survey_card.dart b/lib/ui/cards/survey_card.dart index 92569b90..579b1b1f 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -21,7 +21,7 @@ class _SurveyCardState extends State { } return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -30,7 +30,7 @@ class _SurveyCardState extends State { Padding( padding: const EdgeInsets.only(left: 10.0), child: Text(locale.translate('cards.survey.title').toUpperCase(), - style: dataCardTitleStyle), + style: fs16fw400ls1), ), SizedBox( height: 160, @@ -57,7 +57,7 @@ class _SurveyCardState extends State { ); Widget text = Text( '${entry.value} ${locale.translate(entry.key).truncateTo(12)}', - style: legendStyle, + style: fs12fw400, ); return Row( children: [ @@ -83,9 +83,10 @@ class _SurveyCardState extends State { ), Text( '$totalSurveys', - style: surveysCardTotalTextStyle.copyWith( - color: - Theme.of(context).extension()!.grey800, + style: fs24fw700.copyWith( + color: Theme.of(context) + .extension()! + .grey800, ), ) ], diff --git a/lib/ui/carp_study_style.dart b/lib/ui/carp_study_style.dart index 3d6f935f..0ac9bfce 100644 --- a/lib/ui/carp_study_style.dart +++ b/lib/ui/carp_study_style.dart @@ -1,8 +1,8 @@ part of carp_study_app; @immutable -class CarpColors extends ThemeExtension { - const CarpColors({ +class StudyAppColors extends ThemeExtension { + const StudyAppColors({ this.primary, this.warningColor, this.backgroundGray, @@ -44,7 +44,7 @@ class CarpColors extends ThemeExtension { final Color? grey950; @override - CarpColors copyWith( + StudyAppColors copyWith( {Color? primary, Color? warningColor, Color? backgroundGray, @@ -61,7 +61,7 @@ class CarpColors extends ThemeExtension { Color? grey800, Color? grey900, Color? grey950}) { - return CarpColors( + return StudyAppColors( primary: primary ?? this.primary, warningColor: warningColor ?? this.warningColor, backgroundGray: backgroundGray ?? this.backgroundGray, @@ -82,11 +82,11 @@ class CarpColors extends ThemeExtension { } @override - CarpColors lerp(CarpColors? other, double t) { - if (other is! CarpColors) { + StudyAppColors lerp(StudyAppColors? other, double t) { + if (other is! StudyAppColors) { return this; } - return CarpColors( + return StudyAppColors( primary: Color.lerp(primary, other.primary, t), warningColor: Color.lerp(warningColor, other.warningColor, t), backgroundGray: Color.lerp(backgroundGray, other.backgroundGray, t), @@ -109,7 +109,7 @@ class CarpColors extends ThemeExtension { ThemeData carpStudyTheme = ThemeData.light().copyWith( extensions: >[ - CarpColors( + StudyAppColors( primary: const Color(0xff000000), warningColor: Colors.orange[500], backgroundGray: const Color(0xfff2f2f7), @@ -179,7 +179,7 @@ ThemeData carpStudyTheme = ThemeData.light().copyWith( ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( extensions: >[ - CarpColors( + StudyAppColors( primary: const Color(0xff24B2FF), warningColor: Colors.orange[700], backgroundGray: const Color(0xff0e0e0e), @@ -253,13 +253,13 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // These TextStyles are now implemented in ResearchPackage -// TextStyle studyTitleStyle = +// TextStyle fs24fw600 = // const TextStyle(fontSize: 24, fontWeight: FontWeight.w600); -// TextStyle studyDetailsInfoTitle = +// TextStyle fs16fw700 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w700); -// TextStyle studyDetailsInfoMessage = +// TextStyle fs12fw700 = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); // TextStyle readMoreStudyStyle = @@ -278,29 +278,29 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // TextStyle scoreTextStyle = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); -// TextStyle aboutStudyCardTitleStyle = +// TextStyle fs24fw700 = // const TextStyle(fontSize: 24, fontWeight: FontWeight.w700) // .apply(fontFamily: 'OpenSans'); -// TextStyle aboutCardTitleStyle = +// TextStyle fs20fw700 = // const TextStyle(fontSize: 20, fontWeight: FontWeight.w700) // .apply(fontFamily: 'OpenSans'); // TextStyle aboutCardInfoStyle = // const TextStyle(fontSize: 14, fontStyle: FontStyle.italic); -// TextStyle aboutCardSubtitleStyle = +// TextStyle fs16fw600 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w600); -// TextStyle aboutCardContentStyle = +// TextStyle fs16fw400 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w400) // .apply(fontFamily: 'OpenSans'); -// TextStyle aboutCardTimeAgoStyle = +// TextStyle fs10fw600 = // const TextStyle(fontSize: 10, fontWeight: FontWeight.w600) // .apply(fontFamily: 'OpenSans'); -// TextStyle sectionTitleStyle = +// TextStyle fs18fw700 = // const TextStyle(fontSize: 18, fontWeight: FontWeight.w700); // TextStyle inputFieldStyle = @@ -312,18 +312,18 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // TextStyle studyDescriptionStyle = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w300); -// TextStyle dataCardTitleStyle = const TextStyle( +// TextStyle fs16fw400ls1 = const TextStyle( // fontSize: 16, fontWeight: FontWeight.w400, letterSpacing: 1); // TextStyle dataCardRightTitleStyle = // const TextStyle(fontSize: 14, letterSpacing: 1); // TextStyle measuresStyle = // const TextStyle(fontSize: 18, fontWeight: FontWeight.w400); -// TextStyle legendStyle = +// TextStyle fs12fw400 = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w400); -// TextStyle audioTitleStyle = +// TextStyle fs22fw700 = // const TextStyle(fontSize: 22, fontWeight: FontWeight.w700); -// TextStyle audioContentStyle = +// TextStyle fs16fw600 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w700); // TextStyle heartRateNumberStyle = @@ -343,27 +343,27 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( // TextStyle dataVizCardBottomText = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); -// TextStyle deviceTitle = +// TextStyle fs16fw700 = // const TextStyle(fontSize: 16, fontWeight: FontWeight.w700); -// TextStyle deviceSubtitle = +// TextStyle fs12fw700 = // const TextStyle(fontSize: 12, fontWeight: FontWeight.w700); // TextStyle healthServiceConnectTitleStyle = // const TextStyle(fontSize: 24, fontWeight: FontWeight.w700); -// TextStyle healthServiceConnectMessageStyle = +// TextStyle fs22fw700 = // const TextStyle(fontSize: 22, fontWeight: FontWeight.w700); -// TextStyle profileSectionStyle = +// TextStyle fs12fw600 = // TextStyle(fontSize: 12, fontWeight: FontWeight.w600); -// TextStyle profileTitleStyle = +// TextStyle fs14fw600 = // TextStyle(fontSize: 14, fontWeight: FontWeight.w600); -// TextStyle profileActionStyle = +// TextStyle fs16fw600 = // TextStyle(fontSize: 16, fontWeight: FontWeight.w600); // TextStyle timerStyle = // const TextStyle(fontSize: 36, fontWeight: FontWeight.w600); -// TextStyle studyNameStyle = +// TextStyle fs30fw800 = // const TextStyle(fontSize: 30.0, fontWeight: FontWeight.w800); diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart index 56db1a91..373b3ca6 100644 --- a/lib/ui/pages/data_visualization_page.dart +++ b/lib/ui/pages/data_visualization_page.dart @@ -16,7 +16,7 @@ class _DataVisualizationPageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( backgroundColor: - Theme.of(context).extension()!.backgroundGray, + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -38,9 +38,9 @@ class _DataVisualizationPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(locale.translate('pages.data_viz.title'), - style: aboutStudyCardTitleStyle.copyWith( + style: fs24fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, fontWeight: FontWeight.bold, )), @@ -60,9 +60,9 @@ class _DataVisualizationPageState extends State { padding: const EdgeInsets.symmetric( horizontal: 15, vertical: 24.0), child: Text(locale.translate('pages.data_viz.thanks'), - style: aboutCardSubtitleStyle.copyWith( + style: fs16fw600.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey600, )), ), diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 71543979..9e563688 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -49,7 +49,8 @@ class DeviceListPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -72,9 +73,10 @@ class DeviceListPageState extends State { children: [ Text( locale.translate('pages.devices.title'), - style: aboutStudyCardTitleStyle.copyWith( - color: - Theme.of(context).extension()!.grey900, + style: fs24fw700.copyWith( + color: Theme.of(context) + .extension()! + .grey900, fontWeight: FontWeight.bold, ), ), @@ -94,9 +96,9 @@ class DeviceListPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(locale.translate("pages.devices.message"), - style: aboutCardSubtitleStyle.copyWith( + style: fs16fw600.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey600, )), const SizedBox(height: 15), @@ -134,7 +136,7 @@ class DeviceListPageState extends State { builder: (BuildContext context, Widget? widget) => Center( child: StudiesMaterial( backgroundColor: - Theme.of(context).extension()!.grey50!, + Theme.of(context).extension()!.grey50!, child: _cardListBuilder( leading: _smartphoneDevice[index].icon!, title: ( @@ -182,8 +184,7 @@ class DeviceListPageState extends State { child: Text( locale.translate( device.getDeviceStatusIcon as String), - style: aboutCardTitleStyle.copyWith( - color: Colors.white)), + style: fs20fw700.copyWith(color: Colors.white)), ), ), ); @@ -218,8 +219,7 @@ class DeviceListPageState extends State { child: Text( locale.translate( service.getServiceStatusIcon as String), - style: aboutCardTitleStyle.copyWith( - color: Colors.white), + style: fs20fw700.copyWith(color: Colors.white), ), ) : service.getServiceStatusIcon as Icon, @@ -255,8 +255,8 @@ class DeviceListPageState extends State { children: [ Text( title!.$1, - style: deviceTitle.copyWith( - color: Theme.of(context).extension()!.grey900, + style: fs16fw700.copyWith( + color: Theme.of(context).extension()!.grey900, ), ), SizedBox(width: 6), @@ -275,8 +275,9 @@ class DeviceListPageState extends State { alignment: Alignment.centerLeft, child: Text( subtitle, - style: deviceSubtitle.copyWith( - color: Theme.of(context).extension()!.grey700, + style: fs12fw700.copyWith( + color: + Theme.of(context).extension()!.grey700, ), ), ), @@ -300,7 +301,7 @@ class DeviceListPageState extends State { ) => Center( child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: StreamBuilder( stream: stream, initialData: initialData, diff --git a/lib/ui/pages/devices_page.authorization_dialog.dart b/lib/ui/pages/devices_page.authorization_dialog.dart index ec22957e..f7a6f370 100644 --- a/lib/ui/pages/devices_page.authorization_dialog.dart +++ b/lib/ui/pages/devices_page.authorization_dialog.dart @@ -32,7 +32,7 @@ class AuthorizationDialog extends StatelessWidget { Text( locale.translate( "pages.devices.connection.bluetooth_authorization.message"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), Image( diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 02b9db9d..0d0be9e1 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -50,7 +50,8 @@ class _BluetoothConnectionPageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Stack( children: [ @@ -124,7 +125,7 @@ class _BluetoothConnectionPageState extends State { Flexible( child: Text( stepTitleMap[currentStep] ?? '', - style: healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context).primaryColor, ), textAlign: TextAlign.center, @@ -169,7 +170,7 @@ class _BluetoothConnectionPageState extends State { _connectDevice(), selectedDevice != null, ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), TextStyle( @@ -190,7 +191,7 @@ class _BluetoothConnectionPageState extends State { }, true, ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), TextStyle( @@ -210,7 +211,7 @@ class _BluetoothConnectionPageState extends State { }, true, ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), TextStyle( @@ -309,7 +310,7 @@ class _BluetoothConnectionPageState extends State { "${locale.translate("pages.devices.connection.step.scan.1")} " "${locale.translate(device.typeName)} " "${locale.translate("pages.devices.connection.step.scan.2")}", - style: healthServiceConnectMessageStyle, + style: fs22fw700, textAlign: TextAlign.justify, ), Expanded( @@ -331,15 +332,15 @@ class _BluetoothConnectionPageState extends State { .map( (bluetoothDevice) => StudiesMaterial( // hasBorder: true, - backgroundColor: - Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context) + .extension()! + .grey50!, child: InkWell( child: ListTile( selected: bluetoothDevice.key == selected, title: Text( bluetoothDevice.value.device.platformName, - style: - healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( fontSize: 20, ), ), @@ -375,7 +376,7 @@ class _BluetoothConnectionPageState extends State { text: locale .translate("pages.devices.connection.instructions"), style: TextStyle( - color: Theme.of(context).extension()!.primary, + color: Theme.of(context).extension()!.primary, decoration: TextDecoration.underline, fontWeight: FontWeight.bold, ), @@ -391,8 +392,8 @@ class _BluetoothConnectionPageState extends State { ), ], ), - style: healthServiceConnectMessageStyle.copyWith( - color: Theme.of(context).extension()!.grey900), + style: fs22fw700.copyWith( + color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.center, ), ) @@ -482,7 +483,7 @@ class _BluetoothConnectionPageState extends State { padding: const EdgeInsets.only(bottom: 20.0), child: Text( locale.translate(device.connectionInstructions!), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ), @@ -513,7 +514,7 @@ class _BluetoothConnectionPageState extends State { child: Text( ("${locale.translate("pages.devices.connection.step.confirm.1")} '${device?.platformName}' ${locale.translate("pages.devices.connection.step.confirm.2")}") .trim(), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ), diff --git a/lib/ui/pages/devices_page.disconnection_dialog.dart b/lib/ui/pages/devices_page.disconnection_dialog.dart index a1326908..0390df0e 100644 --- a/lib/ui/pages/devices_page.disconnection_dialog.dart +++ b/lib/ui/pages/devices_page.disconnection_dialog.dart @@ -26,7 +26,7 @@ class DisconnectionDialog extends StatelessWidget { children: [ Text( "${locale.translate("pages.devices.connection.disconnect_bluetooth.message")} ${locale.translate(device.name)}?", - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), Row( diff --git a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart index 7c6d3aa6..72944dfc 100644 --- a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart +++ b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart @@ -31,7 +31,7 @@ class EnableBluetoothDialog extends StatelessWidget { Text( locale.translate( "pages.devices.connection.enable_bluetooth.message1"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), Padding( @@ -40,7 +40,7 @@ class EnableBluetoothDialog extends StatelessWidget { Text( locale.translate( "pages.devices.connection.enable_bluetooth.message2"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), if (Platform.isAndroid || Platform.isIOS) @@ -54,7 +54,7 @@ class EnableBluetoothDialog extends StatelessWidget { Text( locale.translate( "pages.devices.connection.enable_bluetooth.message3"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ], diff --git a/lib/ui/pages/devices_page.health_service_connect.dart b/lib/ui/pages/devices_page.health_service_connect.dart index 88dd4bd6..0a35387b 100644 --- a/lib/ui/pages/devices_page.health_service_connect.dart +++ b/lib/ui/pages/devices_page.health_service_connect.dart @@ -14,7 +14,7 @@ class HealthServiceConnectPage extends StatelessWidget { .first; return Scaffold( - backgroundColor: Theme.of(context).extension()!.grey100, + backgroundColor: Theme.of(context).extension()!.grey100, body: SafeArea( child: Container( child: Column( @@ -48,36 +48,36 @@ class HealthServiceConnectPage extends StatelessWidget { TextSpan( text: "${locale.translate("pages.devices.type.health.instructions.page2.part1")} ", - style: healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, ), ), TextSpan( text: "${Platform.isAndroid ? locale.translate("pages.devices.type.health.instructions.page2.android.allow_all") : locale.translate("pages.devices.type.health.instructions.page2.ios.turn_on_all")} ", - style: healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .primary, // Change to desired color ), ), TextSpan( text: "${locale.translate("pages.devices.type.health.instructions.page2.part2")} ", - style: healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, ), ), TextSpan( text: "${locale.translate("pages.devices.type.health.instructions.page2.allow")} ", - style: healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .primary, // Change to desired color ), ), @@ -87,9 +87,9 @@ class HealthServiceConnectPage extends StatelessWidget { "pages.devices.type.health.instructions.page2.part3.android") : locale.translate( "pages.devices.type.health.instructions.page2.part3.ios"), - style: healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, ), ), @@ -126,7 +126,7 @@ class HealthServiceConnectPage extends StatelessWidget { ), style: ElevatedButton.styleFrom( backgroundColor: - Theme.of(context).extension()!.primary, + Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), diff --git a/lib/ui/pages/devices_page.list_title.dart b/lib/ui/pages/devices_page.list_title.dart index 339c4c7f..19f57309 100644 --- a/lib/ui/pages/devices_page.list_title.dart +++ b/lib/ui/pages/devices_page.list_title.dart @@ -23,8 +23,8 @@ class DevicesPageListTitle extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), child: Text( locale.translate("pages.devices.${type.name}.title").toUpperCase(), - style: dataCardTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey900, + style: fs16fw400ls1.copyWith( + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold)), ), ); diff --git a/lib/ui/pages/enable_connection_dialog.dart b/lib/ui/pages/enable_connection_dialog.dart index 5ebb653d..cf5c7776 100644 --- a/lib/ui/pages/enable_connection_dialog.dart +++ b/lib/ui/pages/enable_connection_dialog.dart @@ -37,7 +37,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { Text( locale.translate( "pages.login.internet_connection.enable_internet_connections.general_message"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), Padding( @@ -45,7 +45,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { child: Text( locale.translate( "pages.login.internet_connection.enable_internet_connections.wifi_message"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, )), Padding( @@ -60,7 +60,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { child: Text( locale.translate( "pages.login.internet_connection.enable_internet_connections.mobile_data_message"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ), @@ -111,7 +111,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { Text( locale.translate( "pages.login.internet_connection.enable_internet_connections.general_message"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), Padding( @@ -119,7 +119,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { child: Text( locale.translate( "pages.login.internet_connection.enable_internet_connections.wifi_message"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, )), Padding( @@ -135,7 +135,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { Text( locale.translate( "pages.login.internet_connection.enable_internet_connections.mobile_data_message"), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ]), diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 1257b9a2..791a9920 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -103,14 +103,15 @@ class HomePageState extends State { }); return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: widget.child, ), bottomNavigationBar: BottomNavigationBar( - backgroundColor: Theme.of(context).extension()!.white, + backgroundColor: Theme.of(context).extension()!.white, type: BottomNavigationBarType.fixed, - selectedItemColor: Theme.of(context).extension()!.primary, + selectedItemColor: Theme.of(context).extension()!.primary, //unselectedItemColor: Theme.of(context).primaryColor.withOpacity(0.8), items: [ BottomNavigationBarItem( diff --git a/lib/ui/pages/home_page.install_health_connect_dialog.dart b/lib/ui/pages/home_page.install_health_connect_dialog.dart index 2a60a0cf..2129ece0 100644 --- a/lib/ui/pages/home_page.install_health_connect_dialog.dart +++ b/lib/ui/pages/home_page.install_health_connect_dialog.dart @@ -14,7 +14,7 @@ class InstallHealthConnectDialog extends StatelessWidget { ), content: Text( locale.translate('pages.about.install_health_connect.description'), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), actions: [ diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index 0180d634..d6ddbadb 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -9,7 +9,8 @@ class InvitationListPage extends StatelessWidget { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: FutureBuilder>( future: bloc.backend.getInvitations(), builder: (context, snapshot) { @@ -38,7 +39,7 @@ class InvitationListPage extends StatelessWidget { slivers: [ SliverAppBar( backgroundColor: - Theme.of(context).extension()!.backgroundGray, + Theme.of(context).extension()!.backgroundGray, title: const CarpAppBar(), centerTitle: true, pinned: true, @@ -121,7 +122,7 @@ class InvitationMaterial extends StatelessWidget { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), @@ -138,7 +139,7 @@ class InvitationMaterial extends StatelessWidget { Text( invitation.invitation.name, maxLines: 1, - style: studyTitleStyle.copyWith( + style: fs24fw600.copyWith( color: CACHET.TASK_COMPLETED_BLUE, overflow: TextOverflow.ellipsis), ), @@ -148,15 +149,17 @@ class InvitationMaterial extends StatelessWidget { TextSpan( text: locale.translate( 'invitation_list.roles_in_the_study.description'), - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs16fw700.copyWith( + color: + Theme.of(context).extension()!.grey600, fontSize: 12, ), ), TextSpan( text: invitation.participantRoleName, - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context).extension()!.grey600, + style: fs16fw700.copyWith( + color: + Theme.of(context).extension()!.grey600, fontSize: 12, ), ), @@ -166,8 +169,8 @@ class InvitationMaterial extends StatelessWidget { Text( invitation.invitation.description ?? '', maxLines: 2, - style: studyDetailsInfoTitle.copyWith( - color: Theme.of(context).extension()!.grey900, + style: fs16fw700.copyWith( + color: Theme.of(context).extension()!.grey900, overflow: TextOverflow.ellipsis, ), ), diff --git a/lib/ui/pages/invitation_page.dart b/lib/ui/pages/invitation_page.dart index 76199656..c7084b18 100644 --- a/lib/ui/pages/invitation_page.dart +++ b/lib/ui/pages/invitation_page.dart @@ -17,7 +17,8 @@ class InvitationDetailsPage extends StatelessWidget { var invitation = model.getInvitation(invitationId); return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: SafeArea( @@ -59,7 +60,7 @@ class InvitationDetailsPage extends StatelessWidget { padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( backgroundColor: - Theme.of(context).extension()!.white!, + Theme.of(context).extension()!.white!, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), @@ -93,7 +94,7 @@ class InvitationDetailsPage extends StatelessWidget { padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( backgroundColor: - Theme.of(context).extension()!.white!, + Theme.of(context).extension()!.white!, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), @@ -118,7 +119,7 @@ class InvitationDetailsPage extends StatelessWidget { fontWeight: FontWeight.bold, fontSize: 22.0, color: Theme.of(context) - .extension()! + .extension()! .primary, ), ), @@ -133,7 +134,7 @@ class InvitationDetailsPage extends StatelessWidget { fontWeight: FontWeight.bold, fontSize: 14, color: Theme.of(context) - .extension()! + .extension()! .grey600, ), maxLines: 1, diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 09d7ec2e..757cf560 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -42,7 +42,7 @@ class MessageDetailsPage extends StatelessWidget { left: 26, right: 10, top: 16, bottom: 16), icon: Icon( Icons.arrow_back_ios, - color: Theme.of(context).extension()!.grey600, + color: Theme.of(context).extension()!.grey600, ), onPressed: () { if (context.canPop()) { @@ -55,9 +55,9 @@ class MessageDetailsPage extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), child: Text(locale.translate(message.title!), - style: aboutCardTitleStyle.copyWith( + style: fs20fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900)), ), Spacer(), @@ -75,8 +75,7 @@ class MessageDetailsPage extends StatelessWidget { .split('.') .last .toLowerCase()), - style: aboutCardSubtitleStyle.copyWith( - color: Colors.white)), + style: fs16fw600.copyWith(color: Colors.white)), ), ), ), @@ -92,9 +91,9 @@ class MessageDetailsPage extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 10.0, vertical: 6.0), child: Text(locale.translate(message.subTitle!), - style: aboutCardContentStyle.copyWith( + style: fs16fw400.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey700)), ) : const SizedBox.shrink(), @@ -123,9 +122,9 @@ class MessageDetailsPage extends StatelessWidget { if (message.message != null) Text( locale.translate(message.message!), - style: aboutCardContentStyle.copyWith( + style: fs16fw400.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900), textAlign: TextAlign.justify, ) diff --git a/lib/ui/pages/process_message_page.dart b/lib/ui/pages/process_message_page.dart index df00b325..d9b4b131 100644 --- a/lib/ui/pages/process_message_page.dart +++ b/lib/ui/pages/process_message_page.dart @@ -70,11 +70,9 @@ class ProcessMessagePage extends StatelessWidget { const SizedBox(height: 40), messageImage(), const SizedBox(height: 20), - Center( - child: - Text(locale.translate(title), style: audioTitleStyle)), + Center(child: Text(locale.translate(title), style: fs22fw700)), const SizedBox(height: 10), - Text(locale.translate(description), style: audioContentStyle), + Text(locale.translate(description), style: fs16fw600), ]), ), bottomSheet: Row( diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 14e53592..4b390de4 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -26,7 +26,7 @@ class ProfilePageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.grey100, + backgroundColor: Theme.of(context).extension()!.grey100, body: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, @@ -43,7 +43,7 @@ class ProfilePageState extends State { icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), label: Text(locale.translate("pages.profile.title"), - style: aboutCardTitleStyle.copyWith( + style: fs20fw700.copyWith( color: Theme.of(context).primaryColor)), ), IconButton( @@ -215,7 +215,7 @@ class ProfilePageState extends State { Widget _buildSectionCard(BuildContext context, List children) { return Card( - color: Theme.of(context).extension()!.grey50, + color: Theme.of(context).extension()!.grey50, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), @@ -225,7 +225,7 @@ class ProfilePageState extends State { children: ListTile.divideTiles( context: context, tiles: children, - color: Theme.of(context).extension()!.grey400, + color: Theme.of(context).extension()!.grey400, ).toList(), ), ), @@ -234,14 +234,13 @@ class ProfilePageState extends State { Widget _buildListTile(String title, String subtitle) { return ListTile( - title: Text(title, - style: profileSectionStyle.copyWith(color: CACHET.GREY_6)), + title: Text(title, style: fs12fw600.copyWith(color: CACHET.GREY_6)), subtitle: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( subtitle, - style: profileTitleStyle, + style: fs14fw600, maxLines: 1, ), ), @@ -258,8 +257,8 @@ class ProfilePageState extends State { return ListTile( leading: leading, title: Text(title, - style: profileActionStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), + style: fs16fw600.copyWith( + color: Theme.of(context).extension()!.grey900)), trailing: trailing, onTap: onTap, contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index 3270e39f..6f1673fc 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -10,10 +10,11 @@ class StudyDetailsPage extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Container( - color: Theme.of(context).extension()!.backgroundGray, + color: Theme.of(context).extension()!.backgroundGray, child: Column( children: [ Padding( @@ -28,7 +29,7 @@ class StudyDetailsPage extends StatelessWidget { left: 26, right: 10, top: 16, bottom: 16), icon: Icon( Icons.arrow_back_ios, - color: Theme.of(context).extension()!.grey600, + color: Theme.of(context).extension()!.grey600, ), onPressed: () { if (context.canPop()) { @@ -41,9 +42,9 @@ class StudyDetailsPage extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), child: Text(locale.translate(model.title), - style: aboutCardTitleStyle.copyWith( + style: fs20fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .primary)), ), ], @@ -76,7 +77,7 @@ class StudyDetailsPage extends StatelessWidget { context: context, leading: Icon(Icons.mail, color: Theme.of(context) - .extension()! + .extension()! .primary), trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), @@ -92,7 +93,7 @@ class StudyDetailsPage extends StatelessWidget { context: context, leading: Icon(Icons.policy, color: Theme.of(context) - .extension()! + .extension()! .primary), trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), @@ -111,7 +112,7 @@ class StudyDetailsPage extends StatelessWidget { context: context, leading: Icon(Icons.public, color: Theme.of(context) - .extension()! + .extension()! .primary), trailing: const Icon(Icons.arrow_forward_ios, color: CACHET.GREY_6), @@ -136,53 +137,53 @@ class StudyDetailsPage extends StatelessWidget { children: [ Text( locale.translate('widgets.study_card.responsible'), - style: studyDetailsInfoTitle.copyWith( + style: fs16fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.responsibleName), - style: studyDetailsInfoMessage.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey700), ), ), Text( locale.translate( 'widgets.study_card.participant_role'), - style: studyDetailsInfoTitle.copyWith( + style: fs16fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.participantRole), - style: studyDetailsInfoMessage.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey700), ), ), Text( locale.translate('widgets.study_card.device_role'), - style: studyDetailsInfoTitle.copyWith( + style: fs16fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.deviceRole), - style: studyDetailsInfoMessage.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey700), ), ), @@ -190,7 +191,7 @@ class StudyDetailsPage extends StatelessWidget { ), ), Divider( - color: Theme.of(context).extension()!.grey300, + color: Theme.of(context).extension()!.grey300, ), Padding( padding: const EdgeInsets.only(top: 16.0), @@ -201,36 +202,36 @@ class StudyDetailsPage extends StatelessWidget { Text( locale.translate( 'widgets.study_card.study_description'), - style: studyDetailsInfoTitle.copyWith( + style: fs16fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.description), - style: studyDetailsInfoMessage.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey700), ), ), Text( locale .translate('widgets.study_card.study_purpose'), - style: studyDetailsInfoTitle.copyWith( + style: fs16fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.purpose), - style: studyDetailsInfoMessage.copyWith( + style: fs12fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey700), ), ), @@ -250,7 +251,7 @@ class StudyDetailsPage extends StatelessWidget { Widget _buildSectionCard(BuildContext context, List children) { return Card( margin: EdgeInsets.zero, - color: Theme.of(context).extension()!.white, + color: Theme.of(context).extension()!.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), @@ -260,7 +261,7 @@ class StudyDetailsPage extends StatelessWidget { children: ListTile.divideTiles( context: context, tiles: children, - color: Theme.of(context).extension()!.grey300, + color: Theme.of(context).extension()!.grey300, ).toList(), ), ), @@ -278,8 +279,8 @@ class StudyDetailsPage extends StatelessWidget { return ListTile( leading: leading, title: Text(title, - style: profileActionStyle.copyWith( - color: Theme.of(context).extension()!.grey900)), + style: fs16fw600.copyWith( + color: Theme.of(context).extension()!.grey900)), trailing: trailing, onTap: onTap, contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index f89af343..6b3352b9 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -13,7 +13,8 @@ class StudyPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -87,7 +88,8 @@ class StudyPageState extends State { builder: (context, snapshot) { if (snapshot.data == true) { return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: + Theme.of(context).extension()!.grey50!, elevation: 8, child: Padding( padding: const EdgeInsets.only(left: 16.0), @@ -98,9 +100,9 @@ class StudyPageState extends State { padding: const EdgeInsets.all(8.0), child: Text( locale.translate('pages.about.app_update'), - style: aboutCardSubtitleStyle.copyWith( + style: fs16fw600.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, ), ), @@ -149,7 +151,7 @@ class StudyPageState extends State { timeago.setLocaleMessages('es', timeago.EsMessages()); return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: InkWell( onTap: () { if (onTap != null) { @@ -175,9 +177,10 @@ class StudyPageState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text(locale.translate(message.title!), - style: aboutStudyCardTitleStyle.copyWith( - color: - Theme.of(context).extension()!.primary)), + style: fs24fw700.copyWith( + color: Theme.of(context) + .extension()! + .primary)), ), if (message.subTitle != null && message.subTitle!.isNotEmpty) Row( @@ -185,9 +188,10 @@ class StudyPageState extends State { Expanded( child: Text( locale.translate(message.subTitle!), - style: aboutCardContentStyle.copyWith( - color: - Theme.of(context).extension()!.grey700, + style: fs16fw400.copyWith( + color: Theme.of(context) + .extension()! + .grey700, ), ), ), @@ -198,9 +202,9 @@ class StudyPageState extends State { Expanded( child: Text( "${locale.translate(message.message!).substring(0, (message.message!.length > 150) ? 150 : null)}...", - style: aboutCardContentStyle.copyWith( + style: fs16fw400.copyWith( color: - Theme.of(context).extension()!.grey900), + Theme.of(context).extension()!.grey900), textAlign: TextAlign.start, )), ]), @@ -249,7 +253,7 @@ class StudyPageState extends State { return StudiesMaterial( margin: const EdgeInsets.all(16.0), - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), @@ -290,7 +294,7 @@ class StudyPageState extends State { .split('.') .last, maxLines: 2, - style: aboutCardSubtitleStyle.copyWith( + style: fs16fw600.copyWith( color: studyStatusColors[deploymentStatus]), ), ), @@ -301,9 +305,9 @@ class StudyPageState extends State { padding: const EdgeInsets.only(left: 16.0), child: Text( getStatusText(locale, deploymentStatus, snapshot), - style: aboutCardSubtitleStyle.copyWith( + style: fs16fw600.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, fontSize: 14, ), @@ -335,8 +339,8 @@ class StudyPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(locale.translate('Announcements'), - style: aboutStudyCardTitleStyle.copyWith( - color: Theme.of(context).extension()!.grey900, + style: fs24fw700.copyWith( + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold, )), ], @@ -359,7 +363,7 @@ class StudyPageState extends State { return Container( child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: InkWell( onTap: () { if (onTap != null) { @@ -383,9 +387,9 @@ class StudyPageState extends State { child: Text( locale.translate(message.title!), overflow: TextOverflow.ellipsis, - style: aboutCardTitleStyle.copyWith( + style: fs20fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, ), ), @@ -410,9 +414,9 @@ class StudyPageState extends State { Expanded( child: Text( locale.translate(message.subTitle!), - style: aboutCardContentStyle.copyWith( + style: fs16fw400.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey700, ), ), @@ -420,9 +424,10 @@ class StudyPageState extends State { Spacer(), Text( timeago.format(message.timestamp.toLocal()), - style: aboutCardTimeAgoStyle.copyWith( - color: - Theme.of(context).extension()!.grey600, + style: fs10fw600.copyWith( + color: Theme.of(context) + .extension()! + .grey600, ), ) ], @@ -436,7 +441,7 @@ class StudyPageState extends State { locale.translate(message.message!).length > 150 ? '${locale.translate(message.message!).substring(0, 150)}...' : locale.translate(message.message!), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.start, )), ], diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 1aec37ac..fe8fc074 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -29,7 +29,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: Theme.of(context).extension()!.grey200, + color: Theme.of(context).extension()!.grey200, ), child: _tabBar, ); @@ -69,7 +69,7 @@ class TaskListPageState extends State length: 2, child: Scaffold( backgroundColor: - Theme.of(context).extension()!.backgroundGray, + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -97,9 +97,9 @@ class TaskListPageState extends State alignment: Alignment.centerLeft, child: Text( locale.translate('pages.task_list.title'), - style: aboutStudyCardTitleStyle.copyWith( + style: fs24fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .grey900, fontWeight: FontWeight.bold, ), @@ -125,10 +125,10 @@ class TaskListPageState extends State labelPadding: const EdgeInsets.only( top: 4, bottom: 4, left: 4, right: 4), labelColor: Theme.of(context) - .extension()! + .extension()! .grey900, unselectedLabelColor: Theme.of(context) - .extension()! + .extension()! .grey900, dividerColor: Colors.transparent, indicator: ShapeDecoration( @@ -136,7 +136,7 @@ class TaskListPageState extends State borderRadius: BorderRadius.circular(8), ), color: Theme.of(context) - .extension()! + .extension()! .white, ), tabs: [ @@ -208,7 +208,7 @@ class TaskListPageState extends State right: Radius.circular(8.0), ), ), - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: IntrinsicHeight( @@ -291,7 +291,7 @@ class TaskListPageState extends State backgroundColor: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 ? CACHET.TASK_TO_EXPIRE_BACKGROUND - : Theme.of(context).extension()!.grey50!, + : Theme.of(context).extension()!.grey50!, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: IntrinsicHeight( @@ -328,7 +328,7 @@ class TaskListPageState extends State color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 ? Theme.of(context) - .extension()! + .extension()! .warningColor : Colors.grey, ), @@ -341,7 +341,7 @@ class TaskListPageState extends State color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 ? Theme.of(context) - .extension()! + .extension()! .warningColor : Colors.grey, fontSize: 12.0, @@ -411,7 +411,7 @@ class TaskListPageState extends State userTask.onDone(); ScaffoldMessenger.of(context).showSnackBar(SnackBar( backgroundColor: - Theme.of(context).extension()!.grey700, + Theme.of(context).extension()!.grey700, content: Text(locale.translate('Done!')), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), @@ -453,7 +453,7 @@ class TaskListPageState extends State return Icon(originalIcon.icon, color: CACHET.TASK_COMPLETED_BLUE); } else { return Icon(originalIcon.icon, - color: Theme.of(context).extension()!.grey600); + color: Theme.of(context).extension()!.grey600); } }, ); @@ -493,7 +493,7 @@ class TaskListPageState extends State return Center( child: GestureDetector( child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, hasBorder: true, shape: RoundedRectangleBorder( borderRadius: BorderRadius.horizontal( @@ -541,7 +541,7 @@ class TaskListPageState extends State color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 ? Theme.of(context) - .extension()! + .extension()! .warningColor : Colors.grey, fontSize: 12.0, @@ -600,7 +600,7 @@ class TaskListPageState extends State padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), child: Text( locale.translate("pages.task_list.no_tasks"), - style: aboutCardSubtitleStyle, + style: fs16fw600, textAlign: TextAlign.center, )) ], diff --git a/lib/ui/tasks/audio_page.dart b/lib/ui/tasks/audio_page.dart index 74f9da9a..f236941e 100644 --- a/lib/ui/tasks/audio_page.dart +++ b/lib/ui/tasks/audio_page.dart @@ -37,7 +37,7 @@ class AudioPageState extends State { Spacer(), IconButton( color: Theme.of(context) - .extension()! + .extension()! .grey900!, onPressed: () { _showCancelConfirmationDialog(); @@ -66,16 +66,16 @@ class AudioPageState extends State { locale.translate( widget.audioUserTask!.title, ), - style: audioTitleStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .primary, ), ), ), StudiesMaterial( backgroundColor: Theme.of(context) - .extension()! + .extension()! .white!, child: Scrollbar( child: SingleChildScrollView( @@ -89,7 +89,7 @@ class AudioPageState extends State { widget.audioUserTask! .instructions, ), - style: audioContentStyle, + style: fs16fw600, ), ), ), @@ -99,7 +99,7 @@ class AudioPageState extends State { CircleAvatar( radius: 30, backgroundColor: Theme.of(context) - .extension()! + .extension()! .primary, child: IconButton( onPressed: () => widget.audioUserTask! @@ -118,7 +118,7 @@ class AudioPageState extends State { child: Text( locale.translate( "pages.audio_task.play"), - style: audioContentStyle, + style: fs16fw600, ), ), ], @@ -135,16 +135,16 @@ class AudioPageState extends State { locale.translate( widget.audioUserTask!.title, ), - style: audioTitleStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .primary, ), ), ), StudiesMaterial( backgroundColor: Theme.of(context) - .extension()! + .extension()! .white!, child: Scrollbar( child: SingleChildScrollView( @@ -157,7 +157,7 @@ class AudioPageState extends State { locale.translate(widget .audioUserTask! .instructions), - style: audioContentStyle, + style: fs16fw600, ), ), ), @@ -193,7 +193,7 @@ class AudioPageState extends State { child: Text( locale.translate( "pages.audio_task.recording"), - style: audioTitleStyle, + style: fs22fw700, ), ), ], @@ -209,9 +209,9 @@ class AudioPageState extends State { child: Text( locale.translate( 'pages.audio_task.done'), - style: audioTitleStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context) - .extension()! + .extension()! .primary, ), ), @@ -222,7 +222,7 @@ class AudioPageState extends State { child: Text( locale.translate( 'pages.audio_task.recording_completed'), - style: audioContentStyle), + style: fs16fw600), ), Spacer(), Padding( @@ -245,7 +245,7 @@ class AudioPageState extends State { icon: Icon( Icons.replay, color: Theme.of(context) - .extension()! + .extension()! .grey700, size: 30, ), diff --git a/lib/ui/tasks/audio_task_page.dart b/lib/ui/tasks/audio_task_page.dart index aa589ef8..46518caa 100644 --- a/lib/ui/tasks/audio_task_page.dart +++ b/lib/ui/tasks/audio_task_page.dart @@ -33,8 +33,9 @@ class AudioTaskPageState extends State { ), Spacer(), IconButton( - color: - Theme.of(context).extension()!.grey900!, + color: Theme.of(context) + .extension()! + .grey900!, onPressed: () { _showCancelConfirmationDialog(); }, @@ -55,7 +56,7 @@ class AudioTaskPageState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text(locale.translate(widget.audioUserTask!.title), - style: audioTitleStyle), + style: fs22fw700), ), Padding( padding: const EdgeInsets.symmetric( @@ -63,7 +64,7 @@ class AudioTaskPageState extends State { child: Text( '${locale.translate(widget.audioUserTask!.description)}\n\n' '${locale.translate('pages.audio_task.play')}', - style: audioContentStyle, + style: fs16fw600, ), ), Padding( @@ -89,7 +90,7 @@ class AudioTaskPageState extends State { ), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context) - .extension()! + .extension()! .primary, padding: const EdgeInsets.symmetric( horizontal: 30, diff --git a/lib/ui/tasks/camera_task_page.dart b/lib/ui/tasks/camera_task_page.dart index 251d36e9..6d827520 100644 --- a/lib/ui/tasks/camera_task_page.dart +++ b/lib/ui/tasks/camera_task_page.dart @@ -45,7 +45,7 @@ class CameraTaskPageState extends State { Spacer(), IconButton( color: Theme.of(context) - .extension()! + .extension()! .grey900!, onPressed: () { _showCancelConfirmationDialog(); @@ -74,7 +74,7 @@ class CameraTaskPageState extends State { child: Text( locale.translate( widget.mediaUserTask.title), - style: audioTitleStyle, + style: fs22fw700, ), ), Padding( @@ -83,7 +83,7 @@ class CameraTaskPageState extends State { child: Text( locale.translate( widget.mediaUserTask.description), - style: audioContentStyle, + style: fs16fw600, ), ), Padding( @@ -112,7 +112,7 @@ class CameraTaskPageState extends State { ), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context) - .extension()! + .extension()! .primary, padding: const EdgeInsets.symmetric( horizontal: 30, diff --git a/lib/ui/tasks/display_picture_page.dart b/lib/ui/tasks/display_picture_page.dart index bd7df449..29cff952 100644 --- a/lib/ui/tasks/display_picture_page.dart +++ b/lib/ui/tasks/display_picture_page.dart @@ -60,7 +60,7 @@ class DisplayPicturePageState extends State { ), Spacer(), IconButton( - color: Theme.of(context).extension()!.grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, @@ -97,14 +97,14 @@ class DisplayPicturePageState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text(locale.translate('pages.audio_task.done'), - style: audioTitleStyle), + style: fs22fw700), ), const SizedBox(height: 40), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( locale.translate('pages.audio_task.recording_completed'), - style: audioContentStyle, + style: fs16fw600, ), ), const SizedBox(height: 20), diff --git a/lib/ui/tasks/participant_data_page.dart b/lib/ui/tasks/participant_data_page.dart index adb2ae4f..23c1320f 100644 --- a/lib/ui/tasks/participant_data_page.dart +++ b/lib/ui/tasks/participant_data_page.dart @@ -266,7 +266,8 @@ class ParticipantDataPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray!, + backgroundColor: + Theme.of(context).extension()!.backgroundGray!, body: SafeArea( child: Container( padding: const EdgeInsets.all(16.0), @@ -283,7 +284,7 @@ class ParticipantDataPageState extends State { ), Spacer(), IconButton( - color: Theme.of(context).extension()!.grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, @@ -357,7 +358,7 @@ class ParticipantDataPageState extends State { Flexible( child: Text( stepTitleMap[currentStep] ?? '', - style: healthServiceConnectMessageStyle.copyWith( + style: fs22fw700.copyWith( color: Theme.of(context).primaryColor, ), textAlign: TextAlign.center, @@ -548,7 +549,8 @@ class ParticipantDataPageState extends State { child: Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).extension()!.grey600!, + color: + Theme.of(context).extension()!.grey600!, width: 1.0, ), borderRadius: BorderRadius.circular(16.0), @@ -564,8 +566,9 @@ class ParticipantDataPageState extends State { showCountryOnly: true, showOnlyCountryWhenClosed: true, alignLeft: false, - textStyle: audioContentStyle.copyWith( - color: Theme.of(context).extension()!.grey900!, + textStyle: fs16fw600.copyWith( + color: + Theme.of(context).extension()!.grey900!, ), ), ), @@ -681,7 +684,7 @@ class ParticipantDataPageState extends State { _nextEnabled, ElevatedButton.styleFrom( backgroundColor: - Theme.of(context).extension()!.primary, + Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), @@ -702,7 +705,7 @@ class ParticipantDataPageState extends State { currentStep == ParticipantStep.presentTypes ? true : _nextEnabled, ElevatedButton.styleFrom( backgroundColor: - Theme.of(context).extension()!.primary, + Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), diff --git a/lib/ui/widgets/charts_legend.dart b/lib/ui/widgets/charts_legend.dart index 1c17583f..12f0a434 100644 --- a/lib/ui/widgets/charts_legend.dart +++ b/lib/ui/widgets/charts_legend.dart @@ -26,7 +26,7 @@ class ChartsLegend extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title.toUpperCase(), style: dataCardTitleStyle), + Text(title.toUpperCase(), style: fs16fw400ls1), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( @@ -39,7 +39,7 @@ class ChartsLegend extends StatelessWidget { children: [ Icon(Icons.circle, color: colors[entry.key], size: 12.0), - Text(' ${entry.value} ', style: legendStyle), + Text(' ${entry.value} ', style: fs12fw400), ], ), ) diff --git a/lib/ui/widgets/details_banner.dart b/lib/ui/widgets/details_banner.dart index d0f4c06b..cc070eb2 100644 --- a/lib/ui/widgets/details_banner.dart +++ b/lib/ui/widgets/details_banner.dart @@ -29,7 +29,7 @@ class DetailsBanner extends StatelessWidget { children: [ Text( locale.translate(title), - style: studyNameStyle.copyWith( + style: fs30fw800.copyWith( fontSize: 30, color: Theme.of(context).primaryColor), ), ], diff --git a/lib/ui/widgets/dialog_title.dart b/lib/ui/widgets/dialog_title.dart index 884f2155..51ed72ac 100644 --- a/lib/ui/widgets/dialog_title.dart +++ b/lib/ui/widgets/dialog_title.dart @@ -48,7 +48,7 @@ class DialogTitle extends StatelessWidget { (titleEnd != null ? ' ${locale.translate(titleEnd!)}' : ""), - style: sectionTitleStyle.copyWith( + style: fs18fw700.copyWith( color: Theme.of(context).primaryColor, ), textAlign: TextAlign.center, diff --git a/lib/ui/widgets/horizontal_bar.dart b/lib/ui/widgets/horizontal_bar.dart index 89e696b4..bc3b3aa6 100644 --- a/lib/ui/widgets/horizontal_bar.dart +++ b/lib/ui/widgets/horizontal_bar.dart @@ -158,7 +158,7 @@ class MyAssetsBar extends StatelessWidget { children: [ Icon(Icons.circle, color: entry.value.color, size: 12.0), Text(' ${entry.value.name!} ${entry.value.size}', - style: legendStyle, textAlign: TextAlign.right), + style: fs12fw400, textAlign: TextAlign.right), ], )), ) @@ -187,10 +187,10 @@ class MyAssetsBar extends StatelessWidget { children: [ Icon(Icons.circle, color: entry.value.color, size: 12.0), Text(' ${entry.value.size}', - style: legendStyle, textAlign: TextAlign.left), + style: fs12fw400, textAlign: TextAlign.left), Expanded( child: Text(' ${entry.value.name!}', - style: legendStyle, + style: fs12fw400, textAlign: TextAlign.left, overflow: TextOverflow.ellipsis)), ], diff --git a/lib/ui/widgets/location_permission_page.dart b/lib/ui/widgets/location_permission_page.dart index 5732b9f8..c4fce61a 100644 --- a/lib/ui/widgets/location_permission_page.dart +++ b/lib/ui/widgets/location_permission_page.dart @@ -5,7 +5,8 @@ class LocationPermissionPage { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: SafeArea( @@ -27,7 +28,7 @@ class LocationPermissionPage { padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( backgroundColor: - Theme.of(context).extension()!.white!, + Theme.of(context).extension()!.white!, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), @@ -55,7 +56,7 @@ class LocationPermissionPage { fontWeight: FontWeight.bold, fontSize: 22.0, color: Theme.of(context) - .extension()! + .extension()! .primary, ), ), @@ -67,14 +68,14 @@ class LocationPermissionPage { child: Icon( Icons.location_on, color: Theme.of(context) - .extension()! + .extension()! .primary, size: 48, ), ), Text( locale.translate(message), - style: aboutCardContentStyle.copyWith( + style: fs16fw400.copyWith( fontWeight: FontWeight.bold, ), textAlign: TextAlign.justify, diff --git a/lib/ui/widgets/location_usage_dialog.dart b/lib/ui/widgets/location_usage_dialog.dart index 35642004..33f5599a 100644 --- a/lib/ui/widgets/location_usage_dialog.dart +++ b/lib/ui/widgets/location_usage_dialog.dart @@ -16,7 +16,7 @@ class LocationUsageDialog { height: MediaQuery.of(context).size.height * 0.15, ), Text(locale.translate("dialog.location.permission"), - style: aboutCardTitleStyle), + style: fs20fw700), ], ), contentPadding: const EdgeInsets.all(15), @@ -28,7 +28,7 @@ class LocationUsageDialog { children: [ Text( locale.translate(message), - style: aboutCardContentStyle, + style: fs16fw400, textAlign: TextAlign.justify, ), ], diff --git a/pubspec.lock b/pubspec.lock index eb591be0..fcaa4258 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.1.1" build_resolvers: dependency: transitive description: @@ -205,10 +205,10 @@ packages: dependency: "direct main" description: name: camera_android_camerax - sha256: b68b638e5e0ede21155e670493ac568981a8f56c5f636d720935a916a1c5a0ef + sha256: d5256612833f9169c1698599a87370490622a188c5a7fb601169bb7b2f41f22b url: "https://pub.dev" source: hosted - version: "0.6.24" + version: "0.6.24+1" camera_avfoundation: dependency: transitive description: @@ -321,6 +321,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + carp_themes_package: + dependency: "direct main" + description: + name: carp_themes_package + sha256: "48e747c82392db406531289828b4143275b28736354fb9e7bc62cfe1a189d675" + url: "https://pub.dev" + source: hosted + version: "0.0.3" carp_webservices: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9e33afbf..e79d9a81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.1.1+87 +version: 4.2.0+88 environment: sdk: ">=3.2.0 <4.0.0" @@ -21,6 +21,7 @@ dependencies: carp_polar_package: ^1.6.1 carp_health_package: ^3.2.0 carp_movesense_package: ^1.7.2 + carp_themes_package: ^0.0.1 carp_webservices: ^3.8.0 carp_backend: ^1.9.2 From d9d0fd29da9298404ed1dbbefeed3a1e5fc5b0cb Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:10:32 +0100 Subject: [PATCH 14/94] fixing changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4174efc..40280f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ * moving to carp_themes_package instead of research package themes +## 4.1.1 + +- small visual fixes + ## 4.1.0 - Now supporting anonymous user authentication From ced8d7966c264728fd74b283ec739c9c61cee263 Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:20:03 +0100 Subject: [PATCH 15/94] formatting --- lib/ui/pages/devices_page.bluetooth_connection_page.dart | 6 +++--- lib/ui/pages/task_list_page.dart | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 02b9db9d..ac0d0690 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -330,9 +330,9 @@ class _BluetoothConnectionPageState extends State { .entries .map( (bluetoothDevice) => StudiesMaterial( - // hasBorder: true, - backgroundColor: - Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context) + .extension()! + .grey50!, child: InkWell( child: ListTile( selected: bluetoothDevice.key == selected, diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 1aec37ac..ddef55c6 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -45,7 +45,7 @@ class TaskListPageState extends State with TickerProviderStateMixin { late TabController _tabController; - bool? showParticipantDataCard = false; + bool showParticipantDataCard = false; @override void initState() { @@ -159,7 +159,7 @@ class TaskListPageState extends State ), ), ), - if (showParticipantDataCard!) + if (showParticipantDataCard) SliverToBoxAdapter( child: _buildParticipantDataCard(), ), From f7916542dfaea595b8786d0ebc062b33f5726efd Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:23:37 +0100 Subject: [PATCH 16/94] formating --- lib/ui/cards/scoreboard_card.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/ui/cards/scoreboard_card.dart b/lib/ui/cards/scoreboard_card.dart index 8a65d23d..bcdc706e 100644 --- a/lib/ui/cards/scoreboard_card.dart +++ b/lib/ui/cards/scoreboard_card.dart @@ -56,9 +56,7 @@ class ScoreboardPersistentHeaderDelegate Text(model.daysInStudy.toString(), style: fs36fw800.copyWith( fontSize: calculateScrollAwareSizing( - shrinkOffset, - fs20fw800.fontSize!, - fs36fw800.fontSize!), + shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), color: Theme.of(context).extension()!.grey900)), if (shrinkOffset < offsetForShrink) Text(locale.translate('cards.scoreboard.days'), @@ -77,9 +75,7 @@ class ScoreboardPersistentHeaderDelegate Text(model.taskCompleted.toString(), style: fs36fw800.copyWith( fontSize: calculateScrollAwareSizing( - shrinkOffset, - fs20fw800.fontSize!, - fs36fw800.fontSize!), + shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), color: Theme.of(context).extension()!.primary)), if (shrinkOffset < offsetForShrink) Text(locale.translate('cards.scoreboard.tasks'), From 8a4da1527401fee81365c29864c569c572178772 Mon Sep 17 00:00:00 2001 From: Panagiotis Giannoutsos <36935711+Panosfunk@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:10:47 +0100 Subject: [PATCH 17/94] removing unused package for android --- android/app/src/main/AndroidManifest.xml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 303b83a4..0356ad36 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -212,29 +212,6 @@ --> - - - - - - - - - - - Date: Fri, 14 Nov 2025 15:08:02 +0100 Subject: [PATCH 18/94] partial transition to spm from pods --- ios/Podfile | 2 +- ios/Podfile.lock | 144 +----------------- ios/Runner.xcodeproj/project.pbxproj | 44 ++++++ .../xcshareddata/swiftpm/Package.resolved | 23 +++ .../xcshareddata/xcschemes/Runner.xcscheme | 18 +++ .../xcshareddata/swiftpm/Package.resolved | 23 +++ pubspec.lock | 68 ++++++++- pubspec.yaml | 2 +- 8 files changed, 175 insertions(+), 149 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ios/Podfile b/ios/Podfile index 6e0ec481..9012b13d 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -35,7 +35,7 @@ target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) pod 'Movesense', :git => 'https://bitbucket.org/movesense/movesense-mobile-lib/' - pod 'PhoneNumberKit', '~> 3.7' + # pod 'PhoneNumberKit', '~> 3.7' end post_install do |installer| diff --git a/ios/Podfile.lock b/ios/Podfile.lock index faec3eff..087ce02c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,52 +1,21 @@ PODS: - - AppAuth (2.0.0): - - AppAuth/Core (= 2.0.0) - - AppAuth/ExternalUserAgent (= 2.0.0) - - AppAuth/Core (2.0.0) - - AppAuth/ExternalUserAgent (2.0.0): - - AppAuth/Core - - "appcheck (1.5.4+1)": - - Flutter - - audio_session (0.0.1): - - Flutter - audio_streamer (0.0.1): - Flutter - - battery_plus (1.0.0): - - Flutter - - camera_avfoundation (0.0.1): - - Flutter - - connectivity_plus (0.0.1): - - Flutter - dchs_flutter_beacon (0.6.5): - Flutter - - device_info_plus (0.0.1): - - Flutter - Flutter (1.0.0) - flutter_activity_recognition (0.0.1): - Flutter - - flutter_appauth (0.0.1): - - AppAuth (= 2.0.0) - - Flutter - - flutter_blue_plus_darwin (0.0.2): - - Flutter - - FlutterMacOS - - flutter_local_notifications (0.0.1): - - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_sound (9.28.0): - Flutter - flutter_sound_core (= 9.28.0) - flutter_sound_core (9.28.0) - - flutter_timezone (0.0.1): - - Flutter - flutter_web_auth_2 (3.0.0): - Flutter - health (12.2.1): - Flutter - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - location (0.0.1): - Flutter - mdsflutter (0.0.1): @@ -55,27 +24,14 @@ PODS: - SwiftProtobuf - Movesense (3.33.1) - MTBBarcodeScanner (5.0.11) - - network_info_plus (0.0.1): - - Flutter - oidc_ios (0.0.1): - Flutter - open_settings_plus (0.0.1): - Flutter - - package_info_plus (0.4.5): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - pedometer (0.0.1): - Flutter - permission_handler_apple (9.3.0): - Flutter - - PhoneNumberKit (3.8.0): - - PhoneNumberKit/PhoneNumberKitCore (= 3.8.0) - - PhoneNumberKit/UIKit (= 3.8.0) - - PhoneNumberKit/PhoneNumberKitCore (3.8.0) - - PhoneNumberKit/UIKit (3.8.0): - - PhoneNumberKit/PhoneNumberKitCore - polar (0.0.1): - Flutter - PolarBleSdk (~> 6.1.0) @@ -89,128 +45,65 @@ PODS: - RxSwift (6.8.0) - screen_state (1.0.0): - Flutter - - sensors_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - - SwiftProtobuf (1.32.0) - - url_launcher_ios (0.0.1): - - Flutter - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS + - SwiftProtobuf (1.33.3) - Zip (2.1.2) DEPENDENCIES: - - appcheck (from `.symlinks/plugins/appcheck/ios`) - - audio_session (from `.symlinks/plugins/audio_session/ios`) - audio_streamer (from `.symlinks/plugins/audio_streamer/ios`) - - battery_plus (from `.symlinks/plugins/battery_plus/ios`) - - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - dchs_flutter_beacon (from `.symlinks/plugins/dchs_flutter_beacon/ios`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_activity_recognition (from `.symlinks/plugins/flutter_activity_recognition/ios`) - - flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`) - - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) - - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - health (from `.symlinks/plugins/health/ios`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - location (from `.symlinks/plugins/location/ios`) - mdsflutter (from `.symlinks/plugins/mdsflutter/ios`) - Movesense (from `https://bitbucket.org/movesense/movesense-mobile-lib/`) - - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - oidc_ios (from `.symlinks/plugins/oidc_ios/ios`) - open_settings_plus (from `.symlinks/plugins/open_settings_plus/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - pedometer (from `.symlinks/plugins/pedometer/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - PhoneNumberKit (~> 3.7) - polar (from `.symlinks/plugins/polar/ios`) - qr_code_scanner_plus (from `.symlinks/plugins/qr_code_scanner_plus/ios`) - screen_state (from `.symlinks/plugins/screen_state/ios`) - - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) SPEC REPOS: trunk: - - AppAuth - flutter_sound_core - MTBBarcodeScanner - - PhoneNumberKit - PolarBleSdk - RxSwift - SwiftProtobuf - Zip EXTERNAL SOURCES: - appcheck: - :path: ".symlinks/plugins/appcheck/ios" - audio_session: - :path: ".symlinks/plugins/audio_session/ios" audio_streamer: :path: ".symlinks/plugins/audio_streamer/ios" - battery_plus: - :path: ".symlinks/plugins/battery_plus/ios" - camera_avfoundation: - :path: ".symlinks/plugins/camera_avfoundation/ios" - connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" dchs_flutter_beacon: :path: ".symlinks/plugins/dchs_flutter_beacon/ios" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_activity_recognition: :path: ".symlinks/plugins/flutter_activity_recognition/ios" - flutter_appauth: - :path: ".symlinks/plugins/flutter_appauth/ios" - flutter_blue_plus_darwin: - :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" - flutter_local_notifications: - :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_sound: :path: ".symlinks/plugins/flutter_sound/ios" - flutter_timezone: - :path: ".symlinks/plugins/flutter_timezone/ios" flutter_web_auth_2: :path: ".symlinks/plugins/flutter_web_auth_2/ios" health: :path: ".symlinks/plugins/health/ios" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" location: :path: ".symlinks/plugins/location/ios" mdsflutter: :path: ".symlinks/plugins/mdsflutter/ios" Movesense: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ - network_info_plus: - :path: ".symlinks/plugins/network_info_plus/ios" oidc_ios: :path: ".symlinks/plugins/oidc_ios/ios" open_settings_plus: :path: ".symlinks/plugins/open_settings_plus/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" pedometer: :path: ".symlinks/plugins/pedometer/ios" permission_handler_apple: @@ -221,16 +114,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/qr_code_scanner_plus/ios" screen_state: :path: ".symlinks/plugins/screen_state/ios" - sensors_plus: - :path: ".symlinks/plugins/sensors_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/darwin" CHECKOUT OPTIONS: Movesense: @@ -238,52 +121,31 @@ CHECKOUT OPTIONS: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ SPEC CHECKSUMS: - AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 - appcheck: 3c94d0ffc94bd639938cac7427d5b13df2795404 - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 audio_streamer: 2e472b9f81cec5e381c4cf7667afa3055dcb45a4 - battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977 - camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd dchs_flutter_beacon: 88d72cc467de508d621e454ea66c6231d0897d4c - device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_activity_recognition: 52dfad4c1e9cec99f0841b3ed0efcc9d424b3deb - flutter_appauth: d4abcf54856e5d8ba82ed7646ffc83245d4aa448 - flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c - flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 health: fe206e65f13a9b88623605fbd8af8f029e23dc35 - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed location: 155caecf9da4f280ab5fe4a55f94ceccfab838f8 mdsflutter: bd3c0b3335d50947d1a192cf70f930e0fb04a766 Movesense: dc64047c1feb856b7f7ac6ea2c67685350d99dd5 MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb - network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc oidc_ios: 16966cad509ce6850ca4ca1216c5138bef2a8726 open_settings_plus: d19f91e8a04649358a51c19b484ce2e637149d70 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 pedometer: 1c5eaab0c6bce8eb7651f7095553b5081c9d06ed permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - PhoneNumberKit: ec00ab8cef5342c1dc49fadb99d23fa7e66cf0ef polar: 0374f6063ade7e2dd85d626f87cbe2393d3eda7c PolarBleSdk: bfb57cf8350f0c53d441b8632ba6df363ce7c79f qr_code_scanner_plus: 7e087021bc69873140e0754750eb87d867bed755 RxSwift: 4e28be97cbcfeee614af26d83415febbf2bf6f45 screen_state: 52d6e997d31bddba6417c60d9cdd22effd0320a7 - sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - SwiftProtobuf: 81e341191afbddd64aa031bd12862dccfab2f639 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a + SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -PODFILE CHECKSUM: 0f233b2493d660073cf18073d2b24e7b319ab4a8 +PODFILE CHECKSUM: 45842edf150243fea66883d4dfad1e5ddb428147 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 516f6216..d1a2b803 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,9 +8,11 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1DF7245C2EC76C6800F24410 /* PhoneNumberKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1DF7245B2EC76C6800F24410 /* PhoneNumberKit */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -53,6 +55,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -78,6 +81,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1DF7245C2EC76C6800F24410 /* PhoneNumberKit in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, D93ECDADCF301D48CCE6DCE8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -106,6 +111,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -199,6 +205,10 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + 1DF7245B2EC76C6800F24410 /* PhoneNumberKit */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -233,6 +243,10 @@ da, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + 1DF7245A2EC76C6800F24410 /* XCRemoteSwiftPackageReference "PhoneNumberKit" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -734,6 +748,36 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1DF7245A2EC76C6800F24410 /* XCRemoteSwiftPackageReference "PhoneNumberKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/marmelroy/PhoneNumberKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.6.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1DF7245B2EC76C6800F24410 /* PhoneNumberKit */ = { + isa = XCSwiftPackageProductDependency; + package = 1DF7245A2EC76C6800F24410 /* XCRemoteSwiftPackageReference "PhoneNumberKit" */; + productName = PhoneNumberKit; + }; + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..7f1c67b8 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS", + "state" : { + "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", + "version" : "2.0.0" + } + }, + { + "identity" : "phonenumberkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/PhoneNumberKit.git", + "state" : { + "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", + "version" : "3.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index fa4cdb69..e596a992 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Date: Fri, 14 Nov 2025 15:33:52 +0100 Subject: [PATCH 19/94] bumping version code and changelog --- CHANGELOG.md | 5 +++++ pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40280f63..80549beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ ## 4.2.0 * moving to carp_themes_package instead of research package themes +* partially transitioning from pods to spm +* health connect flow update +* informed consent accepted is now checked via API instead of local settings +* fixing hasSeenBluetoothInstructions bool logic +* small UI changes ## 4.1.1 diff --git a/pubspec.yaml b/pubspec.yaml index 3aa37e46..be151299 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.2.0+89 +version: 4.2.0+90 environment: sdk: ">=3.2.0 <4.0.0" From ba8b96bface9e988b13c2c7cafaf771e5593fb0f Mon Sep 17 00:00:00 2001 From: Miklos Havlik Date: Wed, 13 May 2026 16:05:07 +0200 Subject: [PATCH 20/94] Simplify informed consent flow and re-seed cached study on init - Re-seed the cached study in CarpBackend.initialize() so returning users don't hit null-derefs in CarpResourceManager. - Replace the cached _hasInformedConsentBeenAccepted flag and the refreshInformedConsentStatus / markInformedConsentNotRequired helpers with one async getter that asks the backend and falls back to LocalSettings when offline. - Make the home redirect async and check auth, deployment, consent in dependency order; drop the duplicate redirect on InvitationListPage. - Drop java.configuration.updateBuildConfiguration from the workspace file (triggered a Red Hat Java extension prompt in VS Code). --- carp_study_app.code-workspace | 1 - lib/blocs/app_bloc.dart | 47 ++++++++++++++----------- lib/carp_study_app.dart | 40 ++++++++------------- lib/data/carp_backend.dart | 6 ++++ lib/ui/pages/informed_consent_page.dart | 6 ++-- 5 files changed, 51 insertions(+), 49 deletions(-) diff --git a/carp_study_app.code-workspace b/carp_study_app.code-workspace index 29fcf7e1..5f75755c 100644 --- a/carp_study_app.code-workspace +++ b/carp_study_app.code-workspace @@ -21,7 +21,6 @@ "dart.debugExternalPackageLibraries": false, "dart.debugSdkLibraries": false, "dart.openDevTools": "never", - "java.configuration.updateBuildConfiguration": "automatic", }, "extensions": { "recommendations": [ diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 3fd87f5c..833cef55 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -166,7 +166,9 @@ class StudyAppBLoC extends ChangeNotifier { Sensing(); - // Initialize and use the CAWS backend if not in local deployment mode + // Initialize and use the CAWS backend if not in local deployment mode. + // backend.initialize() re-seeds the cached study into CAWS service + // singletons, so a returning user doesn't hit null-derefs. if (deploymentMode != DeploymentMode.local) { if (await checkConnectivity()) { await backend.initialize(); @@ -351,31 +353,34 @@ class StudyAppBLoC extends ChangeNotifier { informedConsentManager.getInformedConsent(refresh: refresh); /// Has the informed consent been accepted by the user? - bool get hasInformedConsentBeenAccepted => - backend.getInformedConsentByRole( - study!.studyDeploymentId, study!.participantRoleName) != - null; + /// Local mode trusts [LocalSettings]; otherwise asks the backend, falling + /// back to [LocalSettings] when offline so signed users aren't re-prompted. + Future get hasInformedConsentBeenAccepted async { + final local = + LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; + if (deploymentMode == DeploymentMode.local || study == null) return local; + 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'); + return local; + } + } - set hasInformedConsentBeenAccepted(bool accepted) { + /// 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 informedConsentHasBeenAccepted([RPTaskResult? result]) async { + info('Informed consent has been accepted by user.'); var participant = LocalSettings().participant; participant?.hasInformedConsentBeenAccepted = true; LocalSettings().participant = participant; - } - - /// Mark the informed consent as accepted by the user based on the - /// [informedConsentResult]. - /// - /// This entails that it has been shown to the user and accepted by the user. - /// Will upload it to CAWS (if not running in local deployment mode). - Future informedConsentHasBeenAccepted( - RPTaskResult informedConsentResult, - ) async { - info('Informed consent has been accepted by user.'); - hasInformedConsentBeenAccepted = true; - - if (deploymentMode != DeploymentMode.local) { - await backend.uploadInformedConsent(informedConsentResult); + if (result != null && deploymentMode != DeploymentMode.local) { + await backend.uploadInformedConsent(result); } + notifyListeners(); } /// Refresh the list of messages (news, announcements, articles) to be shown in diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 43ac5389..c919eed4 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -8,8 +8,7 @@ class CarpStudyApp extends StatefulWidget { /// Reload language translations and re-build the entire app. static void reloadLocale(BuildContext context) async { - CarpStudyAppState? state = - context.findAncestorStateOfType(); + CarpStudyAppState? state = context.findAncestorStateOfType(); state?.reloadLocale(); } @@ -34,8 +33,7 @@ class CarpStudyAppState extends State { routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, - builder: (BuildContext context, GoRouterState state, Widget child) => - HomePage(child: child), + builder: (BuildContext context, GoRouterState state, Widget child) => HomePage(child: child), routes: [ // This is the root route, handling the onboarding. // The flow of logic is: @@ -50,17 +48,16 @@ class CarpStudyAppState extends State { path: homeRoute, parentNavigatorKey: _shellNavigatorKey, redirect: (context, state) async { - if (bloc.deploymentMode != DeploymentMode.local) { - if (!bloc.backend.isAuthenticated) { - return LoginPage.route; - } else if (!bloc.hasStudyBeenDeployed) { - return InvitationListPage.route; - } + if (bloc.deploymentMode != DeploymentMode.local && + !bloc.backend.isAuthenticated) { + return LoginPage.route; } - if (!bloc.hasInformedConsentBeenAccepted) { + if (!bloc.hasStudyBeenDeployed) { + return InvitationListPage.route; + } + if (!await bloc.hasInformedConsentBeenAccepted) { return InformedConsentPage.route; } - return firstRoute; }), GoRoute( @@ -87,8 +84,7 @@ class CarpStudyAppState extends State { path: DataVisualizationPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: DataVisualizationPage( - bloc.appViewModel.dataVisualizationPageViewModel), + child: DataVisualizationPage(bloc.appViewModel.dataVisualizationPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), @@ -120,8 +116,7 @@ class CarpStudyAppState extends State { GoRoute( path: ParticipantDataPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => ParticipantDataPage( - model: bloc.appViewModel.participantDataPageViewModel), + builder: (context, state) => ParticipantDataPage(model: bloc.appViewModel.participantDataPageViewModel), ), GoRoute( path: '/task/:taskId', @@ -147,8 +142,7 @@ class CarpStudyAppState extends State { GoRoute( path: '${MessageDetailsPage.route}/:messageId', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => MessageDetailsPage( - messageId: state.pathParameters['messageId'] ?? ''), + builder: (context, state) => MessageDetailsPage(messageId: state.pathParameters['messageId'] ?? ''), ), GoRoute( path: '${InvitationDetailsPage.route}/:invitationId', @@ -161,11 +155,8 @@ class CarpStudyAppState extends State { GoRoute( path: InvitationListPage.route, parentNavigatorKey: _rootNavigatorKey, - redirect: (context, state) => bloc.study != null - ? InformedConsentPage.route - : (bloc.user == null ? LoginPage.route : null), - builder: (context, state) => InvitationListPage( - model: bloc.appViewModel.invitationsListViewModel), + builder: (context, state) => + InvitationListPage(model: bloc.appViewModel.invitationsListViewModel), ), ], debugLogDiagnostics: true, @@ -173,8 +164,7 @@ class CarpStudyAppState extends State { /// Research Package translations, incl. both local language assets plus /// translations of informed consent and surveys downloaded from CARP - final RPLocalizationsDelegate rpLocalizationsDelegate = - RPLocalizationsDelegate( + final RPLocalizationsDelegate rpLocalizationsDelegate = RPLocalizationsDelegate( loaders: [ const AssetLocalizationLoader(), bloc.localizationLoader, diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index 3db20440..52a08e8a 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -90,6 +90,12 @@ class CarpBackend { CarpParticipationService().configureFrom(CarpService()); CarpDeploymentService().configureFrom(CarpService()); + // CAWS services hold the active study as ambient state. If a study is + // cached locally (returning user), re-seed the services here — otherwise + // CarpResourceManager throws null-derefs on the first resource fetch. + final cachedStudy = LocalSettings().study; + if (cachedStudy != null) study = cachedStudy; + info('$runtimeType initialized - app: $app'); } diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index ffcd2acf..ed0d1fd5 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -28,9 +28,11 @@ class InformedConsentState extends State { body: FutureBuilder( future: widget.model.getInformedConsent(localization.locale).then( (document) { + // No consent document configured for this study → mark accepted + // locally and continue. Nothing to upload. if (document == null) { - bloc.hasInformedConsentBeenAccepted = true; - context.go(CarpStudyAppState.homeRoute); + bloc.informedConsentHasBeenAccepted(); + if (context.mounted) context.go(CarpStudyAppState.homeRoute); } return document; }, From 88d9eb058f7497af4f51102641681c338a768571 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 18 May 2026 15:37:09 +0200 Subject: [PATCH 21/94] chore: apply dart format and refresh pubspec.lock --- lib/carp_study_app.dart | 22 +++++++++++++-------- pubspec.lock | 44 ++++++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index c919eed4..2ade1dec 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -8,7 +8,8 @@ class CarpStudyApp extends StatefulWidget { /// Reload language translations and re-build the entire app. static void reloadLocale(BuildContext context) async { - CarpStudyAppState? state = context.findAncestorStateOfType(); + CarpStudyAppState? state = + context.findAncestorStateOfType(); state?.reloadLocale(); } @@ -33,7 +34,8 @@ class CarpStudyAppState extends State { routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, - builder: (BuildContext context, GoRouterState state, Widget child) => HomePage(child: child), + builder: (BuildContext context, GoRouterState state, Widget child) => + HomePage(child: child), routes: [ // This is the root route, handling the onboarding. // The flow of logic is: @@ -84,7 +86,8 @@ class CarpStudyAppState extends State { path: DataVisualizationPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: DataVisualizationPage(bloc.appViewModel.dataVisualizationPageViewModel), + child: DataVisualizationPage( + bloc.appViewModel.dataVisualizationPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), @@ -116,7 +119,8 @@ class CarpStudyAppState extends State { GoRoute( path: ParticipantDataPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => ParticipantDataPage(model: bloc.appViewModel.participantDataPageViewModel), + builder: (context, state) => ParticipantDataPage( + model: bloc.appViewModel.participantDataPageViewModel), ), GoRoute( path: '/task/:taskId', @@ -142,7 +146,8 @@ class CarpStudyAppState extends State { GoRoute( path: '${MessageDetailsPage.route}/:messageId', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => MessageDetailsPage(messageId: state.pathParameters['messageId'] ?? ''), + builder: (context, state) => MessageDetailsPage( + messageId: state.pathParameters['messageId'] ?? ''), ), GoRoute( path: '${InvitationDetailsPage.route}/:invitationId', @@ -155,8 +160,8 @@ class CarpStudyAppState extends State { GoRoute( path: InvitationListPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => - InvitationListPage(model: bloc.appViewModel.invitationsListViewModel), + builder: (context, state) => InvitationListPage( + model: bloc.appViewModel.invitationsListViewModel), ), ], debugLogDiagnostics: true, @@ -164,7 +169,8 @@ class CarpStudyAppState extends State { /// Research Package translations, incl. both local language assets plus /// translations of informed consent and surveys downloaded from CARP - final RPLocalizationsDelegate rpLocalizationsDelegate = RPLocalizationsDelegate( + final RPLocalizationsDelegate rpLocalizationsDelegate = + RPLocalizationsDelegate( loaders: [ const AssetLocalizationLoader(), bloc.localizationLoader, diff --git a/pubspec.lock b/pubspec.lock index cd7fe24a..2425195c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "91.0.0" air_quality: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "8.4.1" app_version_update: dependency: "direct main" description: @@ -397,10 +397,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -549,10 +549,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.3" data_serializer: dependency: transitive description: @@ -1217,18 +1217,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" mdsflutter: dependency: "direct main" description: @@ -1241,10 +1241,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1265,10 +1265,10 @@ packages: dependency: "direct dev" description: name: mockito - sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.5.0" + version: "5.6.4" network_info_plus: dependency: transitive description: @@ -2014,26 +2014,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.16" timeago: dependency: "direct main" description: From 6f0663482e2eb190aedae4b8c3ed82393bf08960 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 27 May 2026 11:02:15 +0200 Subject: [PATCH 22/94] feat(onboarding): consent gate, pull-to-refresh, faster study entry - HomePage routes the user to /study/consent when informed consent has not been accepted; configureStudy (which kicks off CAMS' OS permission requests) is deferred until consent is recorded, and a bloc listener re-runs setup on informedConsentHasBeenAccepted. - InvitationListPage stores its invitations Future in state and is wrapped in RefreshIndicator with AlwaysScrollableScrollPhysics so pull-to-refresh re-fetches from CAWS. - bloc.initialize pre-warms sqflite via Persistence().init() during the launch splash, removing a multi-second platform-thread block from the accept-invitation path. - isHealthInstalled() switches from appCheck.getInstalledApps() (enumerates every APK on the phone) to a single-package isAppInstalled() query, cutting another ~1s of platform work that was running in parallel with study config. - StudyPage shows a "Configuring the study" loader while configureStudy is running, instead of the empty-deployment placeholder cards. configureStudy now flips state to configured before notifyListeners so the loader dismisses cleanly. - LocalResourceManager.initialize signature aligned with the locally pinned carp_mobile_sensing 1.12.x StudyProtocolManager interface. - pubspec.yaml routes carp_mobile_sensing to the local ../carp.sensing-flutter checkout (1.12.0-perm-hotfix branch). --- .vscode/launch.json | 2 +- .../xcshareddata/swiftpm/Package.resolved | 9 - .../xcshareddata/swiftpm/Package.resolved | 9 - lib/blocs/app_bloc.dart | 37 ++-- lib/blocs/sensing.dart | 27 ++- lib/carp_study_app.dart | 86 ++++---- lib/data/local_resource_manager.dart | 2 +- lib/main.dart | 1 - lib/ui/pages/home_page.dart | 110 +++++------ lib/ui/pages/informed_consent_page.dart | 38 +++- lib/ui/pages/invitation_list_page.dart | 186 ++++++++++-------- lib/ui/pages/invitation_page.dart | 2 +- lib/ui/pages/study_page.dart | 66 +++++-- lib/ui/widgets/location_permission_page.dart | 121 ------------ .../informed_consent_page_model.dart | 4 +- pubspec.lock | 16 +- pubspec.yaml | 14 +- 17 files changed, 339 insertions(+), 391 deletions(-) delete mode 100644 lib/ui/widgets/location_permission_page.dart diff --git a/.vscode/launch.json b/.vscode/launch.json index 570ef544..0dafcd10 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,7 @@ "--dart-define", "deployment-mode=dev", "--dart-define", - "debug-level=debug" + "debug-level=none" ], "flutterMode": "debug" }, diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f1c67b8..a71b474b 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "appauth-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/openid/AppAuth-iOS", - "state" : { - "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", - "version" : "2.0.0" - } - }, { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f1c67b8..a71b474b 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "appauth-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/openid/AppAuth-iOS", - "state" : { - "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", - "version" : "2.0.0" - } - }, { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 833cef55..452da3b7 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -162,6 +162,10 @@ class StudyAppBLoC extends ChangeNotifier { Settings().debugLevel = debugLevel; await Settings().init(); + + // Pre-warm sqflite during splash; first call freezes the UI thread. + await Persistence().init(); + CarpResourceManager().initialize(); Sensing(); @@ -203,9 +207,9 @@ class StudyAppBLoC extends ChangeNotifier { if (Platform.isIOS) return true; try { - final apps = await appCheck.getInstalledApps() ?? []; - return apps.any( - (app) => app.packageName == LocalSettings.healthConnectPackageName); + // Single-package query; getInstalledApps() enumerates everything. + return await appCheck + .isAppInstalled(LocalSettings.healthConnectPackageName); } catch (e) { debug("$runtimeType - Error checking Health Connect installation: $e"); return false; @@ -303,28 +307,24 @@ class StudyAppBLoC extends ChangeNotifier { _state = StudyAppState.configuring; - // set up and initialize sensing await Sensing().initialize(); - // make sure that the CAWS backend services are configured with the study - backend.study = study!; + // backend.study is set in backend.initialize() (cached study on cold start) + // and in setStudyInvitation() (new invitation flow). No need to re-assign. - // add the study and configure sensing await Sensing().addStudy(); - // initialize the UI data models appViewModel.init(Sensing().controller!); - // set up the messaging part and get the initial list of messages messageManager.initialize(); refreshMessages(); - // refresh the list of messages on a regular basis Timer.periodic(const Duration(minutes: 30), (_) => refreshMessages()); info('Study configuration done.'); - notifyListeners(); + // Flip state before notifying so listeners see isConfigured == true. _state = StudyAppState.configured; + notifyListeners(); } Future> getParticipantDataListFromDeployment() async => @@ -353,19 +353,22 @@ class StudyAppBLoC extends ChangeNotifier { informedConsentManager.getInformedConsent(refresh: refresh); /// Has the informed consent been accepted by the user? - /// Local mode trusts [LocalSettings]; otherwise asks the backend, falling - /// back to [LocalSettings] when offline so signed users aren't re-prompted. + /// + /// 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 get hasInformedConsentBeenAccepted async { - final local = - LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; - if (deploymentMode == DeploymentMode.local || study == null) return local; + if (deploymentMode == DeploymentMode.local || study == null) { + return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? + 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'); - return local; + return false; } } diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 4ce5d2ba..01e34c7e 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -134,12 +134,11 @@ class Sensing { assert(bloc.study != null, 'No study is provided. Cannot start deployment w/o a study.'); - // Add the study to the client. _study = await SmartPhoneClientManager().addStudy(bloc.study!); + _controller = SmartPhoneClientManager().getStudyRuntime(study!.studyDeploymentId); - // Get the study controller and try to deploy the study. return await tryDeployment(); } @@ -155,24 +154,34 @@ class Sensing { StudyStatus status = await controller!.tryDeployment(useCached: true); - // Make sure to translate the user tasks in the study protocol before using - // them in the app's task list. translateStudyProtocol(); - // Configure the controller await controller?.configure(); - // Listening on the data stream and print them as json to the debug console - controller?.measurements - .listen((measurement) => debugPrint(toJsonString(measurement))); + // Mirror each measurement to the console only when CAMS debug logging is on + // — matches the gating in carp_mobile_sensing's debug()/info() helpers so a + // release build (or any non-debug level) doesn't get spammed. + if (Settings().debugLevel.index >= DebugLevel.debug.index) { + controller?.measurements + .listen((measurement) => debugPrint(toJsonString(measurement))); + } info('$runtimeType - Study added, deployment id: $studyDeploymentId'); return status; } Future removeStudy() async { - if (study != null) { + if (study == null) return; + // SmartPhoneClientManager.removeStudy calls getStudyRuntime which does an + // unsafe `as SmartphoneDeploymentController` cast — it throws when the + // study was never added to the client manager (e.g. the user cancelled + // consent before configureStudy ran). Swallow that case; we just want + // the study gone, and it never existed here. + try { await SmartPhoneClientManager().removeStudy(study!.studyDeploymentId); + } on TypeError { + info('$runtimeType - study $study was never registered with the ' + 'client manager, skipping removeStudy.'); } } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 2ade1dec..eb34048e 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -19,49 +19,60 @@ class CarpStudyApp extends StatefulWidget { class CarpStudyAppState extends State { /// The landing page once the onboarding process is done. - static const String firstRoute = StudyPage.route; static const String homeRoute = '/'; /// Reload language translations and re-build the entire app. void reloadLocale() => setState(() => rpLocalizationsDelegate.reload()); - // This create the routing in the entire app. - // Each page (like [LoginPage]) know the name of its own route. + // State-driven routing. Bloc state + // changes notify the router via refreshListenable, which re-evaluates the + // redirect at the current location and moves the user automatically. final GoRouter _router = GoRouter( - initialLocation: homeRoute, + initialLocation: StudyPage.route, navigatorKey: _rootNavigatorKey, + refreshListenable: bloc, errorBuilder: (context, state) => const ErrorPage(), + redirect: (context, state) async { + final loc = state.matchedLocation; + debugPrint('[redirect] loc=$loc auth=${bloc.backend.isAuthenticated} ' + 'studyDeployed=${bloc.hasStudyBeenDeployed}'); + + // 1) Not authenticated → login page. + if (bloc.deploymentMode != DeploymentMode.local && + !bloc.backend.isAuthenticated) { + debugPrint('[redirect] → /login (not authenticated)'); + return LoginPage.route; + } + + // 2) No study deployed → user belongs on the invitation list (or its + // details page). Anywhere else gets bounced to the list. + if (!bloc.hasStudyBeenDeployed) { + if (loc == InvitationListPage.route || + loc.startsWith('${InvitationDetailsPage.route}/')) { + debugPrint('[redirect] → null (already on invitation route)'); + return null; + } + debugPrint('[redirect] → /invitations (no study)'); + return InvitationListPage.route; + } + + // 3) Fully onboarded. + debugPrint('[redirect] → null (fully onboarded)'); + return null; + }, routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, builder: (BuildContext context, GoRouterState state, Widget child) => HomePage(child: child), routes: [ - // This is the root route, handling the onboarding. - // The flow of logic is: - // - do we run locally and need authentication - // - the user is authenticated, if not show login page - // - a study is deployed, if not show list of invitations for the user - // - the user has accepted the informed consent, if not show informed consent page - // - // Once the above is done, then show the "first route", which currently is - // the "study" information page. + // 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: (context, state) async { - if (bloc.deploymentMode != DeploymentMode.local && - !bloc.backend.isAuthenticated) { - return LoginPage.route; - } - if (!bloc.hasStudyBeenDeployed) { - return InvitationListPage.route; - } - if (!await bloc.hasInformedConsentBeenAccepted) { - return InformedConsentPage.route; - } - return firstRoute; - }), + path: homeRoute, + parentNavigatorKey: _shellNavigatorKey, + redirect: (_, __) => StudyPage.route, + ), GoRoute( path: TaskListPage.route, parentNavigatorKey: _shellNavigatorKey, @@ -81,6 +92,18 @@ class CarpStudyAppState extends State { ), 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, + ), + ), + ], ), GoRoute( path: DataVisualizationPage.route, @@ -131,13 +154,6 @@ class CarpStudyAppState extends State { return task?.widget ?? const ErrorPage(); }, ), - GoRoute( - path: InformedConsentPage.route, - parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => InformedConsentPage( - model: bloc.appViewModel.informedConsentViewModel, - ), - ), GoRoute( path: LoginPage.route, parentNavigatorKey: _rootNavigatorKey, diff --git a/lib/data/local_resource_manager.dart b/lib/data/local_resource_manager.dart index fafdba4b..8681d47b 100644 --- a/lib/data/local_resource_manager.dart +++ b/lib/data/local_resource_manager.dart @@ -38,7 +38,7 @@ class LocalResourceManager } @override - void initialize() {} + Future initialize() async {} // INFORMED CONSENT diff --git a/lib/main.dart b/lib/main.dart index 778eef6e..938949a9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -122,7 +122,6 @@ part 'ui/tasks/camera_page.dart'; part 'ui/widgets/carp_app_bar.dart'; part 'ui/widgets/horizontal_bar.dart'; -part 'ui/widgets/location_permission_page.dart'; part 'ui/widgets/charts_legend.dart'; part 'ui/widgets/details_banner.dart'; part 'ui/widgets/studies_material.dart'; diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 791a9920..3b4f7a7a 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -12,72 +12,67 @@ class HomePage extends StatefulWidget { } class HomePageState extends State { - /// Ask for location permissions. - /// - /// The method opens the [LocationPermissionPage] if location permissions are - /// needed and not yet granted. - /// - /// Android requires the app to show a modal window explaining "why" the app - /// needs access to location. Best practice for doing this is explain on the - /// [Request location permissions](https://developer.android.com/develop/sensors-and-location/location/permissions) - /// Android Developer page. - /// - /// This approach is used on both Android and iOS, even though it is an - /// Android recommendation / requirement. - Future askForLocationPermissions(BuildContext context) async { - if (!context.mounted) return; - - if (bloc.usingLocationPermissions) { - var granted = await LocationManager().isGranted(); - if (!granted) { - await showGeneralDialog( - context: context, - barrierDismissible: false, - barrierColor: Colors.black38, - transitionBuilder: (ctx, anim1, anim2, child) => BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: 4 * anim1.value, sigmaY: 4 * anim1.value), - child: FadeTransition( - opacity: anim1, - child: child, - ), - ), - pageBuilder: (context, anim1, anim2) => - LocationPermissionPage().build( - context, - "dialog.location.info", - )); - await LocationManager().requestPermission(); - } - } - } + bool _setupDone = false; + bool _setupInFlight = false; @override void initState() { super.initState(); + // Re-run setup when bloc notifies (e.g. after consent submission). + bloc.addListener(_onBlocChanged); + } + + @override + void dispose() { + bloc.removeListener(_onBlocChanged); + super.dispose(); + } + + void _onBlocChanged() { + _maybeRunSetup(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _maybeRunSetup(); + } + + Future _maybeRunSetup() async { + if (_setupDone || _setupInFlight || !mounted) return; + if (!bloc.hasStudyBeenDeployed) return; + + _setupInFlight = true; + try { + // Defer configureStudy (and its OS permission prompts) until consent. + if (!await bloc.hasInformedConsentBeenAccepted) { + if (!mounted) return; + final loc = GoRouterState.of(context).matchedLocation; + if (loc != InformedConsentPage.route) { + context.go(InformedConsentPage.route); + } + return; + } + + _setupDone = true; - // Setting up sensing, which entails; - // - asking for location permissions - // - configuring the study - // - loading localizations - // - starting sensing - askForLocationPermissions(context) - .then((_) => bloc.configureStudy().then((_) { - // Load localizations for the current locale and study - CarpStudyApp.reloadLocale(context); - bloc.start(); - })); - - if (Platform.isAndroid) { - // Check if HealthConnect is installed - _checkHealthConnectInstallation(); + // Run setup and HC check in parallel so the HC dialog doesn't gate sensing. + await Future.wait([ + bloc.configureStudy().whenComplete(() { + if (mounted) CarpStudyApp.reloadLocale(context); + bloc.start(); + }), + if (Platform.isAndroid) _checkHealthConnectInstallation(), + ]); + } finally { + _setupInFlight = false; } } Future _checkHealthConnectInstallation() async { bool isInstalled = await bloc.isHealthInstalled(); - if (!isInstalled) { - showDialog( + if (!isInstalled && mounted) { + await showDialog( context: context, barrierDismissible: true, builder: (context) => InstallHealthConnectDialog(context), @@ -103,8 +98,7 @@ class HomePageState extends State { }); return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: widget.child, ), diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index ed0d1fd5..52556e07 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 = '/consent'; + static const String route = '/study/consent'; final InformedConsentViewModel model; const InformedConsentPage({super.key, required this.model}); @@ -12,11 +12,27 @@ class InformedConsentPage extends StatefulWidget { class InformedConsentState extends State { final GlobalKey _scaffoldKey = GlobalKey(); - void resultCallback(RPTaskResult result) { - widget.model.informedConsentHasBeenAccepted(result); - if (context.mounted) { - context.go(CarpStudyAppState.homeRoute); + // 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(CarpStudyAppState.homeRoute); + } + + @override + void dispose() { + // Bypassing onCancel entirely is intentional — see issue carp-dk/research.package#168. + if (!_submitted) { + bloc.leaveStudy(); } + super.dispose(); } @override @@ -27,12 +43,14 @@ class InformedConsentState extends State { key: _scaffoldKey, body: FutureBuilder( future: widget.model.getInformedConsent(localization.locale).then( - (document) { + (document) async { // No consent document configured for this study → mark accepted - // locally and continue. Nothing to upload. - if (document == null) { - bloc.informedConsentHasBeenAccepted(); - if (context.mounted) context.go(CarpStudyAppState.homeRoute); + // and navigate to /study. Set _submitted so dispose() doesn't tear + // the study back down. + if (document == null && !_submitted) { + _submitted = true; + await bloc.informedConsentHasBeenAccepted(); + if (mounted) context.go(CarpStudyAppState.homeRoute); } return document; }, diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index d6ddbadb..547d52fc 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -1,110 +1,128 @@ part of carp_study_app; -class InvitationListPage extends StatelessWidget { +class InvitationListPage extends StatefulWidget { static const String route = '/invitations'; final InvitationsViewModel model; const InvitationListPage({super.key, required this.model}); + @override + State createState() => _InvitationListPageState(); +} + +class _InvitationListPageState extends State { + late Future> _invitationsFuture; + + @override + void initState() { + super.initState(); + _invitationsFuture = bloc.backend.getInvitations(); + } + + Future _refresh() async { + final next = bloc.backend.getInvitations(); + setState(() { + _invitationsFuture = next; + }); + await next; + } + @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: FutureBuilder>( - future: bloc.backend.getInvitations(), - builder: (context, snapshot) { - Widget child; - if (snapshot.hasData) { - child = SliverFixedExtentList( - itemExtent: 150, - delegate: SliverChildBuilderDelegate( - (context, index) { - return Container( - child: InvitationMaterial( - invitation: snapshot.data![index], - ), - ); - }, - childCount: snapshot.data!.length, - ), - ); - } else { - child = const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator()), - ); - } + body: RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + future: _invitationsFuture, + builder: (context, snapshot) { + Widget child; + if (snapshot.hasData) { + child = SliverFixedExtentList( + itemExtent: 150, + delegate: SliverChildBuilderDelegate( + (context, index) { + return Container( + child: InvitationMaterial( + invitation: snapshot.data![index], + ), + ); + }, + childCount: snapshot.data!.length, + ), + ); + } else { + child = const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ); + } - return CustomScrollView( - slivers: [ - SliverAppBar( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, - title: const CarpAppBar(), - centerTitle: true, - pinned: true, - stretch: true, - stretchTriggerOffset: 20, - scrolledUnderElevation: 0, - onStretchTrigger: () async => bloc.backend.invitations, - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: IntrinsicHeight( - child: Stack( - children: [ - Positioned( - left: 8, - top: 0, - bottom: 0, - child: IconButton( - icon: const Icon(Icons.arrow_back_ios), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(LoginPage.route); - } - }, + return CustomScrollView( + // Always scrollable so pull-to-refresh still works when there's + // zero or one invitation and the content doesn't fill the screen. + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverAppBar( + backgroundColor: + Theme.of(context).extension()!.backgroundGray, + title: const CarpAppBar(), + centerTitle: true, + pinned: true, + scrolledUnderElevation: 0, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: IntrinsicHeight( + child: Stack( + children: [ + Positioned( + left: 8, + top: 0, + bottom: 0, + child: IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: () => bloc.signOutAndLeaveStudy(), + ), ), - ), - Center( - child: Text( - locale.translate('invitation.invitations'), - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, + Center( + child: Text( + locale.translate('invitation.invitations'), + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22.0, + ), ), ), - ), - ], + ], + ), ), ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - bottom: 8.0, left: 16.0, right: 16.0), - child: Container( - padding: EdgeInsets.all(10.0), - child: Text( - locale.translate('invitation.subtitle'), - textAlign: TextAlign.left, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + bottom: 8.0, left: 16.0, right: 16.0), + child: Container( + padding: EdgeInsets.all(10.0), + child: Text( + locale.translate('invitation.subtitle'), + textAlign: TextAlign.left, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14.0, + ), ), ), ), ), - ), - child, - ], - ); - }, + child, + ], + ); + }, + ), ), ); } diff --git a/lib/ui/pages/invitation_page.dart b/lib/ui/pages/invitation_page.dart index c7084b18..217123a1 100644 --- a/lib/ui/pages/invitation_page.dart +++ b/lib/ui/pages/invitation_page.dart @@ -169,7 +169,7 @@ class InvitationDetailsPage extends StatelessWidget { child: TextButton( onPressed: () { bloc.setStudyInvitation(invitation, context); - context.push(InformedConsentPage.route); + context.go(StudyPage.route); }, child: Text( locale.translate("invitation.accept_invite"), diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 6b3352b9..68976f03 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -26,24 +26,33 @@ class StudyPageState extends State { child: const CarpAppBar(hasProfileIcon: true), ), Flexible( - child: StreamBuilder( - stream: widget.model.messageStream, - builder: (context, AsyncSnapshot snapshot) { - final cards = _buildCards(context); - return RefreshIndicator( - onRefresh: () async { - await bloc.refreshMessages(); - final status = await Sensing().tryDeployment(); - if (status == StudyStatus.Deployed) { - bloc.start(); - } - bloc.deploymentService.getStudyDeploymentStatus( - widget.model.studyDeploymentId); + // Re-render when configureStudy completes; until then show loader. + child: ListenableBuilder( + listenable: bloc, + builder: (context, _) { + if (!bloc.isConfigured) { + return const _ConfiguringStudyLoader(); + } + return StreamBuilder( + stream: widget.model.messageStream, + builder: (context, AsyncSnapshot snapshot) { + final cards = _buildCards(context); + return RefreshIndicator( + onRefresh: () async { + await bloc.refreshMessages(); + final status = await Sensing().tryDeployment(); + if (status == StudyStatus.Deployed) { + bloc.start(); + } + bloc.deploymentService.getStudyDeploymentStatus( + widget.model.studyDeploymentId); + }, + child: ListView.builder( + itemCount: cards.length, + itemBuilder: (context, index) => cards[index], + ), + ); }, - child: ListView.builder( - itemCount: cards.length, - itemBuilder: (context, index) => cards[index], - ), ); }, ), @@ -533,3 +542,26 @@ extension CopyWithAdditional on DateTime { ); } } + +/// Placeholder shown while [StudyAppBLoC.configureStudy] is running. +class _ConfiguringStudyLoader extends StatelessWidget { + const _ConfiguringStudyLoader(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + 'Configuring the study', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/location_permission_page.dart b/lib/ui/widgets/location_permission_page.dart deleted file mode 100644 index c4fce61a..00000000 --- a/lib/ui/widgets/location_permission_page.dart +++ /dev/null @@ -1,121 +0,0 @@ -part of carp_study_app; - -class LocationPermissionPage { - Widget build(BuildContext context, String message) { - RPLocalizations locale = RPLocalizations.of(context)!; - - return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, - body: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Text( - locale.translate('dialog.location.permission'), - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, - ), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 16.0), - child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - child: Padding( - padding: const EdgeInsets.only( - right: 24.0, - left: 24.0, - top: 16.0, - bottom: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - Row( - children: [ - Text( - locale.translate( - 'dialog.location.location_data'), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, - color: Theme.of(context) - .extension()! - .primary, - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 24.0), - child: Icon( - Icons.location_on, - color: Theme.of(context) - .extension()! - .primary, - size: 48, - ), - ), - Text( - locale.translate(message), - style: fs16fw400.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.justify, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ), - // button to accept the invitation - Container( - margin: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), - child: TextButton( - onPressed: () { - Permission.locationWhenInUse - .request() - .then((value) => context.pop(true)); - }, - child: Text( - locale.translate("dialog.location.continue"), - style: - const TextStyle(color: Color(0xffffffff), fontSize: 22), - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/view_models/informed_consent_page_model.dart b/lib/view_models/informed_consent_page_model.dart index bf97ab67..2527de9b 100644 --- a/lib/view_models/informed_consent_page_model.dart +++ b/lib/view_models/informed_consent_page_model.dart @@ -22,7 +22,9 @@ class InformedConsentViewModel extends ViewModel { } /// Called when the informed consent has been accepted by the user. - void informedConsentHasBeenAccepted( + /// 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.informedConsentHasBeenAccepted(informedConsentResult); diff --git a/pubspec.lock b/pubspec.lock index 2425195c..866ba228 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -316,10 +316,9 @@ packages: carp_context_package: dependency: "direct main" description: - name: carp_context_package - sha256: a1a708be991d8e7a66e43b43ad606c2915ac9c55851f5c57f63a61f35baefb6e - url: "https://pub.dev" - source: hosted + path: "../carp.sensing-flutter/packages/carp_context_package" + relative: true + source: path version: "1.12.0" carp_core: dependency: "direct main" @@ -340,11 +339,10 @@ packages: carp_mobile_sensing: dependency: "direct main" description: - name: carp_mobile_sensing - sha256: c9b58ba07c7468a681619aad4ff87c7382b453538994a974bce63b87bd9959cc - url: "https://pub.dev" - source: hosted - version: "1.13.1" + path: "../carp.sensing-flutter/carp_mobile_sensing" + relative: true + source: path + version: "1.12.2" carp_movesense_package: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index be151299..bfdfb4a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,7 +39,7 @@ dependencies: flutter_localizations: sdk: flutter - intl: '>= 0.19.0 <= 0.21.0' + intl: ">= 0.19.0 <= 0.21.0" fl_chart: 1.0.0 google_fonts: ^6.1.0 @@ -69,10 +69,10 @@ dependency_overrides: # path: ../carp/carp.sensing-flutter/carp_serializable/ # carp_core: # path: ../carp/carp.sensing-flutter/carp_core/ - # carp_mobile_sensing: - # path: ../carp.sensing-flutter/carp_mobile_sensing/ - # carp_context_package: - # path: ../carp/carp.sensing-flutter/packages/carp_context_package/ + carp_mobile_sensing: + path: ../carp.sensing-flutter/carp_mobile_sensing/ + carp_context_package: + path: ../carp.sensing-flutter/packages/carp_context_package/ # carp_connectivity_package: # path: ../../packages/carp_connectivity_package/ # carp_survey_package: @@ -97,12 +97,11 @@ dependency_overrides: # path: ../carp.sensing-flutter/backends/carp_webservices/ # carp_backend: # path: ../carp/carp.sensing-flutter/backends/carp_backend/ - # research_package: + # research_package: # path: ../research.package/ # cognition_package: # path: ../cognition_package - dev_dependencies: test: any json_serializable: any @@ -119,7 +118,6 @@ dev_dependencies: # 3. run 'flutter pub get' # 3. run 'dart run flutter_launcher_icons:main' - flutter: uses-material-design: true From 49725cb65b0c9da78070be50d628668592bf7cb8 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 27 May 2026 14:27:42 +0200 Subject: [PATCH 23/94] fix(ci): drop local CAMS overrides and bump version code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removes the carp_mobile_sensing and carp_context_package dependency_overrides that pointed at ../carp.sensing-flutter/. That sibling path doesn't exist on Xcode Cloud / Play Store CI, so flutter pub get failed in ios/ci_scripts/ci_post_clone.sh, which in turn meant flutter build --config-only never ran and the FlutterGeneratedPluginSwiftPackage was missing for SPM. - Reverts LocalResourceManager.initialize back to a sync `void` to match the published carp_mobile_sensing 1.13.x interface now that we're no longer pinned to the local 1.12.x checkout. - Bumps pubspec.yaml from 4.2.0+90 to 4.2.0+91 — Play Store internal track is already at 90, blocking the upload. --- lib/data/local_resource_manager.dart | 2 +- pubspec.lock | 16 +++++++++------- pubspec.yaml | 10 +++++----- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/data/local_resource_manager.dart b/lib/data/local_resource_manager.dart index 8681d47b..fafdba4b 100644 --- a/lib/data/local_resource_manager.dart +++ b/lib/data/local_resource_manager.dart @@ -38,7 +38,7 @@ class LocalResourceManager } @override - Future initialize() async {} + void initialize() {} // INFORMED CONSENT diff --git a/pubspec.lock b/pubspec.lock index 866ba228..2425195c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -316,9 +316,10 @@ packages: carp_context_package: dependency: "direct main" description: - path: "../carp.sensing-flutter/packages/carp_context_package" - relative: true - source: path + name: carp_context_package + sha256: a1a708be991d8e7a66e43b43ad606c2915ac9c55851f5c57f63a61f35baefb6e + url: "https://pub.dev" + source: hosted version: "1.12.0" carp_core: dependency: "direct main" @@ -339,10 +340,11 @@ packages: carp_mobile_sensing: dependency: "direct main" description: - path: "../carp.sensing-flutter/carp_mobile_sensing" - relative: true - source: path - version: "1.12.2" + name: carp_mobile_sensing + sha256: c9b58ba07c7468a681619aad4ff87c7382b453538994a974bce63b87bd9959cc + url: "https://pub.dev" + source: hosted + version: "1.13.1" carp_movesense_package: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index bfdfb4a2..04b1f223 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.2.0+90 +version: 4.2.0+91 environment: sdk: ">=3.2.0 <4.0.0" @@ -69,10 +69,10 @@ dependency_overrides: # path: ../carp/carp.sensing-flutter/carp_serializable/ # carp_core: # path: ../carp/carp.sensing-flutter/carp_core/ - carp_mobile_sensing: - path: ../carp.sensing-flutter/carp_mobile_sensing/ - carp_context_package: - path: ../carp.sensing-flutter/packages/carp_context_package/ + # carp_mobile_sensing: + # path: ../carp.sensing-flutter/carp_mobile_sensing/ + # carp_context_package: + # path: ../carp.sensing-flutter/packages/carp_context_package/ # carp_connectivity_package: # path: ../../packages/carp_connectivity_package/ # carp_survey_package: From 808cb0564d313157ed212f496a326fc5be4e7353 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 27 May 2026 14:35:25 +0200 Subject: [PATCH 24/94] style: dart format home_page.dart --- lib/ui/pages/home_page.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 3b4f7a7a..7dc95954 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -98,7 +98,8 @@ class HomePageState extends State { }); return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, + backgroundColor: + Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: widget.child, ), From ac9b825827107416b1f94f68da1325a0402f12b0 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 27 May 2026 15:09:01 +0200 Subject: [PATCH 25/94] fix(ci): regen Package.resolved on Xcode Cloud + scope GH Actions - ios/ci_scripts/ci_post_clone.sh: append `xcodebuild -resolvePackageDependencies` so Xcode Cloud refreshes Package.resolved with whatever SPM deps the FlutterGeneratedPluginSwiftPackage currently declares (e.g. appauth-ios from flutter_appauth). Avoids "out-of-date resolved file" failures when plugin updates change the iOS dep graph. - .github/workflows/build.yml: scope the Flutter build/test gate to pushes on master/test and to pull requests. Stops running on every WIP branch push. - ios/Podfile.lock: regenerated after `flutter build --config-only` picked up the current plugin set (was stale). --- .github/workflows/build.yml | 5 +- ios/Podfile.lock | 136 ++++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 14 -- ios/ci_scripts/ci_post_clone.sh | 4 + 4 files changed, 144 insertions(+), 15 deletions(-) delete mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e42569e..cb4ade44 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,10 @@ name: Flutter build and test -on: push +on: + push: + branches: [master, test] + pull_request: env: FLUTTER_FOLDER: '$HOME/flutter' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 087ce02c..cffb65a3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,21 +1,55 @@ PODS: + - AppAuth (2.0.0): + - AppAuth/Core (= 2.0.0) + - AppAuth/ExternalUserAgent (= 2.0.0) + - AppAuth/Core (2.0.0) + - AppAuth/ExternalUserAgent (2.0.0): + - AppAuth/Core + - "appcheck (1.5.4+1)": + - Flutter + - audio_session (0.0.1): + - Flutter - audio_streamer (0.0.1): - Flutter + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - battery_plus (1.0.0): + - Flutter + - camera_avfoundation (0.0.1): + - Flutter + - connectivity_plus (0.0.1): + - Flutter - dchs_flutter_beacon (0.6.5): - Flutter + - device_info_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_activity_recognition (0.0.1): - Flutter + - flutter_appauth (0.0.1): + - AppAuth (= 2.0.0) + - Flutter + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS + - flutter_local_notifications (0.0.1): + - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_sound (9.28.0): - Flutter - flutter_sound_core (= 9.28.0) - flutter_sound_core (9.28.0) + - flutter_timezone (0.0.1): + - Flutter - flutter_web_auth_2 (3.0.0): - Flutter - health (12.2.1): - Flutter + - just_audio (0.0.1): + - Flutter + - FlutterMacOS - location (0.0.1): - Flutter - mdsflutter (0.0.1): @@ -24,10 +58,17 @@ PODS: - SwiftProtobuf - Movesense (3.33.1) - MTBBarcodeScanner (5.0.11) + - network_info_plus (0.0.1): + - Flutter - oidc_ios (0.0.1): - Flutter - open_settings_plus (0.0.1): - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - pedometer (0.0.1): - Flutter - permission_handler_apple (9.3.0): @@ -45,31 +86,65 @@ PODS: - RxSwift (6.8.0) - screen_state (1.0.0): - Flutter + - sensors_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS - SwiftProtobuf (1.33.3) + - url_launcher_ios (0.0.1): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS - Zip (2.1.2) DEPENDENCIES: + - appcheck (from `.symlinks/plugins/appcheck/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) - audio_streamer (from `.symlinks/plugins/audio_streamer/ios`) + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - battery_plus (from `.symlinks/plugins/battery_plus/ios`) + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - dchs_flutter_beacon (from `.symlinks/plugins/dchs_flutter_beacon/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_activity_recognition (from `.symlinks/plugins/flutter_activity_recognition/ios`) + - flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - health (from `.symlinks/plugins/health/ios`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) - location (from `.symlinks/plugins/location/ios`) - mdsflutter (from `.symlinks/plugins/mdsflutter/ios`) - Movesense (from `https://bitbucket.org/movesense/movesense-mobile-lib/`) + - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - oidc_ios (from `.symlinks/plugins/oidc_ios/ios`) - open_settings_plus (from `.symlinks/plugins/open_settings_plus/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - pedometer (from `.symlinks/plugins/pedometer/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - polar (from `.symlinks/plugins/polar/ios`) - qr_code_scanner_plus (from `.symlinks/plugins/qr_code_scanner_plus/ios`) - screen_state (from `.symlinks/plugins/screen_state/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) SPEC REPOS: trunk: + - AppAuth - flutter_sound_core - MTBBarcodeScanner - PolarBleSdk @@ -78,32 +153,62 @@ SPEC REPOS: - Zip EXTERNAL SOURCES: + appcheck: + :path: ".symlinks/plugins/appcheck/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" audio_streamer: :path: ".symlinks/plugins/audio_streamer/ios" + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" + battery_plus: + :path: ".symlinks/plugins/battery_plus/ios" + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" dchs_flutter_beacon: :path: ".symlinks/plugins/dchs_flutter_beacon/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_activity_recognition: :path: ".symlinks/plugins/flutter_activity_recognition/ios" + flutter_appauth: + :path: ".symlinks/plugins/flutter_appauth/ios" + flutter_blue_plus_darwin: + :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_sound: :path: ".symlinks/plugins/flutter_sound/ios" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" flutter_web_auth_2: :path: ".symlinks/plugins/flutter_web_auth_2/ios" health: :path: ".symlinks/plugins/health/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" location: :path: ".symlinks/plugins/location/ios" mdsflutter: :path: ".symlinks/plugins/mdsflutter/ios" Movesense: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ + network_info_plus: + :path: ".symlinks/plugins/network_info_plus/ios" oidc_ios: :path: ".symlinks/plugins/oidc_ios/ios" open_settings_plus: :path: ".symlinks/plugins/open_settings_plus/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" pedometer: :path: ".symlinks/plugins/pedometer/ios" permission_handler_apple: @@ -114,6 +219,16 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/qr_code_scanner_plus/ios" screen_state: :path: ".symlinks/plugins/screen_state/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" CHECKOUT OPTIONS: Movesense: @@ -121,21 +236,37 @@ CHECKOUT OPTIONS: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ SPEC CHECKSUMS: + AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 + appcheck: 3c94d0ffc94bd639938cac7427d5b13df2795404 + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 audio_streamer: 2e472b9f81cec5e381c4cf7667afa3055dcb45a4 + audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd + battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977 + camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd dchs_flutter_beacon: 88d72cc467de508d621e454ea66c6231d0897d4c + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_activity_recognition: 52dfad4c1e9cec99f0841b3ed0efcc9d424b3deb + flutter_appauth: d4abcf54856e5d8ba82ed7646ffc83245d4aa448 + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c + flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 health: fe206e65f13a9b88623605fbd8af8f029e23dc35 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed location: 155caecf9da4f280ab5fe4a55f94ceccfab838f8 mdsflutter: bd3c0b3335d50947d1a192cf70f930e0fb04a766 Movesense: dc64047c1feb856b7f7ac6ea2c67685350d99dd5 MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb + network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc oidc_ios: 16966cad509ce6850ca4ca1216c5138bef2a8726 open_settings_plus: d19f91e8a04649358a51c19b484ce2e637149d70 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 pedometer: 1c5eaab0c6bce8eb7651f7095553b5081c9d06ed permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d polar: 0374f6063ade7e2dd85d626f87cbe2393d3eda7c @@ -143,7 +274,12 @@ SPEC CHECKSUMS: qr_code_scanner_plus: 7e087021bc69873140e0754750eb87d867bed755 RxSwift: 4e28be97cbcfeee614af26d83415febbf2bf6f45 screen_state: 52d6e997d31bddba6417c60d9cdd22effd0320a7 + sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 PODFILE CHECKSUM: 45842edf150243fea66883d4dfad1e5ddb428147 diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index a71b474b..00000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "phonenumberkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/marmelroy/PhoneNumberKit.git", - "state" : { - "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", - "version" : "3.8.0" - } - } - ], - "version" : 2 -} diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index 04698d63..5846d2b2 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -33,4 +33,8 @@ fi flutter build ios --config-only --release --dart-define="deployment-mode=$MODE" +# Refresh Package.resolved so Xcode Cloud (which has automatic SPM resolution +# disabled) doesn't reject the build when Flutter plugins change iOS deps. +xcodebuild -workspace Runner.xcworkspace -scheme Runner -resolvePackageDependencies + exit 0 \ No newline at end of file From 3c2406dfba5b78779f4010772739a63e4fcd2814 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 27 May 2026 15:14:08 +0200 Subject: [PATCH 26/94] feat(ios): adopt UIScene lifecycle Per https://docs.flutter.dev/release/breaking-changes/uiscenedelegate. - Info.plist gains a UIApplicationSceneManifest entry pointing at FlutterSceneDelegate (no custom SceneDelegate needed for our flow). - AppDelegate now conforms to FlutterImplicitEngineDelegate and moves GeneratedPluginRegistrant.register into didInitializeImplicitFlutterEngine, which is where plugin registration is supposed to live in the scene lifecycle. The UNUserNotificationCenter wiring (flutter_local_notifications) stays in didFinishLaunchingWithOptions since it doesn't touch FlutterViewController. - Restores ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/ swiftpm/Package.resolved which was inadvertently deleted in the previous CI fix commit. --- .../xcshareddata/swiftpm/Package.resolved | 14 +++++++++++++ ios/Runner/AppDelegate.swift | 18 +++++++--------- ios/Runner/Info.plist | 21 +++++++++++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..a71b474b --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "phonenumberkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/PhoneNumberKit.git", + "state" : { + "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", + "version" : "3.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b1802da6..4dfcf7ba 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,25 +2,21 @@ import UIKit import Flutter import flutter_local_notifications -// func registerPlugins(registry: FlutterPluginRegistry) -> () { -// if (!registry.hasPlugin("BackgroundLocatorPlugin")) { -// GeneratedPluginRegistrant.register(with: registry) -// } -// } - @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - - // from flutter_local_notifications + // flutter_local_notifications expects the notification center delegate set early. if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } - - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + // UIScene lifecycle: plugin registration moves here from didFinishLaunching. + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9570d6a3..47fa25e9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -93,6 +93,27 @@ CARP uses the motion system to detects activities such as walking and biking NSSpeechRecognitionUsageDescription CARP uses speech recognition + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UIBackgroundModes From c0acb2df1e2d745bfeafb4d370b7bb5b9e8e0e65 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 28 May 2026 08:05:31 +0200 Subject: [PATCH 27/94] fix(ci): gitignore iOS Package.resolved, revert resolve step Committing Package.resolved against an auto-generated Package.swift (FlutterGeneratedPluginSwiftPackage) is the antipattern: Flutter versions drift, the plugin SPM dep list shifts, and Xcode Cloud's disable-auto-resolution behavior rejects the resulting mismatch. Untracking the file lets each environment resolve on its own. Reverts the xcodebuild -resolvePackageDependencies line in ci_post_clone.sh added last commit; it was treating the symptom. --- ios/.gitignore | 1 + .../xcshareddata/swiftpm/Package.resolved | 14 -------------- .../xcshareddata/swiftpm/Package.resolved | 14 -------------- ios/ci_scripts/ci_post_clone.sh | 4 ---- 4 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ios/.gitignore b/ios/.gitignore index 239fda2a..7b7b1009 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -20,6 +20,7 @@ Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ +**/Package.resolved Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index a71b474b..00000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "phonenumberkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/marmelroy/PhoneNumberKit.git", - "state" : { - "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", - "version" : "3.8.0" - } - } - ], - "version" : 2 -} diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index a71b474b..00000000 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "phonenumberkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/marmelroy/PhoneNumberKit.git", - "state" : { - "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", - "version" : "3.8.0" - } - } - ], - "version" : 2 -} diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index 5846d2b2..04698d63 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -33,8 +33,4 @@ fi flutter build ios --config-only --release --dart-define="deployment-mode=$MODE" -# Refresh Package.resolved so Xcode Cloud (which has automatic SPM resolution -# disabled) doesn't reject the build when Flutter plugins change iOS deps. -xcodebuild -workspace Runner.xcworkspace -scheme Runner -resolvePackageDependencies - exit 0 \ No newline at end of file From c9a8f9c4f1e78be098b769bcd64cf59db42f8b1c Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 28 May 2026 08:09:10 +0200 Subject: [PATCH 28/94] fix(ci): pin Flutter to 3.41.9 across all CI environments Four CI entry points were using inconsistent Flutter versions, which caused FlutterGeneratedPluginSwiftPackage/Package.swift to differ between local dev and Apple's CI (e.g. flutter_appauth routed through CocoaPods locally but SPM on Xcode Cloud's `stable` Flutter), breaking SPM resolution on every iOS build. - ios/ci_scripts/ci_post_clone.sh (Xcode Cloud) - .github/workflows/build.yml (Flutter build and test) - .github/workflows/deploy_to_google_play_store_fastlane_release.yml - .github/workflows/create_release_on_tag.yml All now pin to 3.41.9, matching the dev machine. --- .github/workflows/build.yml | 2 +- .github/workflows/create_release_on_tag.yml | 2 +- .../workflows/deploy_to_google_play_store_fastlane_release.yml | 2 +- ios/ci_scripts/ci_post_clone.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb4ade44..bfa64510 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: uses: flutter-actions/setup-flutter@v4 with: channel: stable - version: 3.32.1 + version: 3.41.9 - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/create_release_on_tag.yml b/.github/workflows/create_release_on_tag.yml index c1cf0c60..3ae5aa5c 100644 --- a/.github/workflows/create_release_on_tag.yml +++ b/.github/workflows/create_release_on_tag.yml @@ -43,7 +43,7 @@ jobs: - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b stable $FOLDER + run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.41.9 $FOLDER - name: Add Flutter to PATH run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH diff --git a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml index db9f3d43..1e571189 100644 --- a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml +++ b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml @@ -36,7 +36,7 @@ jobs: - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b stable $FOLDER + run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.41.9 $FOLDER - name: Add Flutter to PATH run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index 04698d63..9ee8ba70 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -7,7 +7,7 @@ set -ex set -euo pipefail # Install Flutter using git. -git clone https://github.com/flutter/flutter.git --depth 1 -b stable $HOME/flutter +git clone https://github.com/flutter/flutter.git --depth 1 -b 3.41.9 $HOME/flutter export PATH="$PATH:$HOME/flutter/bin" # Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms. From 1bb504717694de17a21dc390f512fdff5eb705b2 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 28 May 2026 08:10:07 +0200 Subject: [PATCH 29/94] fix(ci): re-track Package.resolved now that Flutter is pinned With Flutter pinned to 3.41.9 across local and all CI environments, the auto-generated FlutterGeneratedPluginSwiftPackage/Package.swift is deterministic, so Package.resolved is stable and safe to commit. Committing it again gives Xcode Cloud's disable-auto-resolution flow something matching to build against, and pins transitive SPM versions. --- ios/.gitignore | 1 - .../xcshareddata/swiftpm/Package.resolved | 14 ++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ios/.gitignore b/ios/.gitignore index 7b7b1009..239fda2a 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -20,7 +20,6 @@ Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ -**/Package.resolved Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..a71b474b --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "phonenumberkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/PhoneNumberKit.git", + "state" : { + "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", + "version" : "3.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..a71b474b --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "phonenumberkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/PhoneNumberKit.git", + "state" : { + "revision" : "c107075aa8d394be7aced273a46e944afe8b321b", + "version" : "3.8.0" + } + } + ], + "version" : 2 +} From 5b555eb756827e332ceaabc6155c25ea3de649da Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 28 May 2026 09:14:22 +0200 Subject: [PATCH 30/94] fix: resolve small issues --- .vscode/launch.json | 2 +- pubspec.lock | 384 +++++++++++++++++++++++--------------------- 2 files changed, 205 insertions(+), 181 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 0dafcd10..570ef544 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,7 @@ "--dart-define", "deployment-mode=dev", "--dart-define", - "debug-level=none" + "debug-level=debug" ], "flutterMode": "debug" }, diff --git a/pubspec.lock b/pubspec.lock index 2425195c..fe9016e0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "93.0.0" air_quality: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "10.0.1" app_version_update: dependency: "direct main" description: @@ -37,18 +37,18 @@ packages: dependency: "direct main" description: name: appcheck - sha256: "6971b1ae5833b15b1abc29f7d03d08db79577b2934ceb34fe39ab937b53c2f17" + sha256: "63abf1ba508a86cd5f7bf134d202d008562ac9bbdbc713c92c28bf3f45beca1d" url: "https://pub.dev" source: hosted - version: "1.5.4+1" + version: "1.7.0" archive: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" audio_session: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: audioplayers - sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 url: "https://pub.dev" source: hosted - version: "6.5.1" + version: "6.6.0" audioplayers_android: dependency: transitive description: @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: audioplayers_darwin - sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" audioplayers_linux: dependency: transitive description: @@ -133,18 +133,18 @@ packages: dependency: transitive description: name: audioplayers_web - sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.2.0" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.3.0" base_codecs: dependency: transitive description: @@ -189,18 +189,18 @@ packages: dependency: transitive description: name: build - sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.0.6" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -209,30 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.1" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46 - url: "https://pub.dev" - source: hosted - version: "3.0.3" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 - url: "https://pub.dev" - source: hosted - version: "2.7.1" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b" + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" url: "https://pub.dev" source: hosted - version: "9.3.1" + version: "2.15.0" built_collection: dependency: transitive description: @@ -245,50 +229,50 @@ packages: dependency: transitive description: name: built_value - sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" url: "https://pub.dev" source: hosted - version: "8.12.0" + version: "8.12.6" camera: dependency: "direct main" description: name: camera - sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" url: "https://pub.dev" source: hosted - version: "0.11.3" + version: "0.11.4" camera_android_camerax: dependency: "direct main" description: name: camera_android_camerax - sha256: d5256612833f9169c1698599a87370490622a188c5a7fb601169bb7b2f41f22b + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" url: "https://pub.dev" source: hosted - version: "0.6.24+1" + version: "0.6.30" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "34bcd5db30e52414f1f0783c5e3f566909fab14141a21b3b576c78bd35382bf6" + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" url: "https://pub.dev" source: hosted - version: "0.9.22+4" + version: "0.9.23+2" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" camera_web: dependency: transitive description: name: camera_web - sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+3" carp_audio_package: dependency: "direct main" description: @@ -381,10 +365,10 @@ packages: dependency: "direct main" description: name: carp_themes_package - sha256: "48e747c82392db406531289828b4143275b28736354fb9e7bc62cfe1a189d675" + sha256: "2dac21da0b77dc5fbc26e98629a82ad859904a78a4b28b3501c60d0a723e59e2" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.4+1" carp_webservices: dependency: "direct main" description: @@ -433,14 +417,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + url: "https://pub.dev" + source: hosted + version: "1.2.0" code_builder: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" cognition_package: dependency: "direct main" description: @@ -469,10 +461,10 @@ packages: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" convert: dependency: transitive description: @@ -517,10 +509,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+2" crypto: dependency: transitive description: @@ -549,34 +541,34 @@ packages: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.7" data_serializer: dependency: transitive description: name: data_serializer - sha256: "547f06990e8e8abce76267bd7b8a071ddc8fe109316ae02402e761c892dd33f6" + sha256: "75e94d751e6f04ae6babc40c2436445c73691f60bb6d9eb7e026e50eb0ddb5de" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" dchs_flutter_beacon: dependency: transitive description: name: dchs_flutter_beacon - sha256: "392d56d69585845311fba75493e852fab78eb9b4669812758e6f475f861f9862" + sha256: d713882502946fe4189bc0ba6542c3670a3ed8670f1d5494da5e1c59394df26d url: "https://pub.dev" source: hosted - version: "0.6.6" + version: "0.6.8" desktop_webview_window: dependency: transitive description: @@ -621,10 +613,10 @@ packages: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" exception_templates: dependency: transitive description: @@ -653,10 +645,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -807,10 +799,10 @@ packages: dependency: "direct main" description: name: flutter_plugin_android_lifecycle - sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" url: "https://pub.dev" source: hosted - version: "2.0.32" + version: "2.0.34" flutter_secure_storage: dependency: transitive description: @@ -863,34 +855,34 @@ packages: dependency: transitive description: name: flutter_sound - sha256: ef89477f6e8ce2fa395158ebc4a8b11982e3ada440b4021c06fd97a4e771554b + sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85" url: "https://pub.dev" source: hosted - version: "9.28.0" + version: "9.30.0" flutter_sound_platform_interface: dependency: transitive description: name: flutter_sound_platform_interface - sha256: "3394d7e664a09796818014ff85a81db0dec397f4c286cbe52f8783886fa5a497" + sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861" url: "https://pub.dev" source: hosted - version: "9.28.0" + version: "9.30.0" flutter_sound_web: dependency: transitive description: name: flutter_sound_web - sha256: "4e10c94a8574bd93bb8668af59bf76f5312a890bccd3778d73168a7133217dc5" + sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb" url: "https://pub.dev" source: hosted - version: "9.28.0" + version: "9.30.0" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "055de8921be7b8e8b98a233c7a5ef84b3a6fcc32f46f1ebf5b9bb3576d108355" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -961,10 +953,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" graphs: dependency: transitive description: @@ -981,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted version: "12.2.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" html: dependency: transitive description: @@ -1017,10 +1017,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" intl: dependency: "direct main" description: @@ -1053,6 +1053,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" jose_plus: dependency: transitive description: @@ -1073,18 +1089,18 @@ packages: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 url: "https://pub.dev" source: hosted - version: "6.11.1" + version: "6.14.0" just_audio: dependency: transitive description: @@ -1161,10 +1177,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" list_operators: dependency: transitive description: @@ -1201,10 +1217,10 @@ packages: dependency: transitive description: name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" logging: dependency: transitive description: @@ -1317,6 +1333,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" oidc: dependency: transitive description: @@ -1433,18 +1457,18 @@ packages: dependency: "direct main" description: name: open_settings_plus - sha256: "7576f0922a650dd5d9c128235cb7537e56a99a5f3ac0943a4a07613cc25c4582" + sha256: "17c5d1c90affd224b0c19ee3dc5bbcff39b630889a90912ba1441c7e821fa914" url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.2" openmhealth_schemas: dependency: transitive description: name: openmhealth_schemas - sha256: "52fe06194d5cc44721827a67432253444fe2dc245d2dc6649bf97b2a5b82b3a9" + sha256: "04dfadafe6cde266846fc778448bb8c13a798066e0e9e54caf213279284bad2e" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.3.1" package_config: dependency: transitive description: @@ -1497,18 +1521,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.20" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -1537,10 +1561,10 @@ packages: dependency: transitive description: name: pedometer - sha256: "71e2608ad472d1fc3bcfce14a151e5c8229c5edd9e2a8a0f1ec416e3ceb00e1a" + sha256: "146d9663d4af6041328ac15cd96cdcd426b866d277d72f540f701193a61a193d" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.2.0" permission_handler: dependency: "direct main" description: @@ -1593,10 +1617,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -1641,10 +1665,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" protobuf: dependency: transitive description: @@ -1673,10 +1697,10 @@ packages: dependency: "direct main" description: name: qr_code_scanner_plus - sha256: b764e5004251c58d9dee0c295e6006e05bd8d249e78ac3383abdb5afe0a996cd + sha256: dae0596b2763c2fd0294f5cfddb1d3a21577ae4dc7fc1449eb5aafc957872f61 url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.1.1" recase: dependency: transitive description: @@ -1685,6 +1709,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" reorderables: dependency: transitive description: @@ -1697,10 +1729,10 @@ packages: dependency: "direct main" description: name: research_package - sha256: da2f8819f53f52dbc3ecce1c64fbc3208e7f75e35877c91366f598ff7857472d + sha256: "399b3ed901aeb35641039dcf0a9ae3c96288054e30833b6ee4ae4cbbce566a3d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" retry: dependency: transitive description: @@ -1753,26 +1785,26 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1785,10 +1817,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1862,18 +1894,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.2.3" source_helper: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.12" source_map_stack_trace: dependency: transitive description: @@ -1894,34 +1926,34 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqflite: dependency: transitive description: name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.2+1" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" url: "https://pub.dev" source: hosted - version: "2.4.2+2" + version: "2.4.2+3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.8" sqflite_darwin: dependency: transitive description: @@ -1950,10 +1982,10 @@ packages: dependency: transitive description: name: statistics - sha256: "83ff786a263790576cedc47a03806abd89e7c8cae0eb0d745bb50032838826fa" + sha256: "9da2eb983faee4a3be1f712f08305c5d93f1e37017d110b5c8d0e83736b5fb7f" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" stats: dependency: transitive description: @@ -1990,10 +2022,10 @@ packages: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.0+1" system_info2: dependency: transitive description: @@ -2050,14 +2082,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -2070,18 +2094,18 @@ packages: dependency: transitive description: name: universal_html - sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.0" universal_io: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" upower: dependency: transitive description: @@ -2102,34 +2126,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" url: "https://pub.dev" source: hosted - version: "6.3.24" + version: "6.3.30" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.5" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -2142,34 +2166,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.2" vector_graphics_codec: dependency: transitive description: @@ -2182,10 +2206,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.3" vector_math: dependency: transitive description: @@ -2198,34 +2222,34 @@ packages: dependency: "direct main" description: name: video_player - sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" url: "https://pub.dev" source: hosted - version: "2.10.1" + version: "2.11.1" video_player_android: dependency: transitive description: name: video_player_android - sha256: cf768d02924b91e333e2bc1ff928528f57d686445874f383bafab12d0bdfc340 + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" url: "https://pub.dev" source: hosted - version: "2.8.17" + version: "2.9.5" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "03fc6d07dba2499588d30887329b399c1fe2d68ce4b7fcff0db79f44a2603f69" + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" url: "https://pub.dev" source: hosted - version: "2.8.6" + version: "2.9.7" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" url: "https://pub.dev" source: hosted - version: "6.6.0" + version: "6.7.0" video_player_web: dependency: transitive description: @@ -2238,18 +2262,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" watcher: dependency: transitive description: name: watcher - sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.1" weather: dependency: transitive description: @@ -2347,5 +2371,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" From 79a263b668d6ce09985111d125b0a6b33fe2c5dc Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 28 May 2026 15:13:44 +0200 Subject: [PATCH 31/94] chore(ci): pin Flutter, fix Podfile + Package.resolved drift Pins Flutter 3.44.0 across all CI environments (Xcode Cloud, GH Actions build/test, GH Actions Play Store deploy, release-on-tag); bumps Android Gradle Plugin to 8.9.1 for AndroidX core/browser 1.17+; regenerates ios/Podfile.lock for flutter_sound_core 9.30.0 and ios/Package.resolved for the current SPM dep graph. --- .github/workflows/build.yml | 2 +- .github/workflows/create_release_on_tag.yml | 15 +- ..._to_google_play_store_fastlane_release.yml | 216 +++++++++--------- android/settings.gradle | 2 +- ios/Podfile.lock | 162 +------------ .../xcshareddata/swiftpm/Package.resolved | 9 + .../xcshareddata/swiftpm/Package.resolved | 9 + ios/ci_scripts/ci_post_clone.sh | 2 +- lib/main.dart | 1 + pubspec.lock | 20 +- pubspec.yaml | 2 +- 11 files changed, 153 insertions(+), 287 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfa64510..f3a9825a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: uses: flutter-actions/setup-flutter@v4 with: channel: stable - version: 3.41.9 + version: 3.44.0 - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/create_release_on_tag.yml b/.github/workflows/create_release_on_tag.yml index 3ae5aa5c..6722f8c9 100644 --- a/.github/workflows/create_release_on_tag.yml +++ b/.github/workflows/create_release_on_tag.yml @@ -1,7 +1,7 @@ on: push: tags: - - 'v*' + - "v*" name: Create Release @@ -13,8 +13,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: - distribution: 'adopt' # See 'Supported distributions' for available options - java-version: '17' + distribution: "adopt" # See 'Supported distributions' for available options + java-version: "17" # - name: Set up JDK 17 # uses: actions/setup-java@v4 @@ -34,16 +34,15 @@ jobs: - name: Cache Gradle id: cache-gradle uses: actions/cache@v3 - with: + with: path: | ~/.gradle/caches ~/.gradle/wrapper/ key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.41.9 $FOLDER + run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.44.0 $FOLDER - name: Add Flutter to PATH run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH @@ -72,7 +71,7 @@ jobs: run: | flutter --version pwd - + - name: Build APK run: | flutter clean @@ -84,7 +83,7 @@ jobs: uses: underwindfall/create-release-with-debugapk@v2.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: + with: tag_name: ${{ github.ref }} asset_path: build/app/outputs/flutter-apk/app-release.apk asset_name: carp-studies-app-${{ github.ref_name }}.apk diff --git a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml index 1e571189..8c60d2b5 100644 --- a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml +++ b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml @@ -2,112 +2,112 @@ name: Upload to Google Store run-name: ${{github.actor}} is pushing to branch and deploying to Google Store on: - push: - branches: - - master - - test + push: + branches: + - master + - test jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: 'adopt' # See 'Supported distributions' for available options - java-version: '17' - - - name: Cache Flutter - id: cache-flutter - uses: actions/cache@v3 - with: - path: | - flutter - ~/.pub-cache - key: flutter-${{ hashFiles('**/pubspec.lock') }} - - - name: Cache Gradle - id: cache-gradle - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper/ - key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.41.9 $FOLDER - - - name: Add Flutter to PATH - run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install dependencies - run: flutter pub get - - # Setup Ruby, Bundler, and Gemfile dependencies - - name: Setup Fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.3' - bundler-cache: false - working-directory: android - - - name: Extract last commit message - id: extract-commit-message - run: | - COMMIT_MESSAGE=$(git log -1 --pretty=%B | tr -d '\n') - COMMIT_MESSAGE="${COMMIT_MESSAGE}" - echo "Commit Message: $COMMIT_MESSAGE" - echo "commit_message=$COMMIT_MESSAGE" >> $GITHUB_ENV - - - name: Extract version code and version name from pubspec.yaml - id: extract-version - run: | - # Navigate to the root directory (if not already there) - pwd - cd $GITHUB_WORKSPACE - - # Extract version name and version code from pubspec.yaml - VERSION_NAME=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1) - VERSION_CODE=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2) - - echo "Version Name: $VERSION_NAME" - echo "Version Code: $VERSION_CODE" - - echo "version_name=$VERSION_NAME" >> $GITHUB_ENV - echo "version_code=$VERSION_CODE" >> $GITHUB_ENV - - - name: Create or update changelog file - run: | - pwd - mkdir -p ./android/fastlane/metadata/android/en-GB/changelogs - echo "${{ env.commit_message }}" > "./android/fastlane/metadata/android/en-GB/changelogs/${{ env.version_code }}.txt" - - - name: Configure Keystore - run: | - echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > app/upload-keystore.jks - echo "$PLAY_STORE_CONFIG_JSON" > PLAY_STORE_CONFIG.json - echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> key.properties - echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> key.properties - echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> key.properties - echo "storeFile=upload-keystore.jks" >> key.properties - env: - PLAY_STORE_UPLOAD_KEY: ${{ secrets.PLAY_STORE_UPLOAD_KEY }} - KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} - KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} - KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} - PLAY_STORE_CONFIG_JSON: ${{secrets.PLAY_STORE_CONFIG_JSON}} - working-directory: android - - - if: github.ref == 'refs/heads/master' - run: fastlane release - env: - PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json - working-directory: android - - - if: github.ref == 'refs/heads/test' - run: fastlane test - env: - PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json - working-directory: android \ No newline at end of file + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: "adopt" # See 'Supported distributions' for available options + java-version: "17" + + - name: Cache Flutter + id: cache-flutter + uses: actions/cache@v3 + with: + path: | + flutter + ~/.pub-cache + key: flutter-${{ hashFiles('**/pubspec.lock') }} + + - name: Cache Gradle + id: cache-gradle + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper/ + key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + + - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} + name: Install Flutter + run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.44.0 $FOLDER + + - name: Add Flutter to PATH + run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH + + - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} + name: Install dependencies + run: flutter pub get + + # Setup Ruby, Bundler, and Gemfile dependencies + - name: Setup Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: false + working-directory: android + + - name: Extract last commit message + id: extract-commit-message + run: | + COMMIT_MESSAGE=$(git log -1 --pretty=%B | tr -d '\n') + COMMIT_MESSAGE="${COMMIT_MESSAGE}" + echo "Commit Message: $COMMIT_MESSAGE" + echo "commit_message=$COMMIT_MESSAGE" >> $GITHUB_ENV + + - name: Extract version code and version name from pubspec.yaml + id: extract-version + run: | + # Navigate to the root directory (if not already there) + pwd + cd $GITHUB_WORKSPACE + + # Extract version name and version code from pubspec.yaml + VERSION_NAME=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1) + VERSION_CODE=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2) + + echo "Version Name: $VERSION_NAME" + echo "Version Code: $VERSION_CODE" + + echo "version_name=$VERSION_NAME" >> $GITHUB_ENV + echo "version_code=$VERSION_CODE" >> $GITHUB_ENV + + - name: Create or update changelog file + run: | + pwd + mkdir -p ./android/fastlane/metadata/android/en-GB/changelogs + echo "${{ env.commit_message }}" > "./android/fastlane/metadata/android/en-GB/changelogs/${{ env.version_code }}.txt" + + - name: Configure Keystore + run: | + echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > app/upload-keystore.jks + echo "$PLAY_STORE_CONFIG_JSON" > PLAY_STORE_CONFIG.json + echo "keyPassword=$KEYSTORE_KEY_PASSWORD" >> key.properties + echo "storePassword=$KEYSTORE_STORE_PASSWORD" >> key.properties + echo "keyAlias=$KEYSTORE_KEY_ALIAS" >> key.properties + echo "storeFile=upload-keystore.jks" >> key.properties + env: + PLAY_STORE_UPLOAD_KEY: ${{ secrets.PLAY_STORE_UPLOAD_KEY }} + KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} + KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} + PLAY_STORE_CONFIG_JSON: ${{secrets.PLAY_STORE_CONFIG_JSON}} + working-directory: android + + - if: github.ref == 'refs/heads/master' + run: fastlane release + env: + PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json + working-directory: android + + - if: github.ref == 'refs/heads/test' + run: fastlane test + env: + PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json + working-directory: android diff --git a/android/settings.gradle b/android/settings.gradle index 52bd901a..5a5bdaff 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.2" - id "com.android.application" version "8.9.0" apply false // this is AGP? + id "com.android.application" version "8.9.1" apply false id 'com.android.library' version '7.4.2' apply false id 'org.jetbrains.kotlin.android' version '2.1.0' apply false } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cffb65a3..e9b69ddd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,55 +1,21 @@ PODS: - - AppAuth (2.0.0): - - AppAuth/Core (= 2.0.0) - - AppAuth/ExternalUserAgent (= 2.0.0) - - AppAuth/Core (2.0.0) - - AppAuth/ExternalUserAgent (2.0.0): - - AppAuth/Core - - "appcheck (1.5.4+1)": - - Flutter - - audio_session (0.0.1): - - Flutter - audio_streamer (0.0.1): - Flutter - - audioplayers_darwin (0.0.1): - - Flutter - - FlutterMacOS - - battery_plus (1.0.0): - - Flutter - - camera_avfoundation (0.0.1): - - Flutter - - connectivity_plus (0.0.1): - - Flutter - dchs_flutter_beacon (0.6.5): - Flutter - - device_info_plus (0.0.1): - - Flutter - Flutter (1.0.0) - flutter_activity_recognition (0.0.1): - Flutter - - flutter_appauth (0.0.1): - - AppAuth (= 2.0.0) - - Flutter - - flutter_blue_plus_darwin (0.0.2): - - Flutter - - FlutterMacOS - - flutter_local_notifications (0.0.1): - - Flutter - flutter_secure_storage (6.0.0): - Flutter - - flutter_sound (9.28.0): - - Flutter - - flutter_sound_core (= 9.28.0) - - flutter_sound_core (9.28.0) - - flutter_timezone (0.0.1): + - flutter_sound (9.30.0): - Flutter + - flutter_sound_core (= 9.30.0) + - flutter_sound_core (9.30.0) - flutter_web_auth_2 (3.0.0): - Flutter - health (12.2.1): - Flutter - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - location (0.0.1): - Flutter - mdsflutter (0.0.1): @@ -57,20 +23,10 @@ PODS: - Movesense - SwiftProtobuf - Movesense (3.33.1) - - MTBBarcodeScanner (5.0.11) - - network_info_plus (0.0.1): - - Flutter - oidc_ios (0.0.1): - Flutter - open_settings_plus (0.0.1): - Flutter - - package_info_plus (0.4.5): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - pedometer (0.0.1): - - Flutter - permission_handler_apple (9.3.0): - Flutter - polar (0.0.1): @@ -80,155 +36,71 @@ PODS: - RxSwift (~> 6.8.0) - SwiftProtobuf (~> 1.0) - Zip (~> 2.1.2) - - qr_code_scanner_plus (0.2.6): - - Flutter - - MTBBarcodeScanner - RxSwift (6.8.0) - screen_state (1.0.0): - Flutter - - sensors_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - SwiftProtobuf (1.33.3) - - url_launcher_ios (0.0.1): - - Flutter - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS - Zip (2.1.2) DEPENDENCIES: - - appcheck (from `.symlinks/plugins/appcheck/ios`) - - audio_session (from `.symlinks/plugins/audio_session/ios`) - audio_streamer (from `.symlinks/plugins/audio_streamer/ios`) - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - - battery_plus (from `.symlinks/plugins/battery_plus/ios`) - - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - dchs_flutter_beacon (from `.symlinks/plugins/dchs_flutter_beacon/ios`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_activity_recognition (from `.symlinks/plugins/flutter_activity_recognition/ios`) - - flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`) - - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) - - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - health (from `.symlinks/plugins/health/ios`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - location (from `.symlinks/plugins/location/ios`) - mdsflutter (from `.symlinks/plugins/mdsflutter/ios`) - Movesense (from `https://bitbucket.org/movesense/movesense-mobile-lib/`) - - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - oidc_ios (from `.symlinks/plugins/oidc_ios/ios`) - open_settings_plus (from `.symlinks/plugins/open_settings_plus/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - pedometer (from `.symlinks/plugins/pedometer/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - polar (from `.symlinks/plugins/polar/ios`) - - qr_code_scanner_plus (from `.symlinks/plugins/qr_code_scanner_plus/ios`) - screen_state (from `.symlinks/plugins/screen_state/ios`) - - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) SPEC REPOS: trunk: - - AppAuth - flutter_sound_core - - MTBBarcodeScanner - PolarBleSdk - RxSwift - SwiftProtobuf - Zip EXTERNAL SOURCES: - appcheck: - :path: ".symlinks/plugins/appcheck/ios" - audio_session: - :path: ".symlinks/plugins/audio_session/ios" audio_streamer: :path: ".symlinks/plugins/audio_streamer/ios" - audioplayers_darwin: - :path: ".symlinks/plugins/audioplayers_darwin/darwin" - battery_plus: - :path: ".symlinks/plugins/battery_plus/ios" - camera_avfoundation: - :path: ".symlinks/plugins/camera_avfoundation/ios" - connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" dchs_flutter_beacon: :path: ".symlinks/plugins/dchs_flutter_beacon/ios" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_activity_recognition: :path: ".symlinks/plugins/flutter_activity_recognition/ios" - flutter_appauth: - :path: ".symlinks/plugins/flutter_appauth/ios" - flutter_blue_plus_darwin: - :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" - flutter_local_notifications: - :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_sound: :path: ".symlinks/plugins/flutter_sound/ios" - flutter_timezone: - :path: ".symlinks/plugins/flutter_timezone/ios" flutter_web_auth_2: :path: ".symlinks/plugins/flutter_web_auth_2/ios" health: :path: ".symlinks/plugins/health/ios" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" location: :path: ".symlinks/plugins/location/ios" mdsflutter: :path: ".symlinks/plugins/mdsflutter/ios" Movesense: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ - network_info_plus: - :path: ".symlinks/plugins/network_info_plus/ios" oidc_ios: :path: ".symlinks/plugins/oidc_ios/ios" open_settings_plus: :path: ".symlinks/plugins/open_settings_plus/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - pedometer: - :path: ".symlinks/plugins/pedometer/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" polar: :path: ".symlinks/plugins/polar/ios" - qr_code_scanner_plus: - :path: ".symlinks/plugins/qr_code_scanner_plus/ios" screen_state: :path: ".symlinks/plugins/screen_state/ios" - sensors_plus: - :path: ".symlinks/plugins/sensors_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/darwin" CHECKOUT OPTIONS: Movesense: @@ -236,50 +108,26 @@ CHECKOUT OPTIONS: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ SPEC CHECKSUMS: - AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 - appcheck: 3c94d0ffc94bd639938cac7427d5b13df2795404 - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 audio_streamer: 2e472b9f81cec5e381c4cf7667afa3055dcb45a4 - audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd - battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977 - camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd dchs_flutter_beacon: 88d72cc467de508d621e454ea66c6231d0897d4c - device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_activity_recognition: 52dfad4c1e9cec99f0841b3ed0efcc9d424b3deb - flutter_appauth: d4abcf54856e5d8ba82ed7646ffc83245d4aa448 - flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 - flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c - flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 + flutter_sound: d95194f6476c9ad211d22b3a414d852c12c7ca44 + flutter_sound_core: 7f2626d249d3a57bfa6da892ef7e22d234482c1a flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 health: fe206e65f13a9b88623605fbd8af8f029e23dc35 - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed location: 155caecf9da4f280ab5fe4a55f94ceccfab838f8 mdsflutter: bd3c0b3335d50947d1a192cf70f930e0fb04a766 Movesense: dc64047c1feb856b7f7ac6ea2c67685350d99dd5 - MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb - network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc oidc_ios: 16966cad509ce6850ca4ca1216c5138bef2a8726 open_settings_plus: d19f91e8a04649358a51c19b484ce2e637149d70 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 - pedometer: 1c5eaab0c6bce8eb7651f7095553b5081c9d06ed permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d polar: 0374f6063ade7e2dd85d626f87cbe2393d3eda7c PolarBleSdk: bfb57cf8350f0c53d441b8632ba6df363ce7c79f - qr_code_scanner_plus: 7e087021bc69873140e0754750eb87d867bed755 RxSwift: 4e28be97cbcfeee614af26d83415febbf2bf6f45 screen_state: 52d6e997d31bddba6417c60d9cdd22effd0320a7 - sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 PODFILE CHECKSUM: 45842edf150243fea66883d4dfad1e5ddb428147 diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a71b474b..7f1c67b8 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS", + "state" : { + "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", + "version" : "2.0.0" + } + }, { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index a71b474b..7f1c67b8 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS", + "state" : { + "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", + "version" : "2.0.0" + } + }, { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index 9ee8ba70..45334acb 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -7,7 +7,7 @@ set -ex set -euo pipefail # Install Flutter using git. -git clone https://github.com/flutter/flutter.git --depth 1 -b 3.41.9 $HOME/flutter +git clone https://github.com/flutter/flutter.git --depth 1 -b 3.44.0 $HOME/flutter export PATH="$PATH:$HOME/flutter/bin" # Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms. diff --git a/lib/main.dart b/lib/main.dart index 938949a9..eb8d3112 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'dart:io'; import 'package:app_version_update/data/models/app_version_result.dart'; import 'package:async/async.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; diff --git a/pubspec.lock b/pubspec.lock index fe9016e0..569ab9b3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -365,10 +365,10 @@ packages: dependency: "direct main" description: name: carp_themes_package - sha256: "2dac21da0b77dc5fbc26e98629a82ad859904a78a4b28b3501c60d0a723e59e2" + sha256: "9753723406efa78fe7f379d3a5a51751acd427b89f5787bfc64f2b89eb3a8f78" url: "https://pub.dev" source: hosted - version: "0.0.4+1" + version: "0.0.5" carp_webservices: dependency: "direct main" description: @@ -1257,10 +1257,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -2046,26 +2046,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" timeago: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 04b1f223..b49b89e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: carp_polar_package: ^1.6.1 carp_health_package: ^3.2.0 carp_movesense_package: ^1.7.2 - carp_themes_package: ^0.0.1 + carp_themes_package: ^0.0.5 carp_webservices: ^3.8.0 carp_backend: ^1.9.2 From 37454566c78a2d642501fa27e4320d2857a02c9a Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 1 Jun 2026 11:31:09 +0200 Subject: [PATCH 32/94] chore(android): bump fastlane and bundled gems Re-resolve the fastlane dependency tree in android/Gemfile.lock via bundle update. Picks up the faraday 1.10.5 CVE-2026-25765 backport and aws-sdk-s3 1.224.0, plus fastlane 2.235.0 and refreshed Google Play API clients. Resolving the tree together supersedes the per-gem dependabot bumps in #554 and #555. --- android/Gemfile.lock | 231 ++++++++++++++++++++++++------------------- 1 file changed, 127 insertions(+), 104 deletions(-) diff --git a/android/Gemfile.lock b/android/Gemfile.lock index 3d9a5e6f..0e43bde2 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -1,43 +1,49 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) - rexml - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - artifactory (3.0.15) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.779.0) - aws-sdk-core (3.174.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1255.0) + aws-sdk-core (3.250.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.66.0) - aws-sdk-core (~> 3, >= 3.174.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.124.0) - aws-sdk-core (~> 3, >= 3.174.0) + logger + aws-sdk-kms (1.128.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) - digest-crc (0.6.4) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.100.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -49,32 +55,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.2.7) - fastlane (2.213.0) - CFPropertyList (>= 2.3, < 4.0.0) + fastimage (2.4.1) + fastlane (2.235.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -82,134 +92,147 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) google-cloud-storage (~> 1.31) highline (~> 2.0) + http-cookie (~> 1.0.5) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.1.0, < 4) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) naturally (~> 2.2) - optparse (~> 0.1.1) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.43.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.0) + google-apis-androidpublisher_v3 (0.101.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - webrick - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.19.0) - google-apis-core (>= 0.9.0, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.63.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.60.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.5.2) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - memoist (~> 0.16) + google-logging-utils (0.2.0) + googleauth (1.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.6.3) - jwt (2.7.1) - memoist (0.16.2) - mini_magick (4.12.0) - mini_mime (1.1.2) - multi_json (1.15.0) - multipart-post (2.3.0) - nanaimo (0.3.0) - naturally (2.2.1) - optparse (0.1.1) + json (2.19.7) + jwt (3.2.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.8.1) os (1.1.4) - plist (3.7.0) - public_suffix (5.0.1) - rake (13.0.6) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.2.5) - rouge (2.0.7) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) - security (0.1.3) - signet (0.17.0) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (1.8.0) - webrick (1.8.1) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.22.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - xcpretty (0.3.0) - rouge (~> 2.0.7) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS arm64-darwin-22 + arm64-darwin-25 x86_64-linux DEPENDENCIES From 7fc3c34224fb7bf4fead859fcfb99c5f86427a9a Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 1 Jun 2026 11:34:17 +0200 Subject: [PATCH 33/94] chore(release): bump build number to 92 for internal track --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index b49b89e4..03790751 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.2.0+91 +version: 4.2.0+92 environment: sdk: ">=3.2.0 <4.0.0" From 361685f25bd83ebaba2327760d2cfb4f9c6ed2ea Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 2 Jun 2026 10:42:04 +0200 Subject: [PATCH 34/94] ci: scope CI to PRs, gate Android prod on tags, iOS auto build number Run validation (build.yml) only on pull requests into develop/test/ master; drop the push triggers so feature-branch pushes don't run CI and there are no duplicate runs, and add a flutter clean step. Stop deploying on push to master: the internal track still deploys on push to test, while the production track now deploys from v* tags via the tag workflow (fastlane release). Drop flutter pub upgrade from the release lanes so builds are reproducible. Use Xcode Cloud's CI_BUILD_NUMBER for the iOS build number so it no longer relies on a manual pubspec bump. Bump the Android build number to 93 for the next internal upload. --- .github/workflows/build.yml | 9 +++-- .github/workflows/create_release_on_tag.yml | 23 ++++++++---- ..._to_google_play_store_fastlane_release.yml | 7 ---- android/fastlane/Fastfile | 36 +++++++++---------- ios/ci_scripts/ci_post_clone.sh | 4 ++- pubspec.yaml | 2 +- 6 files changed, 43 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3a9825a..3ec84ab6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,10 +5,12 @@ name: Flutter build and test +# CI validation runs only on pull requests into the integration branches. +# Feature-branch pushes don't trigger anything; nothing runs on push to a +# branch, so there are no duplicate runs. on: - push: - branches: [master, test] pull_request: + branches: [develop, test, master] env: FLUTTER_FOLDER: '$HOME/flutter' @@ -35,6 +37,9 @@ jobs: channel: stable version: 3.44.0 + - name: Clean + run: flutter clean + - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/create_release_on_tag.yml b/.github/workflows/create_release_on_tag.yml index 6722f8c9..5d4e49be 100644 --- a/.github/workflows/create_release_on_tag.yml +++ b/.github/workflows/create_release_on_tag.yml @@ -16,12 +16,6 @@ jobs: distribution: "adopt" # See 'Supported distributions' for available options java-version: "17" - # - name: Set up JDK 17 - # uses: actions/setup-java@v4 - # with: - # distribution: 'adopt' # See 'Supported distributions' for available options - # java-version: '17' - - name: Cache Flutter id: cache-flutter uses: actions/cache@v3 @@ -76,7 +70,6 @@ jobs: run: | flutter clean flutter pub get - flutter pub upgrade flutter build apk --release --no-tree-shake-icons - name: Create Release and upload apk @@ -88,3 +81,19 @@ jobs: asset_path: build/app/outputs/flutter-apk/app-release.apk asset_name: carp-studies-app-${{ github.ref_name }}.apk asset_content_type: application/vnd.android.package-archive + + # Setup Ruby/Bundler so fastlane is available + - name: Setup Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: false + working-directory: android + + # Production release to Google Play runs only on tags (like the iOS + # App Store workflow on Xcode Cloud). + - if: startsWith(github.ref, 'refs/tags/') + run: fastlane release + env: + PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json + working-directory: android diff --git a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml index 8c60d2b5..d1a43268 100644 --- a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml +++ b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml @@ -4,7 +4,6 @@ run-name: ${{github.actor}} is pushing to branch and deploying to Google Store on: push: branches: - - master - test jobs: build: @@ -100,12 +99,6 @@ jobs: PLAY_STORE_CONFIG_JSON: ${{secrets.PLAY_STORE_CONFIG_JSON}} working-directory: android - - if: github.ref == 'refs/heads/master' - run: fastlane release - env: - PLAY_STORE_JSON_KEY: PLAY_STORE_CONFIG.json - working-directory: android - - if: github.ref == 'refs/heads/test' run: fastlane test env: diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 7fd1c19f..fe8ed7fb 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -10,13 +10,10 @@ # https://docs.fastlane.tools/plugins/available-plugins # -# Uncomment the line if you want fastlane to automatically update itself -update_fastlane - default_platform(:android) platform :android do - + desc "Submit a new release build to Google Play's Production track" lane :release do current_production_version_code = google_play_track_version_codes(track: 'production').first.to_i @@ -27,15 +24,14 @@ platform :android do if version_parts.size == 2 version_number = version_parts[1].to_i if version_number > current_production_version_code && version_number > current_internal_version_code - sh "flutter clean" - sh "flutter pub get" - sh "flutter pub upgrade" - sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons" + sh "flutter clean" + sh "flutter pub get" + sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons" - upload_to_play_store( - track: 'production', - aab: '../build/app/outputs/bundle/release/app-release.aab', - ) + upload_to_play_store( + track: 'production', + aab: '../build/app/outputs/bundle/release/app-release.aab', + ) else UI.user_error!("Version code must be greater than or equal to the current version code: #{current_production_version_code} and #{current_internal_version_code}") end @@ -58,15 +54,15 @@ platform :android do if version_number > current_production_version_code && version_number > current_internal_version_code test_version = "#{base_version}-test+#{version_number}" sh "sed -i 's/^version: .*/version: #{test_version}/' #{pubspec_path}" - sh "flutter clean" - sh "flutter pub get" - sh "flutter pub upgrade" - sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --dart-define='deployment-mode=test'" + sh "flutter clean" + sh "flutter pub get" + + sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --dart-define='deployment-mode=test'" - upload_to_play_store( - track: 'internal', - aab: '../build/app/outputs/bundle/release/app-release.aab', - ) + upload_to_play_store( + track: 'internal', + aab: '../build/app/outputs/bundle/release/app-release.aab', + ) else UI.user_error!("Version code must be greater than or equal to the current version code: #{current_production_version_code} and #{current_internal_version_code}") end diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index 45334acb..b04288bd 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -31,6 +31,8 @@ elif [ "${CI_WORKFLOW:-}" = "Test - Internal Testing" ]; then MODE="test" fi -flutter build ios --config-only --release --dart-define="deployment-mode=$MODE" +# Build number is Xcode Cloud's auto-incrementing counter (CI_BUILD_NUMBER), +# so iOS builds don't rely on a manual bump in pubspec. +flutter build ios --config-only --release --build-number="$CI_BUILD_NUMBER" --dart-define="deployment-mode=$MODE" exit 0 \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 03790751..3cb19313 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.2.0+92 +version: 4.2.0+93 environment: sdk: ">=3.2.0 <4.0.0" From 0b2cae5e46dc48c6b141d8929de649c845f5f8e0 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 2 Jun 2026 10:49:32 +0200 Subject: [PATCH 35/94] ci: use commit subject for Play release notes (500-char cap) --- .../workflows/deploy_to_google_play_store_fastlane_release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml index d1a43268..b2dd1cca 100644 --- a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml +++ b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml @@ -55,7 +55,8 @@ jobs: - name: Extract last commit message id: extract-commit-message run: | - COMMIT_MESSAGE=$(git log -1 --pretty=%B | tr -d '\n') + # Subject line only: Play release notes are capped at 500 chars. + COMMIT_MESSAGE=$(git log -1 --pretty=%s | tr -d '\n') COMMIT_MESSAGE="${COMMIT_MESSAGE}" echo "Commit Message: $COMMIT_MESSAGE" echo "commit_message=$COMMIT_MESSAGE" >> $GITHUB_ENV From 0d84562b1f4c39eff5423697abe97ac7cacc7229 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 3 Jun 2026 14:27:00 +0200 Subject: [PATCH 36/94] chore(fvm): pin Flutter to 3.44.0 The SwiftPM and UIScene migrations on test require Flutter 3.44. Building with the previously pinned 3.41.9 left stale ephemeral SwiftPM packages, causing xcodebuild to fail package resolution (error 74). Pin the project to 3.44.0 so local builds match CI. --- .fvmrc | 3 +++ .gitignore | 3 +++ carp_study_app.code-workspace | 1 + 3 files changed, 7 insertions(+) create mode 100644 .fvmrc diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..457360f8 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.44.0" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 02b68057..fb1fca35 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,6 @@ carp/carpspec.yaml assets/carp/ assets/carp/lang/_da.json assets/carp/lang/_en.json + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/carp_study_app.code-workspace b/carp_study_app.code-workspace index 5f75755c..a2f866b8 100644 --- a/carp_study_app.code-workspace +++ b/carp_study_app.code-workspace @@ -21,6 +21,7 @@ "dart.debugExternalPackageLibraries": false, "dart.debugSdkLibraries": false, "dart.openDevTools": "never", + "dart.flutterSdkPath": ".fvm/flutter_sdk", }, "extensions": { "recommendations": [ From 9b1918927a27f0870ed3b823361206ec931ea0e5 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 3 Jun 2026 16:31:52 +0200 Subject: [PATCH 37/94] fix(icons): replace app icons with crisp opaque set for iOS/Android Regenerate launcher icons from the vector source with an opaque white background. iOS PNGs are flattened to remove the alpha channel (App Store rejects transparent marketing icons) and re-rendered at full resolution to fix the blurry icon. Add the missing Android hdpi density. --- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 892 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 852 -> 615 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 1482 -> 1210 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 2137 -> 1947 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 2720 -> 2741 bytes .../AppIcon.appiconset/100.png | Bin 0 -> 1158 bytes .../AppIcon.appiconset/1024.png | Bin 0 -> 23882 bytes .../AppIcon.appiconset/114.png | Bin 0 -> 1357 bytes .../AppIcon.appiconset/120.png | Bin 0 -> 1310 bytes .../AppIcon.appiconset/144.png | Bin 0 -> 1676 bytes .../AppIcon.appiconset/152.png | Bin 0 -> 1747 bytes .../AppIcon.appiconset/167.png | Bin 0 -> 2070 bytes .../AppIcon.appiconset/180.png | Bin 0 -> 2173 bytes .../Assets.xcassets/AppIcon.appiconset/20.png | Bin 0 -> 362 bytes .../Assets.xcassets/AppIcon.appiconset/29.png | Bin 0 -> 494 bytes .../Assets.xcassets/AppIcon.appiconset/40.png | Bin 0 -> 565 bytes .../Assets.xcassets/AppIcon.appiconset/50.png | Bin 0 -> 650 bytes .../Assets.xcassets/AppIcon.appiconset/57.png | Bin 0 -> 767 bytes .../Assets.xcassets/AppIcon.appiconset/58.png | Bin 0 -> 739 bytes .../Assets.xcassets/AppIcon.appiconset/60.png | Bin 0 -> 733 bytes .../Assets.xcassets/AppIcon.appiconset/72.png | Bin 0 -> 892 bytes .../Assets.xcassets/AppIcon.appiconset/76.png | Bin 0 -> 901 bytes .../Assets.xcassets/AppIcon.appiconset/80.png | Bin 0 -> 924 bytes .../Assets.xcassets/AppIcon.appiconset/87.png | Bin 0 -> 1111 bytes .../AppIcon.appiconset/Contents.json | 159 +++++++++++++++++- .../Icon-App-1024x1024@1x.png | Bin 15493 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 369 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 625 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 969 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 505 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 921 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 1289 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 625 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 1135 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 1711 -> 0 bytes .../AppIcon.appiconset/Icon-App-50x50@1x.png | Bin 833 -> 0 bytes .../AppIcon.appiconset/Icon-App-50x50@2x.png | Bin 1485 -> 0 bytes .../AppIcon.appiconset/Icon-App-57x57@1x.png | Bin 920 -> 0 bytes .../AppIcon.appiconset/Icon-App-57x57@2x.png | Bin 1662 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 1711 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 2439 -> 0 bytes .../AppIcon.appiconset/Icon-App-72x72@1x.png | Bin 1089 -> 0 bytes .../AppIcon.appiconset/Icon-App-72x72@2x.png | Bin 2015 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 1139 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 2077 -> 0 bytes .../Icon-App-83.5x83.5@2x.png | Bin 2271 -> 0 bytes 46 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..3d81132fae0649a51d01ef725660beb29b190799 GIT binary patch literal 892 zcmV-?1B3jDP)!Q7zyU4d3Bdt5AVENcy@MQ(ARxltK@La|5Ml2i2P6oH zuy>FH5(GrpJE%J#k#MxIcaZo-9gqVO1Vs4o^Xcnw^Y;CuKi3}v5^J(y@@!2#d>wFm z`}KQ3zmI?rMoC@^i=0~!o)Df;jjst%&Wt4n63WflK$Y#egtQGTu#m9!X2SRsjME&+v$l=6tONWRTRe%e=h_NU5(XKp3YjZ@>NE z<4{U{RRe+w$+(_V7q4D~%g@($#8^vBv9BgTv}8dSwbWX)_TuI95bM$LzG&ghq}W#z zAo9nwxgSO5s~HfqM}ZRiFkj7pNVM?UtIuMJN7Q{-1+d;1<kB@*-aeAdD8B6DJUvs*30c0<u7NB)eMLinSqHv+gR0g1B=W{Ga#N*^E1+8X1py0bv{fYV*ra9|CjLysgBkHRo#;Ad+Cb)D(01R!ZleYu=KtVn7(h z@LCgmE393+?o{)sZONAeG|@f&?u60WqFGBJs5VFMb)~*$0a*g*ax%3R2qXj~a<}HI zUC-Xr4~@Jd3w}SF0I|r#;%C3UgkE4$BS0cIdjJdB-s#-dd&|qU*4KeTDcIDbTS|=q z3)$Y((o>d~Ypt)TgGvoO#Cf9ty0`Dn0SUAY$N>ogBJ3UHfCK>%_6~ADf`ABn2RR@? zK!m-69FQO&!rnm+NDvU=huc8{jD;PLk(jyu2LJ&7|Hm#`D*ylh21!IgR09D2uh@@{ SrKV&60000$%~fE0vIXQi9(rAY0O#+Dp$W$Jql}6K z#GK{MY0XpnT7UNkavs9DUH*#%Pq#JZ0|J7WI zyv+>i%f_tLxwbxBiUg!m+O|nJM%uZE4Ekvh&T&XXdwqsakpPT2gtSvZkpSKjQy~ry z_+$&P$oscM!IjTOG3c3ytqbRjPqqNmniDg&=Ps?*eSfOvbsyuCB_JeB7Px5C;gEgh zlN7*WQ>!Eq!JLi_(PvNP6BR&|b#X|LIUI)LeLc7FX%)Z)!D6DEB7=zI_ea;KkQg5? zfCT{39{0EY`#eQn=T!(|@Tc=7eQbf}!k?W8p9RrdHVXg|2PK<@YY_tQhwtA2TL2dj z?xD{<14ueBw|*5ccE)N3{$~aTR8>o8z+ks-RWr~UmH`K6pMf6$00960uncvI00006 dNkl~_Q5eVnGkclYb#2Qv)4Y~itzvkijLHXnsD$h(qQW4f9x{red`U`r zkg|uOv>@n04^a@9PZCsM2}Vd4f_&%#7OdqeORLVRdzqLMY=2(!>@iq#GGdC0c?kvkJn+<>L}L9uB-HJSHGED2FW#bW>p`Tn zG(>+K8uZ-*DlmBNI!vyGu;ygyN-=BN(EvOLP9y#PMc9hmR56Bb;(^9iq+D*$6`OYF zJmkVv=s( zJ>;lP1o1v+bM$=Z+YnVw!vfkeh>R#yW5Yf_#@~G;ZA(@vM7xaOm#>Hnk3tQFr;HUO z3sp|T0)LK8>)~&I0!*90Y>ad~BOi_Qwo9;;E5UyGsU8`SZ0?Vsf0JS3jjOFs|lk$;o0EK5V`ttMDflcMKbhjv4htnfBn z1HutKX%SahMz#{7O{R{a7qFNi7vv(ox&qFfRkL1X(f;kQZzzWEW*b>jp?>eYX|*aQ zm*&H{V+$Nrv++NLexNX?XO^jt{pY$P`yC+*1i#P6$U7^(cA8%xTGhBc*T!1xPfHhozHC%u-T!1w{0GW2dVtHH#mjD0&07*qo IM6N<$f;Iexi~s-t diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 920cb155045bfc86eccf5c8e76146cfad2d19020..690944a46407f62b9b8e544bde36e8ea500a53b7 100644 GIT binary patch delta 1191 zcmX@by^C{#ay?7Fr;B4q1>>7phDBE#1YAS;6kA1(y9hj*;_Prj@{HpLNsfmCO9Y%; za)LYxxqg?eKE26g@zk)(moL5Ay=v`M599s6|K@tW{(98@Z;Q~5j4#4o4s0!t@}(pd zn436RPOoN8aWH61s4Q*Z;ZPIMSigP&e8NDBBUM8wIW zX*p2F!?CVp;ip#(3CSrATNv%<@6F4*zwiF>`Lp|bU03djsDJzL=JiSls|U-Y{RzGKTWn(ANjFE@_=mp?u!2KyqY^a3ar`2apApF zI}RSd-f-7ES36N~l3MJ8tONI3|LmOg{O7_G7wgYFe)(>*Ltb9qy&Hu;c1RhR3BLcV zXJ4DqrnLP3lV5iq)Z6r&JS%IxkCXY>p$?1lR~gn73c9X!6}DT)wuaRuW!^frf?gFR z?wCzxIWfEuMIXISp1sN-_jp=>Mtlp8tDgJ6EK8IA?25d1GeE{W2z6}e<#FJ3)ia-W z*?;ErXnoVT3k_F3`5rFSt94l{P+;1?Z&Y)!al#^h2D>~qo4d93T_&ETjT3j=my$PU zez$biu_L#Vt7;VB3^$Cd3%)SLHzGyO*4z$-7sBzPI~q1)jK;F zW3KPm)8pG#Jk$8wpRM!V*It>kDtQ~D&b__smbUClK9iVN{^!oOl@p_iOV1iLY?F$z zQO|mOCYATL{dV20fsX@X)g=$y{qu9ZzVEuVZ7jQwMqTV*iCnbZH|4KT%{R6U6IfXm}!N+xM1}pHF4*p>RtoeZBGIwo!?i_+{Sg?FrMkP$${yfSFffzgx;w& zl)R80RO-SOa`dkFWX4*xQ1=vq!YNIRp`UN{%;4eJ76r=J__DPi^B(^H> jaNEPcz`zD7gP0lEuCC2n`NDS+P=vwL)z4*}Q$iB}%T6{i literal 1482 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 WhZ1I{WX9^2Gr)DP2bt#-nD!rPgOBt8 literal 2137 zcmd5;`#;l*8@G|knNXb;$9+e{30YAt&1G(hw0v`k=C;O(mhGbrW5l5%mt0nqu*6z& zFw`(1xt%d_M9d}0eQ0jEo_)XHKjQoS;d$QA^LoEt&+~d-&+~e{pL;IOb~4h6(qdv_ zG7k1AvZjZzn<`2FZ z#1KY^*;9xN^$e%A?PC;TMdaEO<2=A*=c3$Xo48M=K)vm)85^R8^SAi5DaJk?lrAN% zAuoy2fZjzNmAhx4cF^~KO!$RcEbMJcD68&5Y*|var@)vn&1C!Zjp##*3szIbt2>7D ze+?IiCA&+EbQqFfc8n3;0;zqtY)IechXbcx50N`slgWo0Lr-hJSpmoE=ZL}415;Ze z(c@vPx|PJ}Cw-oRFJOr-g;?HmB6l&CimDQ%SNJ7ieEEqfWA1zm*O&}N{a$7T%*c-4 zvjI}CKXw(KWbI5w2}{R0TJlOr#Uj$KRHN{BAhlojsswO9z&PccP&%ikU)>O=Scv3+ zY(rxOjNfQv5^U{Sly1}IF1z_+BDX$6c~C!J|M}48o|Sph#&HC+(R40xFM7e59--?CkZT=$86}|j58hU zHSgMAEo<*U0&-G+V$aGJ1h1m`6M?-IlVN~hB<>}5z1dstqj^fZJSd70K4C;ogxUf< z7x?tRUXBn>sb11^z3J|sWS&k3qZRvKCF0$Gme%{h5nN(9%x{>kjZ$QF@;qf|R~O6S zt^48u#Vy*a4{4)j$?r-kfs1sfxiu1RW#b46&$%|I)b*47>CEYOu|*9{yiueit(zIC z6rw1gD#nJMmI|UI%(lg3cBLY1CaiN^*c4HZii1ZHVC)UI74i_S(oHS=&+59;)W<)_a~f~xbo9#D}f zo437vL)*LDVqM#l0bhq|+np#2whJCKd)oRF zwRBJAtCK~00^GBv4BWn2jCtCX>pI;{^oe`f}@C?cAm+zsO_67&Bi0BV2$Us z5C0PoYGeCm>R|-Pjwr>0zrUy3s|51Q6RrrX=Je0C<;H;h8=|%=)STFB$`%uB9AZSk z(6L(%qpgvz_FmS9XN@-!bebzGRcbD&bZ!#297iM$b8gl3$MiYsVCsFo2Q=>YkC3sC zs?V%ppQorsWr?Qjykniwz>I`0Y{L^hINUm^lE6)yZ)YucdzyR7=ln$%=8HZPstc>YYcfZfECQ>HOctf3ibr@ECIs16=7lP@z-$Jv)>F!n?W#__B%INAwxZ& zmPHRR-GI{33LZT>GHpXhzD{FNCQ0-s7MOeX7U>A-}48$~D)+$)~uDU|LI@6KM z6D?c#t8VSaiwV?Qm+ra_l{z|Rv9e4_Ax&2awLPM#fOlg?wot=O59n=G6%jp3fk`Ve zME0Xe+yofSM7y4=l^nTGE_+s-vib54sT|{w%3vd12F}~2Qm^w10+LM!6ZUPFkiT4$ zXfQTmcDqF$UzHB)-Go6XM!H$U1arw!uaZb;1`$kn>%zHN^dtY|oc>8D)U#@!ZhpOu zo(loYm?c$kM;gB*GWn2_eA;^Q`OGr!yw$uZreEh&^JR~OgsnUSSS6rA8%^BE8A5Q> zm`Mxb=wBAFA7Z~9PxAfKQ>%hTEDkFVv4|zw zX++EgANDdU9~JbH`fChWH_da5_YOQrHN)xY2jgagBEG&*(0+_;_R1Vi35KPa;Ez?a zjubddNPgY*z9y?dEH0S-%s9gi$_>|ZCh0jcp=wpF$34tCy$mdOW-Ab-AjaMWe3&Ms znaSN()eO5Bj}QDqbLa|9%nsIV)qY38p0cEV5WjG$Y<_;A9@4(qoSlbRVBBrSAfU13 zaQU1wlCh!mF~i~P5KPnenEO)~tQ&vM;PNYrS?_1L)Cc$7&5caLv&sM+Y#!#RNxxD4 z&~1qLQYI}2aVBdrgNelWtRsEL2(=B%7MjXa!z=Aa{y&85zw+9)2Z9wg=iWZEPhRvZ Oh&k9eqh4G2-1!F?6Y1~( diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index d6d32473a004616d5473ac8238afcecf91536f3d..6b6b8b8a908798c18a79608c98fa5878c94e2f3b 100644 GIT binary patch literal 2741 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000000000000000000000000000000000000..46146da4397e5bce954f7be4657fb5d62053d57d GIT binary patch literal 1158 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000000000000000000000000000000000000..01eb2e2c4aefd12130e07792351b83af52beee33 GIT binary patch literal 1357 zcmeAS@N?(olHy`uVBq!ia0vp^MIg+<1SA>u3uysK#^NA%Cx&(BWL^R}7O4@QX}-P; zAPEiz#`a7G7LXDkmI7i12Brl}aFL<~%m_9}GI-kt9R>!L!=5gVAr*{o=Wfiq;vnK0 z$-ygPqOg#oV?xFXCrPg#92!3vR$X|a?NP@lrSgC~LqkzDa{=EX2QFULOpQanZZ^F( zaqlZ??&sO;(+tZ`e{22u+?~p8*W+f)oiiuqg8b|QuGZ{Xsn0h2%;+wy3L!O#M0|U>MSrwLHCmsf6ow~}v!Z$x> z`y|;G(OtspH#aiGN!L4XbXu3JYhtzfQ|{|YstLR)t6iqe49U{r3p*2$-FZ?bjCFQn z&YYuBf6_d99@YN;eO!@i-S50Tx4rjIJoIr}51+1NYF#IN>*&jeVYfc@$G#|e{5J37jLWixkM(lTWFGW*a!n>+?{>j!_f8a* z6&&34hik&*SC_V3R6NbSwLALr{C^+3vX}0PYuB+6*nVkG*!`W|(c$y2dAT!$@0_#! zk(uL1&-uav?{_nQ-0<Uc^G(GQ-13nzQ`G07^a*3UMzHIugAXV!*`E= zF?+t^#1wwBADum>alZ>=?9LRv^E@Usf1}@KzLuk2$Fk1LA4m~i!~9G|ElhZ8>+Eee z+(X%(X#qK=DVg21A@Ks$9c5ElFNSsO$nQJ8`Bn3bG!gqIx8iK=;4i&g)~&%!H&&gP z)hrr*=jEG7@5SEP>(xBMwHUQscJu%Gu_b9!ME6TC-9L;IC3WX&{MO*RU|y#h_OvI2# zW=CIEgU+?`a;rWK)rjh~e?`9EIGOY(qHKEpU7z61)9R)(2e4f3i!{31deB2(*hu#( zbGEx?BuC*rwj=jvoq64K_1@{A$Oz-*D=Zp|`L>H+U|7hV-56sS`G#Xh{kfa_zl8WK zV9nlmO<>u_eGk(uR#&Z(ewl2*%&)k4@!fDoj;JZgfjk)%GJ0oX=1=|XQNQfJW$}+Y z%ekJ0N3NU})zh#+iYrZFjnAxQs%zSsBssJvFU#ayy)c38(5WSvQHQcLW;AXv%A7Tg cM#{eOf5^>!rdG7v4pgjpy85}Sb4q9e0Ie=#D*ylh literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000000000000000000000000000000000000..2ebe6aa3141bdb1d3c3a5d252c6fd066ecdaacc7 GIT binary patch literal 1310 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1SGeyEo=o+jKx9jP7LeL$-D$|EK(yp(|mmy zw18|52FCVG1{RPKAeI7R1_q`DOmLBk1%HKSr-}N0&XLs=av9c_cU%JHl+K*SuW}6-pmW@)ldwJ(-pDmHea$B^GC+@C# z5h-PU|9-8|yQP|k>(8GnXJ>!+!p7kL`_*>o3G=w@X01qUY&$pQnmLng;NiN1O@|Nt zd)v;XG&5D$&GgNMv-AG^^1W7P&zr5{X#M(aiQqjGGlthQ+BWv{X&?RdI^tx(&0lTF zQY$;lf?g~PSYT&*TkLDrlg49QZ|}?84qJU{=`N|83m$GgyH81M%C*cxKlb)TINyEw z`T*7V!e`b63v+O)-_MaptFpTy3) z9tWaaI|G!BO}5GJE)?aOGxdwq&xISMEpKn=(uz~IH`x~d{7NFn95=y?|FxX2bIWdI ziZp(^KmF{ZHtBaJT@H(Ts`~%0Ie)I(@%01=59KRM7c^^0<**pVOIBU6zg}hh{>A6c z{olT>HnIBf<@xl+%AmUJwl*Q#gxQ~!9iLy3$yhD2<=h9S5?LPS-c5&>3f;ZawWmgE z$D3E{mwyeq@LDiojiymJ*Dd>N=Wg^Jz24xq@^eOxb=3ZK&W}Fy2W{N^W{UCCi?y*@ z7ZMNtzDW}xzPC9-f6$j>V_4byc{ic^Vf@O zd~bvvg!z>xtqtBKB^f8iBzBAU_u{yHxB2BC)ZW&$iMYIaBIB;@cjL0>)P>~BUiNrv zm0&*Wser6;c+EER1ru3%%XsyJ5+-V`Z3m{7AAJ&2Vs~v+5aXY`@7u#b-eh^%*PY9% z6aR-_j`zQ~A!fsoy5iaE-aIRr`@2Xl<=MLZv+EZf+rvFgbN9IoyDEfBdhcvDS?2V3 zc3)Zdz4$o6?T*QNUK^NXahQsIT;A#Jw>?sMRz`8R+0$!5@3ww=+Oa&_L+~w7K zI2OC^?wI!SgNEMCzp=jG3wF!P#$EW6sn|RB-}36udNa2!_}i2kCV5sTBZ!SfH)9gR g${rzFP`*w7*i#SsxbwRU8-vOzPgg&ebxsLQ0P`P9^#A|> literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000000000000000000000000000000000000..edb39e4e508a19335d7357c10d48099e1929f8d6 GIT binary patch literal 1676 zcmb_ddpy$%6#rS0G10WlNGjyU<>%4zT5BH772Xs|ULC&%OWOd;j>J$M>A`$2p(RIVbI)J5famqXYne3dz~= zuuRj|pa_%6V7-W0nUcG7m}n1FcWX;zj#Z!+iQ?u4?3USzfZVk+fc#pD3>X;z0LFm* z&CnR@{;}7B+DnEk08oID90@-~%S{(Q&C+&4J3YllGB@gieJ}iLwULDD4$jfA(H_

BCXBy>VGQEf!ju3S{9GwU7&qkt@9O0tu9VT zheqjI7n+xwKIud*&O?YgZh_%1@t?H!;_AO7RJaX%#BF)fdfS>u1GUOLYm7Dz6hpaS zo#4h5Z#MHNVs?36ayrhpH*-pmFfz+~Pv-;>MR6*t*0{N>s?fUJkzE{qweLzAzPSI4 zf9}m|wNC@Df8ZReYE)u}gXGnf>7`_?pBMeY9Z+LC%)M``=Jvt_zc^o>9&{Aac^kQX>J|sbSSL-*~ z;v6EbSx5%JBtu>SSMB|~c6DoIqqJcRMd*d@^P@UiR1k{xCqi*hv}g23>3FEF7-OL3 zC-gy=G4NfcT$i#s6Eo=!#F;XWlHS&{UWl|wr==geBVtnx zeyr`BZrVC49?!23Fep(kn0G+=w&a-b)iQ&RCdJC$uIDC20`OK)n+`9l(21nFyky zL(PdzPbM<Z||(!jmtzK z_N|S^v~*fzTP@xU+nF*rbsK({p5{BTqbmn)Q6(B>y7Er0>Mz)qAOTvRW z9LqA139so~T2j9c^@eh;DJcZM@6m@n9df0{7dw>C&c)kfZMM8onD}EWX2y-8g&)8D zQiWVwPrQ%#=7SVBm5YXjzhZlGen_G;kBx~J!h@q#m>H#Bt*Xx?H=U1|kMWIpCMc}v zRfzFPiv^q5f=(oFky}7XW!HwPH7joq(kTXH1ZdefbEjc_x#Mi!?*zd4G&E7+7o$;b z7aQY}Sr2^%c?EeJ=Qo$MZ_uF|=qkA9Lu`$1!<;Ka7g3nO-t5-;a_L~C5>KJf(az!i zd_GL0`$I$}s+-WKIpBk=!`*6j{?hw(GjzWz$Rok4e~N7^TcgmH%b%xAB?f)o@64)N zkDKtgj~xLg54y*RLkV8;E&iiT`zq;Xoh9M<5$X{ZB48bt3O7!lePte|!lNoxN+<)w z0mAa+oKF4;98V8g5C?GJmRS5iS8z3o=LAgvZx!qgt)G`aUj_=2M~)V8e0$6;sNEC~ ztEVSYt8R9-wwCx0!*srb%6kRL}rsgOPaw8$sRc=~RSUMWc5QL`Re^SO(UTr$%PAH63Lq|!c!reDkti28~6`$2CY6;Tqsu4TDX&9AIWw~X+qbNlRNDzu!8*_Ge-V0otD zus9U5rBh@liy%!aB3}WTxE!>AUl1cW2R~;sMlY1Q*naU99~jbm=1)Wk&fe{9d>WG& zcV~;W{}Ipy(E64a)ku!I!zRUUMfQcg(2?l$^utP}h$<`BCv@XsQxtE*@e5#Rn%NYP?6LO)urk|82~+C#8}V z9<}E*eCe#ghYCmD<<>{NmdR*-K!HO9Z!+7WFDAId>HJr{Jd=xQ9g3*HiKKY1M#Z|3 z)`sO;3CS?w{m^0p;r^F}rarINGR;)M)3$k7f~&3yv!Xd><@$5C{uATfd?6v#8KeS) zV0~NWQ}-GxVU1mbg(hdP8a{T^gu+b=!>=vk$gb-xd;JX%!)B-#V5>$Pk$OJ9NNS2IckZez zyEZeC@s>c-J2`3fz611t%BPw9Hd~=zT3e`<8r;{E)v?I#pcU#x!b1y;lQ;r)Tl^^S z7B~aEukpt!Z?d}zfn!!ZE=RFFqjQSwnzEc|-@gC(UTgbHOQVFznXeV#QY(y8=&~Xn zM$q+vZ&WV9aQ2q;8}kjm(3hq-o8S;8mrh+6cm^E&zN6;Q8NV`LYpVS`)TVMYf376C z`DlwLyRIuCMKrulV!)poscE8~DrTu%8$5b0A+DXVy8>31MC zOPvIhRN-UWJG&I5!dF1Wj2z6kbz{VWCrEU2SXmB5EH;TSX$~KMwRvc8l_OvO;Uq_7 zlj0CIiViL>jx-^Ko-SK#u);(={jpxIyL!GY|@q@NaTfy+5^+?}3Vidod;c(=9ZNawpDO!evn<`WW~`)EOE zx~T;{JsT~shu3ZRkzh#q2vaIUqVM6EeV1Ah{evV)78V^Q=t~zhA)@0L p$D!7QIDCOBMt@CQcl=+()yQnB%4vv$Kd|;(ux@zQO6Q>DzX9sI4b=bu literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000000000000000000000000000000000000..6fa42b91994c6416294fa59e9935e55180122d51 GIT binary patch literal 2070 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000000000000000000000000000000000000..0a0e91f3933e58402529d17d7550742a5a64183a GIT binary patch literal 494 zcmeAS@N?(olHy`uVBq!ia0vp^vLMXC1SD^M{15@87>k44ofy`glX(f`SfoaHruq6Z zXaU(A42Q5o_!n^5Ps(4g@Po2WtB`bV>%s{a!=$01w1i1uZyms8*UbCb+DH&v%~@54=M-}98;Gtt!(d~v?;XWtB6i&M>U z6YDMWzNOr(`~0#gVgJoT6Y8>^FT9>EIVrSTBZueLEd5u1)zkPFv-@7p;SpvQe|_Gj zBZ+-RbGkLJ2IJ@26Q}-}?DC%&+^WfxbXeN7^W#5{CiNSvzwWJ>&)Bj$r}Kx*f#~Hw51mfRn10i);Ed-H zPr;(vhfEV>OpO0TPS)fvkmpyd^mK8ZaDUIF-${ZoU+d>|t@!S!S^X)y)%zvi?8a)1 TlH7@RLE-M{>gTe~DWM4fMtI1> literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000000000000000000000000000000000000..02f5bcb8c847370e41d5c0573d9ec020b65023ae GIT binary patch literal 565 zcmeAS@N?(olHy`uVBq!ia0vp^8X(NU1SFZ~=vx6P#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>k0%imoBpIpaG7YGBwWo_?NCfBG@P$E#9R!{- zgtD-yluTHm(A?6`aFTt&srCu^D_Hh1G_!KD>7)vrX>bf^=Fq=8)3>O4yNPArtk*6% z-^%XauHCyg+c$EWclQq+{iZFCe2)BIRx`=UQ`VR9^N!LDmR5}a66*6Oq>Ej={B7aW zmtR&Xgdgc)&1*4x$=oUWWa{OwvT^_FYJcz9Ww!a<$)ABem8M4~oX|~bns?9E;CsBF zcglChPjNkt@|PCrPoH@Ah=+5Fe&qM}`|doDj1${u;Pg$%KmWb5sqcxnq$}MV@mCw` z6xM4A#K-PS>siElRPH9jgy>=&MW-=-@IyUwmO zYndrCKTz2cYiTgTokXLNR!xfA|SJwteB zX+uMUc2byGhSQY2rzT#SaN^m|j24SG`M=Jk_B~~^E!0l-(qssk;QMQPhNkq}tG0d@ oUt~RSiOCkc=@sNIwxjJoCry^$UO4+_CT8iCdV`YBob`!pgB=`B<>___b^^a{{ng1y5 z>#oYWkG`kYtMxw3EHUhZYy z5)4LK6I)nqC#Y&Z{drvYFWYqoRjvfZ(+8HSC?AUG;MOy&bdcbl6wfRt7BWBZQ)`gZ zoCiCMw@vn!6TfpaufSh((T1AIkq2%T#BDm~mu>ZAQCEhq^aI0Fx*<#26Q7+tq&9Qz zzNe0QT-@LHh;e`1x-q{XLy+a2iG1%(`B0bV>HNmtx6EQVk}q_JN;_A_es-<8_o#Nymsoc()4v) z&P8Phds)K|9BJoGcRz2Y-T&^A!ORKEKc8*?w9V(V*ENqHu^dGSn{-|zSSqvZlYPjt zGb#3NvcxU1^$y9r5A6c)GODO+i~Y1eJMHwP#lL;0r12O{+h}5z4v(C_%-i=dE?T@Z U=FKUSHc*=IboFyt=akR{08D!Z^Z)<= literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000000000000000000000000000000000000..a4a6dc9c40cdf165e01ce84a37e6cd06c6ea5fa3 GIT binary patch literal 767 zcmeAS@N?(olHy`uVBq!ia0vp^mLSZ*1SFZOL@og-#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>@0%imoBsnE=#VnwbQcoAhkPOzhk-Pnl7zn7? z%v!vFk#lZE$0RNUbI273_b$f9U2$h)4l>;4ZJb`nvnnz%R$o25=`gR<9S_3`-TCX= zSBuJaPG;a{I>z(bNrqQZ%5Q?duzrVVL&|bFnWh8V-PU@)(Unb?i+#CmgWrK87Gf{Y zFM7I5g-L6j%3AIf?RA&pj*8|S^17W^`C;L-)z?F1m*tmoANlk=Oxq=VgQptHf%R%U zQyW&gUC?|#FP2|o%7)&@=iPY~=Gi&TxW>%nlJD&N=+>e2ALhJKcTD*FW}!rInbPj5 zk=)a_KZttfa{W_-{-zqSkkd~4Cus)-yk?wt;;Kt+efG{KaWAI2J73O-Y`a*b+WvR? zE|c}A*0(8nt}dCOu*cI>RYddYbFM{dxrVlS;`_Ax5;_$>1P7_eEWUEuX%^3e)@4$$!m$?=1A>$w-05NyFx=P56s=AlviV4yq7P*WZSL@$u;JW zU*~_*4xi`#*=+3$`A;=p`ro=SmCa>6DDZsJ=824xqCVfcQnCJO=gp5bkA9zHK6QU{ zK`+OVt&2_suE_OUw9URckn^?bgr)xruetJHcx<_u>5KrMQX%hhRd=slMW%GOPCFG5mDst|%!V=Q7em+&g%4a&ubgjqY4%>bk)*BrFX8xH<@$GKCv%j>C(X+64tln&yJ(KC=GrOFr(AX@e9*IaA2`uSQ*YB}z6ZAQrl)gq^g>=! zuav25g}1>>Az#yAtw)tFr8oVJo1itXX6M_c^K(+RavYo5(s!`2J2-#SCA+)R3#aNb z#@Afk{IH2LW##f^MvrdnyjyiXVNzs-=GzR51Ft!*zIT1mc&;EJXsM5B!>oU2-$eM- zCf!??#MvsoSz=q9RjR6``_k{?Pj@mlY}DDb=;)8mNjv7wEttmg#jWu<7n4Jzy0b`~ z@U|QJyHA!WYDY&W{%=^VFx8uF_ZlZfjxy0DMJE%chib4KT-Ow$chg{Zy|CYzMc0*UKx^{^<0VgowgfmamkO{7ZCSFAxo9|Ub*u%|7>5o2kDA+-&<9CEsS@^Gu;b2)7EOlPDvBdczLixcuY~5=gGPM3HI1sU4KGtY$qi<1<)Gdx}=5lZQ+>bej`f}yE zq!{R2Shabpzh4Gh^53%GMF$(SD0)b8}wV z3zl7-595~aR}c2+n|HUagvZ<@;-VsJ;od`f`<`4CIg!VnoBP+dfG4_W{!Gf*nie-oUN1$lhbpG5U88J-~7iqo&xRTD^AJ(I%9GqXHwo{!(_JKS{Duq zamk7-ZFORuExNQ>Rm4iei@TM}OxugyTcadkiWrw@&Nu$WpAR2f+$p9AN@kv}elF{r G5}E*C{v*f$ literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000000000000000000000000000000000000..5fe7ec80e6df3453ba3c3ce47ec26508102f8432 GIT binary patch literal 892 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|Wxo#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>l0%imoB)R!#%2l9}Po6H0AsMW1XKf6UHWX;f zb&?8dVM?39b7Zpm2JhWIvuqeA8}U1^^7=E{FsoKh+|eT)wA=621*`vO{misq`fZ=| zcK23yNq@IV{?+&Q|MyzX+_w5^)`tH7eBS~?magQuZJOSleAG}`Qe$bx5e`$gLZ7)> z%Hm?jdn6Vf)970nociR@qZxwAS0t)m-}vzJ(c_CbO)h(8_I><+@@u$nP~E=&OLpc2 zUr!MTJGAGvGMCFS(J~#K<_{NUH@`dmUH->~ikLWy?Xv}W6MGGset-Rt5h;D+QOo*u z_v$*9{7l^^!N(Tz=+mbA|5HvMjJxIA@^-h9(B?lym$|omTDG<2?I8hK)7+S7j}=00 zx86*NUpx7N8t2wS0^EIz7i2d}Nlt0uJmhhEn`y#gsoLrX{yXxIGA`)V^AHZRb=a1F z>te|nc`N>-M^EI>H1L&hD)36N2+mdV5o?<=W0K$@fy7M)EUy+wIp0|#DDaHqc4t0kE6Jy z^xVwi!!n60^H%2`^B*qn zd#y6VId_R7=hkeWa}xzt_$cr0a9<(BRuUH#B)Zkz|NGu-ZqD1@g2fGTuO=!VpFY2O z&B>@kVu>HV-@Uu`%7rU#U*fiy3Yxv^VmK=E=B2OKp+dduJ(pu8)|RUHMJVgoDc0Fv zTUOK9@1FfD%=yRv@U0z_vH~ynR?dm@d=Z*3*WGeTfRF9N4Sz(Ce1* zI@T{sH$>=^ZTq`<_gfb2I=7YO=BSB04hIA|%s0JlO_968;rTqnVpsBtX%0agQ?+g8 zKR)^A+dWIIod=ztdNf@(U$yV^T=r-Vz4p5An{<0hHB~-F9M9kXf|qBN zaqsrW*OfNxnmBV^#072n)a?wWlT&W)aVnXqveBu@RM<-InUsZhp7+FH{4q$F(n#0s`6XG&VjuQ;ESQN58eDdpaysRvFS zX8^iv<&zNU>z~$cdB1Usnc^A6P91flr|CM5XBA%-ZLKln%qlA7y>j?z&E7BNQ#Y{8jd6t`y=DQRXBbBrb&80^-_eRTSMeTCC|87S^>Cv^q_I6iTCaL>Be(^=1I5_;u zKlwbiiX|$F;wN$rwXZAsvc~yo-LB#(0upTtjSqhmTEFgYs(!D=6*kq4HH!}~ViD-L z+@F4{elc5p)#S!qfu21pU+cNd{HPT1!ZPa`hpE%1LZ6alzkCCZx?N9`Uh$jf9mm2j zl|--Rl@%V-;teG0J@@BcI(KhUvD}r$zdu{UJ$&<>OG6Jgvu3H>{Gjp5L{+r&sFSHG bVfJ6v(qv|?l0Bl|K-t05)z4*}Q$iB}x6FTB literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000000000000000000000000000000000000..32b614cec547c4e6f959b3a14eae1bc8a01a1792 GIT binary patch literal 924 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51SA=YQ-6V}Aa^H*b?0PW0y!3`5uRzjz6@GG zHU|S^dnN-5NC^;20Wkvu(*h>ANWcPS1REqNq>)$3z`!iz>Eakt!T5IeZok6@B5l!n z-jWdxNe`6V)Fe)G@G~m&9|)0av13-YVbSg>Y23MH!=b2u2`4wz$Q-xxlsaxJx=QBy z^#9iT^$V5Sx{oGp@S49hv`IID)uR0P(ZoWF%^W8toI4ZHq|7&YLdrBnzVtMWgM#gz z9!94H+Kr8;Jm@&AqB1je&78xBzc+VZ{3)4pM{53~>hvPvyqCuxrdB*m^WJ1Ceqfr& z7S$m{g=Mnn zk9l?xmYp$GCgIG@3)j?bIv{t}@j&X=U^czWGF)%g%(vJOTzseZ`1LQ0hoyPHzDUmO zV`h8)C+m`Olr5Fr+Fx_*G59UY?Q_ zcH6Qup5=INyxTH;dznJ0;OosdnNIPX2<9qYZpwYRB*%L3#S{=W2#?_EtPd?u~ z<@0TB_qCO#?);guT6e{SZfKo7edW@!XC3Sx4>lu%(XUKr{7w9 zbGz3oCGWZ@I++O+P z%NN1l!Y2YslaJG+$To{PGZ(VB%cv=y)Nz)< dmmq;jvEIY}2M)H#kcEmbZ?Gt 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 L`JtQxPFMaH)ip^x diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index f82eda9f64f7c7f3010e270dbbb10f7b1a66e3fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 369 zcmV-%0gnEOP)!P92D>)sQ{ikOA?8CY08 zZ`v=`oF@?PkK5V*8Q%6TM6r{Xi!aQBS&0ALlx1(a=kdq*Ffuda(9FQ_=ha&VMn(o$ z%H#2{<&W@UWM%pD{Pl;HZI!OX{vQ!}TT?*HF^ew@An;eJ}Z^WXO$%tHJh7j0tG zP=xEmqM1cPM7$;cKg0j0QDuK#ynzUQ*>Vu%b729AzH;mV#>mPlHK&=&#)yfFgMooT zVgG_}N6!8G@)at}#mQxDglsLUW)@~1H!DOqadYyxTVr|(yJj-LNYo4fsKHh;o@S%K P00000NkvXXu0mjf0QMW8^=>o0ggE zn#$a^#^hSe!0`Xa&-XJ|6I3Rc6~U$?1GN|o-c4Lgk;M!Q3||kP!XwLJqRqg-z{JJD zz{CV$|Ga$b|Igpw&s_QU#_y50sW0ie3eFXyp17D~+Ge7V9Icq=;{qzN@k%t?r7Gf>tG}Zq1@yqv9m$Aye zX`ju&z`$i^!o+i^}p#&FZWM&ep&*BSl`@CWAuiFo?q&09^&Fk&JX{v+Y zW!Nod=H-^2--=5QhqelbwhDf834%dZEsk0|YVoMWqZW@^j0FGy%^Rd6UGUTZ00000 LNkvXXu0mjfg9$I{ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 4b71b730ceb6a38d86a40f39814931699c800531..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 969 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1SGf4^HT*`V_7V+NTaEq_vn1F3Ct6ZvEo2S>$P;bnqGVi zit?SH87T6`Pja=>tR^eomH;;~&sqPCZtUDMrRzer(WS4gH-GI34fv_7+Ox}Usrbog ziL5KHOuEm_I++{ceBSWPh7yK@&kD@UtfgnSugX6XXLD$Y+R325oiD!ah`xPy?OW&R zS-#KtHmwd{knUW!eE;6}uk>yu?h9IYI#MjlOrz=S#obRct~|`080{^fT=cJF(e2y2 z|GFIdt6kC)=w`#wWDyX#xTp7$c*^1_r|;OhOkckK&y_#7HZNooeU|Zfo)JUC`GC7b#`vCl;^?>C$+dpm989-mE~ z5x=)A{#R)6@#T%zQfaZbK0ne;TAG#Eq-80{u%O47)oq5Wn3?_Q{|)T9@6y*ZH7DP# z{WG1hRWnO$X)W{Nj;X)4MjqQcTdlu9b!UvH9>amFpEjjG{?}Q!ufCvcX=4-H9{n|Z zmmMwC4o?a@HoLajEb)k9q-D_c2U#EYGp|}wX_)@!eCdy0Z!i8*|M&Ks-LLKmSCaX{ zqc*EteO_QCZ{i*H$@XuJ|NkE+|38jSUGw_$k<~VhMs{I>vYIz_bu!KDy`R)-TN4(j z1z%={B)|8s|9(`H1 zig`_`Y86Z9tVa>PZnO3&xX<6uwQ9;`?knBFLh^B2?`KNwz5XQc^@Ka@3uh%>(#d%s zBye(}w6dqRkDK>#Td(C)*R*)4hUYvzR?cwnrC5FOyMPHvTQ^tSZ4zM>>{%Z5vgwXy nde{FiX8**UmJ~W-Lmy=sb(vD+PqJhJ^Cp9*tDnm{r-UW|IXcmm diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 293b0de121bb63cb19892a27be6933d91e3cf70b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 505 zcmV6^o`0XeV%5pS&BmblQkg24 z6!f-#;qQmfaJq_-Q6M>3v?iUAiHU%@5b*!!uh$JznRvNz8OWxg`2Xwo-;ZB>n6rkN zmrJ-Xo+xu6;BC)*MpoGhmp@VW1(9 z6vUyW!YU)dA}#*9WyarE?|xps#bsqkthu1b;pF)D`wOzj|9}7f|M>a;-#>;wfB*mZ z$p9&485z+u;xm^;R)X8X^yA{q$bvsF-FjKpE482%9uACLoE&;;B$z8woXBaa^LhQ= ze_y_#Nxg2E268MI2xUbuadQ!6E)x%zNM$NtkP8C?gUqt-=LPM*?>#|Q@cY42ux@6- zv@oHZC@g+O$zh@m$v%Y5Wf2kN vaj`@bWMpRMGSkDV9~T(v<<6+NBimd6W95jgm#7-q00000NkvXXu0mjfpO5lZ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index c80cb562521b82c581fea742bac34a35728fdeec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 921 zcmeAS@N?(olHy`uVBq!ia0vp^Rv^s51SIor-mM2xk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+n5{is978JRB>(*X-~R9gMjht243`e9D9+?`o9pdu@G2&% zDe(Cvp&qO0(-N-i+`_W>+)`DEQa#;+ASG>ci|uvp{K&kUXBJwSm(DFG?(G)VGHv?P z+WY5Y6t8-@nT3T_YWfx1#^qOw87gX7e}BK5-~L?u|H6O2mWNtLq^KU5^|@ls8O5Oe z^Y{HJ{jIq2TxvqXkIJv-rsuZb+j36ChgVRqCp{&3>8#?32j?HxpQjqq8M1^U)pgQC zFOv`dufH!3o?gGdz`3eUT9{qhFzSTo?tH6f1|L6i9QiS6p}263ebv9z%-BcaL+gP-~a3RJAORx{kn?3urp?dDD%9-jQ);-7w585s$3Bz)rDq#w<<;q}VNYh`)=?EC)Y z_sr8VX;nX_PJDl-K7QZdU0)j{rpmZQ=}cOr(AKBr(sKOnv-|z?7qnDAJ9U40zGBn; z{`e&eiyc$%%>I5vL)eG&;n$7sViRBJw;Rk;yIym*R_4&Z&u>H>7i+A(8Zkri-=C!? zSW+BzPhw=U;5FSpUoB?!-k0;f*PW9JkovGi)QdkkA)!Jj^4I&{{A_G(@#XRdr*1su zb?L%RpQf5Wf%lGBeE2Z&EIY%!cgM1p%hYh*V8^AW&c|m2 zIsG&|G6NWBuGz+s2U8~}r2Uv~d%P}gpKs5)`0MZg?yBPto^$>B#N&6{B!oT6^|-qu zYNt8qY&m$~$U(imD;`G{z3nf)?qSBoQ(=>H5!jxp87^q=s^g z!m;nC*CpTgj|Ya}*Z=!1PA9vCiG{Ih`Wf5iu^X%Tl_|-3tttN56az$^hTWa*-TEh5 sfk;Js0!v$Ld~QG^?o($6Lr$MCvb8GFE`x8^Zn#>OV{N$XS<~( z9ZM?d(G}UfH6Ti(*7(NcHr^Fazr-B<@yAZ~*5^O-59>d?SA5>J=V;Q0{qBFZiyP=W za5so)WH4S}V%24cTCjlCfm>t?Lq=nh7=v!W1*Qe8Tu}^L931f~GKi9R|CYJqWN~hj zrt--xo%h)eHNP{tR;KPE;`*BDVxFpz04|<2Ibreb$uHT{P=3M{Oi@{MsoMhXd8<%-H(x3adUFD;sf|NShlR77m8>0DJ{`2C4W&)nTl>@MUL zzDv-ap0HnlYxg&~J+Gf6##J^Q-ST0Av7OxCFFo5I|LD1F^S(rKm6_J`d#5irO^OZP z@O{(&?)Q~T1(!N)POGf3xmaF$t@7fFv(4|*wwoGlnUM9~;?F%#!|Q5+KXQ8N%G65k zs-E9?Zfk%R_rLslgAfZ9uK80vu3Z1SbmzXbb)}PR-CGVlu79ilKk<*P`18rE4FUgo zpWQn8cX`^MI}y=|Yu0-F`QaWsd->bND}~kS-lk`$FKoRL5wPpS&5k=?j6ytrruZ`a zQ~sZ+w~+N>9(TNS%4*(kf9FXE@0?NL*InU$$a%X^ysM3Jx!Kkkk<(6XI~8(a>eQ;+ zyKiKz&s*W$=eE{NU4zB3(Psiwr?TGt zb+v2v=Bchf56;)~zLI3+vhqUsnq-YBrLm_ zDqZ*1y*M#2PWG|Hs#BY0m7O+ORpMk3p`f+2Fned^ci|?*n?IM=eQtgwdiu^;S>@yQ z?H(q7_jRdO&g8va+BfOT;v2dg&v;S;_niw|q+ialY|1kTx-v4i{hF5Of&sXD`_*1mO=62G($lQC8 zwv~Us|6DA*P=vcvVY1{Gmt)V(=W}(l9$fMKYTUBxMRiY4u5 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 58e4e85425da1063ab97be3625ee1735290b57ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 625 zcmV-%0*?KOP)MW8^=>o0ggE zn#$a^#^hSe!0`Xa&-XJ|6I3Rc6~U$?1GN|o-c4Lgk;M!Q3||kP!XwLJqRqg-z{JJD zz{CV$|Ga$b|Igpw&s_QU#_y50sW0ie3eFXyp17D~+Ge7V9Icq=;{qzN@k%t?r7Gf>tG}Zq1@yqv9m$Aye zX`ju&z`$i^!o+i^}p#&FZWM&ep&*BSl`@CWAuiFo?q&09^&Fk&JX{v+Y zW!Nod=H-^2--=5QhqelbwhDf834%dZEsk0|YVoMWqZW@^j0FGy%^Rd6UGUTZ00000 LNkvXXu0mjfg9$I{ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 8270a2e8b2efae66b36d7163322c762ba631b106..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1135 zcmV-#1d#iQP)v<&~>5^CPnG~fUbiGOGYxjet`{XXQ$a}Sp=$z+mv zl3>=lfz<{JqYf5E9W0DGSQvG%FzR4o)WO22gN0EC3!@GeMjb4SI#?KWurTUiVO19H zj*lNYLEEg{MmN8;jISyu*+&!PNG#wv45vGiq|7;7ku&T0XZY6Y(EL z2!e+s|tva=JO=&qa z9Xi<)8Mx;7_&wIOPEXVdE+)@@aWVS!_4Q{ujMbYp$3XZFCw~5IY|rbCo}+x_CjDvZ zC^L~ji#Ol9fDd!75 zSb^$H9{K@|Y2dGWmXY;XpTN zGUWU4OkcmyT(3We%HjWWHBS6=d+z2izx6qwWXQtF#C-JPb-0--gnRqfSU7dtym1S9 z=5oZYe3z6Yk^!)QFP!-GjwiK#UfCRX>4Zprxtpz-j9DNBwr8(lSR%47r@ z4_}xoE|4asq`(Ad;cfTM%(4BW!D?F*kcAni$XAsUB*{4KQsDjyD)d~8ws`ge7s2fzzw-@} zp$US>ZTC#Pb6Rsw^pan?_h)L$JQrVXjHR)bb=g6aVG5g@_Lyq7|I}c? z9ARTk<$7Ny$*~W$C74|2P6b=NaCg^ik<<10hirkyCrY}7X)3qfV|%Naq^PBDQrQOQ zXD33ZKArCQl8|M+*CGhxi&gdy-q!ORPrEQ%HpSEYsX@8Zco-%oUFxVnmza*%F{F4p-t9yV}IAw)wGOVq{bf zg$YGjNg*Ye3{MqurNtyg0=2pvV8XOu7GBZyK!YoEaD{3!;{OK!)Pg!#7R@5i z!NRD6g;56!qYf5E9W0DGSQvG%FzR4o)Oq5B{|3>X8=sCHnQZ_7002ovPDHLkV1fp( BDBJ)5 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index d2e7089285d8d64f44f9fc3cf0e50d19731ceb09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1711 zcmchY{XY{30LFJ@Q|uxXhP)HSu2zI%+MKcF_1G#hZyPpKrsgWLA}=`#sd;Pjl3@yy zsdZOgdt(;E<~Eg=Ixk6AmNYx}N8J7JJm1gf`Q`cTd2;-Z{tVt?xCH&gi%a-Bg$M#+-z>wbMIE zD_g7Dz{x5=_j0y1Z8IGRF540D9CIdWp-Unp-&vFx?yHE3YR63e&4*V_-l-gxE8P9k z;`}C)Wi<^+&M!@$SF@y#m1?@+O}Ri7q-q8$!$atCn`r6})3P;G|Bqt>p_zCmiQik6 zUtX-GXi9>P#0BmwH5V&R|Cy(V_j>?ZCF8*LY$Qm0BB8ei{s`E&G5n%qM0WNH*+kM^&CkpBs`oHUd|#jc3%I-p$Fx7);<&iD0j7S}|BmQ!5@(NRna_IqiwQ=%+pvK7cn zM8in77=0(7qDmiVQC9_%ptO0<}=<8GRjV`B9Doxdy+r$zJ7~04}k3~ zI&=OwyyaII&9A&w2|}zngx6vZ8PfCjWHdnLxYJ*&iMcB$8_g=?>~}4scs=D$!ELLE z?_K-OEi|r=p8q{BbffV}-S)S)>a5z#x4x!IevkTIdRK1a;z3NH&xM4%H~Fd*7xw#F zg5;VRw|6myeVG$vT}U=iMl!vAB#7HrGWW-^BcmyQ-){{k^a#6a{yZhU0xuX-3|@KE z_w?PjFkz$70rPyJF6U@6)XlN#p$D`rbwUKa1#OBE1*A>Z3apy=^iatp1!a1v(`gmxOTnPv4mEmJ0{bbC)jl&ujs3J zvDb51){HO)sb4%I+rcOn> zlipmRDEHNoDa>keyUvH8dW6$uWHPYAIsP6^>8*GP%0!2{1@Og0ToDCVzq&IEGZrc{?rFLpV|9!2j~wxx2-epSvkF`|`A@O#+h5 z9EUaj1W2)7>edX(bXt9vh7 z@4P$LZ7HGss?1+5qhNpj`T74(zn@qA?%|e28i#H<@4m%U?{?M65i9cY{&CLls)^uB zv$XeoS=+e}fAsj{9=<<6A>tp8O{3Y96rWppamJv^v!K;!n^_2=(pWUe*N zxy!_G=lID3i^V@}Wk~qU`-$-%z*g%FjDqem-lpT-LGWLXs6zg(FX!663qSM^cWuy5A4&nIP3$@uV_w z+1lXwGxdz`hLqm86Y)~I$Uii;i>+Dz{w8zw$*1@`pkyb^d&06ySFhryf~bG&sz50hU;>PeOG4)O})${u;c%v-k_%o2IdEX1j#{vrO*D+}L8JX^OcP?*dP+z z`jWGg@BgBsB5%Ij-K9QJpvu_pot2Q?r*!W#y;DCQ?oaNw(WzhPGAqMU_WigoU$6gm!>YBF)eTM;cHiWj6pbx%EDJ|u0_f!&7+z|QYvd!_U-Gt`ORD?Db@08q#sYe zzKV0Vk;=3;{kNHxduZv0KeL(nr@emp>W)>dO{nOQ)YV^{@keiZPgycK3z#PuJYD@< J);T3K0RRPOj*$QW diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png deleted file mode 100644 index ac8b8e8bdb782b9dde187fab86b04cbd415af067..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1485 zcmcIk`9Bj10AJ)WjrSB%y;X*gtBA)-$=uS+Xjv>d=C;Ko8Idx_yj*i^j@eu}Qb?K5 zE0%4JMY)AO)JR6=c(m;4e|YbQ@A3J3zrTGypKmH0W+x|mSQY>P$T`?sBX-sACuAgc zS)3ptxhsbvZQLVWasH9Fydy9GOFx_s=D0(ccK`-~@%FnN&cT=i08%&yYfHE2qNTzR zCl8EDHz6jbKZw4@b!3uzelb;d`g{X#%O|#g9tkAy-2LI1loTtsH$<4lg{~eIsE-Lc zl4E|D|pOQj_`YA{{mr$m*e%Z z%>Y;ZnlI#JzJ-fMx3XS)Rk`uw_fX0B z`5!q&A$=rIuq}!~S<@hETx~nq<|p?a+6bC(SOIqxx?j)16JC=wV->9^xIfH>+T(Xnn7xOb)2)-I&puAeG*EiP|Qu zwyCJYNS7e)6ZKN(1&eyq7ONZ--l_tEFbFjTK3hUHTU026J|ff*pOX{o2UWVEsMHQ(R15^@s`u zXE*-36Mm;YYszxsf>l{{@IhJxqHZSaSc%Eyd`Ea-iOKox_wd$Y-tT%zbpuWOu`~!! zR;ru7F+J#WkU0ClSVNDX`pN$scfq-Nk1gw)A|pMpxxF`RMJh;s-t+gdbaV- zB28pfkWLPI7e6yiS2)s?+k>cr#bJ3fuV&w=xljMI9A7Qq&I~75H=d~96#nYsUw+t2 zt7!3ZU!}B!^X|yXC__rs3ujjIp6ZPz<=%V}Dmh2kj^IEXC?h&9k4o9OAU;7O+eVYs zI`JZUdsTkiGc11ZHSfV3AVOdf<+bRuROV{X;9HNfV%zK^6dP+V(w3w*hvd(zKKP8Z z!-ve|&P~G(JggJ!oD5=?n$PK4W%d!!r}hfd&_E^Osz3?d-`s#n=LWynM0ZgmCgm zNeDV|^VAkGsTR^h8&nEYor|CwCXp+89Y>ZYYo>1CQY?hr-ltu>j8RxKD1N0OfkYTA z9SZFkTJAk4QVSO}&IRXw5uBa7YOLhTa6Ql|K*2OwP%9bgppyGdso`(6;Cj-_bT2Gz zenB{H>-RSp4CTNFvLYke42>>Pn32IdO3DKInh&XBA&kC#J$sfi7fm0umJn0q09=WL jMnI`9Kx^NBXyYZ#rrqn|glo@t{~6$51GBD&UQhfN8qC4c diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index ead04e84e21bc9ab7aad5265cf5716a6f7728e3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 920 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 E1KTg5TL1t6 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index d2f79e250a96f545c26b45a619603dd7e863eaf6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1089 zcmV-H1it%;P)sh1{OmNEQT5oVyK#iYncQ)EsA6xpZ1 zv9Ho=YZh}gfFwx0Mm9Qu%}Qrd62(M8h`ArX`f=xBxXq~7mMqh(To9?}e(=omv{B5} zM8c$v(QjF!%*_ziF7Q_Lw>lkVC%Woynyh8CIaXN6;PgCyYhSfpvvzSz*#AH9vXK4;slb;`Uf#nf^91EpE+zn{OZ zEnXrfL9?zvJ$HI=?JYm!`a>*=|Lu1FI#0PaUGJTKqN$0hTu zm+S%Eed8`=)Jv5%z!dlXgP6b783p;TuRMLMEZGto_dYY2)THRbIiYdS8(YuwcfIxQ zC$IR2OSz@;x4EF|)r<19YZmo6sLIb#<>!RP?bfh5eMJ^EFUo52<9dGn~ zY2{z}|JG@6@#wSxTy5K>Z-S^2JF8J^zvYylq;~y@W7?9D*+1VXc_552pYa2Ri z8$x5#$=#vlzU%T$S9wjp^~{(A{^62|DKaN0XJ$f)!NTTD=3gwCSajhWHhU5oqYF6N zrOJ$i(5RKOviR3aBSt5VP|wQ+6scLYz;(DC+MV9FO=7~sX{kx(nuC-<2iquI zjLn^9*t1bK(){Ns+G4UaRJh7(I!<5Wdb|QBph(Ti`TA`|gi;}-8?GDx+G1j-PZ_ps zu6#nuAccfN98RQI;nxveO5xpdz{{4m~3<$Bfid}%A z0Zl`s8;0vjmg`EEa~$`xt2-+2^Dhk~L8z4g0RB@9I5q!LLd6Q61_1Eyemm$Fls3Uo zU@_FdVyJ<|Py>sh1{OmNEQT6b3^lMAYG5(cz+$K|WW{~~cUuNo5G0=000000NkvXX Hu0mjf&uIv! diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index 5bf2e8aa0c9b18efb0932ead09251e864b982b30..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2015 zcmc(g={FmQ0>v3b$1pr&iKY6eid`6MD~cvGt!;=UN+d`LZS8w)(W)xh4-bzB{5I6_B47MZ zm%h7jf8~g+i;xM2xrI9g-4Bn#gkpIte1p8P|Ajxq_+cHf7~hx>2G*2^M=%)ZB*J(vitM5(dB6KsA$tM|feNh_`+Hs%!(DGPjiqnW{0HlFx)c zMW07$$!Ycz?$iw99QQ{2GwiYoD>BoP`ms6Krmq!>pirJ1>>*?;X7?yQc&V>_t2|L^ zFu6_$MJccdzEm%0-cCdRfd-?&Krm1kC=3t=@BvbIQ)o&5TAjvEJl}}z%DWlu0}j$x zEq~vtILVYGba*^!MOu0rmIlk_7|BSuq>EuO$b7D`a5Xw>%ii??Yj#&9$gt)o?}+l$ zfYF!Hw;cRjJ|xDHR`#}Tf1WTPpg{B$MCBO%85e=3h8h=er}4;4OMQ3u=fgWL69 zGdapL5Ocz+&TK3u$IxJSe=1y8+iGD(jSwEqStnALneyss)9vmhK+q`<@4RuyV8{aD zuY=c>JuJt~yBnIro;aOB6ec#py|49q&7o1*E_=zX4P3KxKth}@NWFrUvw~}DC>y4R z_&LFwu%6mIy5A7FiKLHfR%0|q)kHGYq(D`xbJWIWCQAgB;p&-W-UV z4y_6L0O?dSd)Ci)jCPSzDd8o1`;;gt(y!B>vX=W$WQG!O@qo=hGB?sid(PT$*&`8I zi+)7vs+_cqQggZ~UN?iuawuvkJ8AghR)Obgdis5-zh{#+{=)Pi?=CnMBTJbf{XJ*C zR*RJI6BDGn8R`T@Y;#{NlWU*bLDj$);^~sE5b@~*56K;I z@YnG1@14&Queriw_M!l_y#mp-i42XxhBC$(gIU(^H>%LBGai?1s!c1>3Cxl=(kg++ z33ceBT98qd4!QF7-XSc3Fv)`P61CqTPdjQ3A0IXH`v6ziuD!VIIi23@RmhK^DrdpR zSQ9|lw_vN{BIw0Dnj^>2k<5vH$=-pFPHDQnrcWM;d+lh5at@@OW)E=8Yv$6n#Zts3#F`j|_af zp7F<$&-+vh4I$Uoj#k|-3wxSWZSKQ@POp+3`Ia$EzmjgbgZx8R{_uAbOX3;hH3NZR zQwpv<(BfI7BX!?X_STVR__7BK3LMt*{ZGayj&w4ip){~@R-N$e_v20?m(TiIX}?jb z%xl_D*17Db31AllY}1XHaNk^T%Li55RqgB?QwldO2(py26S`;OiqG^H^kHplX&9)b zDD0q2uTTTbhzP_Zi0Zhn8Fs(Bim6PJ;a-y(1O&>jwe0sxTun6^O><-1kwv6l3?{JIuM>x zoeL-mtus*jK7rh&&fm44F;_4`6qSEJ6)`!(V16fnN9#WQTjJupxQjI-XynOZ1LoFojSD& zIBTD4>R_`rwCjOp9zFz$r>|FG*XY8FnX!ucPr8pqy(RG%$ChE)sO(raCiN^l}q`s=y diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 98b3d315f60fe9955523c09c409c3d0a4cb2e1d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1139 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 I=K8&V0As&$`2YX_ From e97caed19eb85691397a3e536dbb61f760ffbc7f Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 4 Jun 2026 14:34:14 +0200 Subject: [PATCH 38/94] fix: resolve Appicon for ios --- .vscode/launch.json | 2 +- ios/Runner.xcodeproj/project.pbxproj | 13 ++++- .../AppIcon26.icon/Assets/icon_foreground.svg | 8 +++ ios/Runner/AppIcon26.icon/icon.json | 54 ++++++++++++++++++ .../100.png | Bin .../1024.png | Bin .../114.png | Bin .../120.png | Bin .../144.png | Bin .../152.png | Bin .../167.png | Bin .../180.png | Bin .../20.png | Bin .../29.png | Bin .../40.png | Bin .../50.png | Bin .../57.png | Bin .../58.png | Bin .../60.png | Bin .../72.png | Bin .../76.png | Bin .../80.png | Bin .../87.png | Bin .../Contents.json | 0 24 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 ios/Runner/AppIcon26.icon/Assets/icon_foreground.svg create mode 100644 ios/Runner/AppIcon26.icon/icon.json rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/100.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/1024.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/114.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/120.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/144.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/152.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/167.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/180.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/20.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/29.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/40.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/50.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/57.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/58.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/60.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/72.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/76.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/80.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/87.png (100%) rename ios/Runner/Assets.xcassets/{AppIcon.appiconset => AppIcon26.appiconset}/Contents.json (100%) diff --git a/.vscode/launch.json b/.vscode/launch.json index 570ef544..0dafcd10 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,7 @@ "--dart-define", "deployment-mode=dev", "--dart-define", - "debug-level=debug" + "debug-level=none" ], "flutterMode": "debug" }, diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d1a2b803..38eddf7c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; D93ECDADCF301D48CCE6DCE8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A0826C16315D483B5D5065B1 /* Pods_Runner.framework */; }; + F4422F0F2FD1A31D00EE32CC /* AppIcon26.icon in Resources */ = {isa = PBXBuildFile; fileRef = F4422F0E2FD1A31D00EE32CC /* AppIcon26.icon */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -67,6 +68,7 @@ A0826C16315D483B5D5065B1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D653928A2B7565FF006884D6 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; E1E629EF268DFF1000DDDF95 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = ""; }; + F4422F0E2FD1A31D00EE32CC /* AppIcon26.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon26.icon; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -154,6 +156,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + F4422F0E2FD1A31D00EE32CC /* AppIcon26.icon */, ); path = Runner; sourceTree = ""; @@ -271,6 +274,7 @@ files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + F4422F0F2FD1A31D00EE32CC /* AppIcon26.icon in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); @@ -457,7 +461,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon26; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -648,7 +653,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon26; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -682,7 +688,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon26; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; diff --git a/ios/Runner/AppIcon26.icon/Assets/icon_foreground.svg b/ios/Runner/AppIcon26.icon/Assets/icon_foreground.svg new file mode 100644 index 00000000..337c8d8e --- /dev/null +++ b/ios/Runner/AppIcon26.icon/Assets/icon_foreground.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ios/Runner/AppIcon26.icon/icon.json b/ios/Runner/AppIcon26.icon/icon.json new file mode 100644 index 00000000..e26d337e --- /dev/null +++ b/ios/Runner/AppIcon26.icon/icon.json @@ -0,0 +1,54 @@ +{ + "fill" : { + "solid" : "extended-gray:1.00000,1.00000" + }, + "groups" : [ + { + "blend-mode" : "normal", + "blur-material" : null, + "hidden" : false, + "layers" : [ + { + "blend-mode" : "normal", + "fill" : "automatic", + "glass-specializations" : [ + { + "value" : true + }, + { + "appearance" : "light", + "value" : true + } + ], + "hidden" : false, + "image-name" : "icon_foreground.svg", + "name" : "icon_foreground", + "opacity" : 1, + "position" : { + "scale" : 1.2, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "layer-color", + "opacity" : 0.6 + }, + "specular" : false, + "translucency" : { + "enabled" : false, + "value" : 0.1 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/100.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/100.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/100.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/1024.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/1024.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/114.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/114.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/120.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/120.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/144.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/144.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/144.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/152.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/152.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/152.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/167.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/167.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/167.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/180.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/180.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/20.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/20.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/20.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/29.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/29.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/40.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/40.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/50.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/50.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/50.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/57.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/57.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/58.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/58.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/60.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/60.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/72.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/72.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/72.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/76.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/76.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/76.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/80.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/80.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/87.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/87.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to ios/Runner/Assets.xcassets/AppIcon26.appiconset/Contents.json From 469637181838711a2f504f1ac45f43b4361d7f76 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 4 Jun 2026 14:37:42 +0200 Subject: [PATCH 39/94] chore(release): bump build number to 94 for internal track --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3cb19313..81963469 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.2.0+93 +version: 4.2.0+94 environment: sdk: ">=3.2.0 <4.0.0" From 5ba7447684c96ff15eb2cc6810800897d7c21b4d Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 4 Jun 2026 14:41:35 +0200 Subject: [PATCH 40/94] ci(android): auto-increment Play versionCode from live track state --- android/fastlane/Fastfile | 52 ++++++++++++++------------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index fe8ed7fb..79008c26 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -16,56 +16,42 @@ platform :android do desc "Submit a new release build to Google Play's Production track" lane :release do - current_production_version_code = google_play_track_version_codes(track: 'production').first.to_i - current_internal_version_code = google_play_track_version_codes(track: 'internal').first.to_i - pubspec_content = YAML.load_file('../../pubspec.yaml') - version_string = pubspec_content['version'] - version_parts = version_string.split('+') - if version_parts.size == 2 - version_number = version_parts[1].to_i - if version_number > current_production_version_code && version_number > current_internal_version_code + # Auto-increment: one above the highest code on either track. This mirrors + # the iOS Xcode Cloud CI_BUILD_NUMBER flow, so the build number never needs + # a manual bump in pubspec.yaml. + codes = google_play_track_version_codes(track: 'production') + + google_play_track_version_codes(track: 'internal') + build_number = (codes.map(&:to_i).max || 0) + 1 + build_name = YAML.load_file('../../pubspec.yaml')['version'].split('+').first + UI.message("Building production versionCode #{build_number} (#{build_name})") + sh "flutter clean" sh "flutter pub get" - sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons" + sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --build-name=#{build_name} --build-number=#{build_number}" upload_to_play_store( track: 'production', aab: '../build/app/outputs/bundle/release/app-release.aab', ) - else - UI.user_error!("Version code must be greater than or equal to the current version code: #{current_production_version_code} and #{current_internal_version_code}") - end - end end desc "Upload to internal testing track" lane :test do - current_production_version_code = google_play_track_version_codes(track: 'production').first.to_i - current_internal_version_code = google_play_track_version_codes(track: 'internal').first.to_i - pubspec_path = '../../pubspec.yaml' - pubspec_content = YAML.load_file(pubspec_path) - version_string = pubspec_content['version'] - version_parts = version_string.split('+') - - if version_parts.size == 2 - version_number = version_parts[1].to_i - base_version = version_parts[0] + # Auto-increment from the live Play state (see :release for rationale). + codes = google_play_track_version_codes(track: 'production') + + google_play_track_version_codes(track: 'internal') + build_number = (codes.map(&:to_i).max || 0) + 1 + base_version = YAML.load_file('../../pubspec.yaml')['version'].split('+').first + build_name = "#{base_version}-test" + UI.message("Building internal versionCode #{build_number} (#{build_name})") - if version_number > current_production_version_code && version_number > current_internal_version_code - test_version = "#{base_version}-test+#{version_number}" - sh "sed -i 's/^version: .*/version: #{test_version}/' #{pubspec_path}" sh "flutter clean" sh "flutter pub get" - - sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --dart-define='deployment-mode=test'" + sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --dart-define='deployment-mode=test' --build-name=#{build_name} --build-number=#{build_number}" upload_to_play_store( track: 'internal', aab: '../build/app/outputs/bundle/release/app-release.aab', ) - else - UI.user_error!("Version code must be greater than or equal to the current version code: #{current_production_version_code} and #{current_internal_version_code}") - end - end end -end \ No newline at end of file +end From 203510e87b1d2baff5639dad67359c77534ec089 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 8 Jun 2026 07:08:54 +0200 Subject: [PATCH 41/94] fix: resolve small bugs --- lib/blocs/app_bloc.dart | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 452da3b7..a26611d7 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -163,16 +163,11 @@ class StudyAppBLoC extends ChangeNotifier { Settings().debugLevel = debugLevel; await Settings().init(); - // Pre-warm sqflite during splash; first call freezes the UI thread. - await Persistence().init(); - CarpResourceManager().initialize(); Sensing(); - // Initialize and use the CAWS backend if not in local deployment mode. - // backend.initialize() re-seeds the cached study into CAWS service - // singletons, so a returning user doesn't hit null-derefs. + // Initialize and use the CAWS backend if not in local deployment mode if (deploymentMode != DeploymentMode.local) { if (await checkConnectivity()) { await backend.initialize(); @@ -207,7 +202,6 @@ class StudyAppBLoC extends ChangeNotifier { if (Platform.isIOS) return true; try { - // Single-package query; getInstalledApps() enumerates everything. return await appCheck .isAppInstalled(LocalSettings.healthConnectPackageName); } catch (e) { @@ -322,7 +316,7 @@ class StudyAppBLoC extends ChangeNotifier { Timer.periodic(const Duration(minutes: 30), (_) => refreshMessages()); info('Study configuration done.'); - // Flip state before notifying so listeners see isConfigured == true. + _state = StudyAppState.configured; notifyListeners(); } From c1f5f108cfeda8a136be4b50a70f67b86fe6e3ae Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 8 Jun 2026 07:41:46 +0200 Subject: [PATCH 42/94] fix: resolve gradle errors --- .gitignore | 12 +++++++++--- android/gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle | 6 +++--- assets/carp/lang/.gitkeep | 0 assets/carp/resources/.gitkeep | 0 5 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 assets/carp/lang/.gitkeep create mode 100644 assets/carp/resources/.gitkeep diff --git a/.gitignore b/.gitignore index fb1fca35..701e4dce 100644 --- a/.gitignore +++ b/.gitignore @@ -122,9 +122,15 @@ android/fastlane/report.xml # CARP related carp/carpspec.yaml -assets/carp/ -assets/carp/lang/_da.json -assets/carp/lang/_en.json +# Ignore local study config but keep the asset dirs (via .gitkeep) so release +# builds don't fail on missing/empty asset directories. +assets/carp/* +!assets/carp/lang/ +assets/carp/lang/* +!assets/carp/lang/.gitkeep +!assets/carp/resources/ +assets/carp/resources/* +!assets/carp/resources/.gitkeep # FVM Version Cache .fvm/ \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 2733ed5d..3ae1e2f1 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/settings.gradle b/android/settings.gradle index 5a5bdaff..5b46a604 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,9 +18,9 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.2" - id "com.android.application" version "8.9.1" apply false - id 'com.android.library' version '7.4.2' apply false - id 'org.jetbrains.kotlin.android' version '2.1.0' apply false + id "com.android.application" version "8.11.1" apply false + id 'com.android.library' version '8.11.1' apply false + id 'org.jetbrains.kotlin.android' version '2.2.20' apply false } include ":app", ":mdsflutter", ":mdslib" diff --git a/assets/carp/lang/.gitkeep b/assets/carp/lang/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/assets/carp/resources/.gitkeep b/assets/carp/resources/.gitkeep new file mode 100644 index 00000000..e69de29b From 3a598527dfc4f8b70be3b7fed38407d630cecdbf Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 8 Jun 2026 08:26:43 +0200 Subject: [PATCH 43/94] fix: simplify flutter release flow --- .github/workflows/create_release_on_tag.yml | 26 +++++-------------- ..._to_google_play_store_fastlane_release.yml | 26 +++++-------------- 2 files changed, 14 insertions(+), 38 deletions(-) diff --git a/.github/workflows/create_release_on_tag.yml b/.github/workflows/create_release_on_tag.yml index 5d4e49be..a530bae4 100644 --- a/.github/workflows/create_release_on_tag.yml +++ b/.github/workflows/create_release_on_tag.yml @@ -16,35 +16,23 @@ jobs: distribution: "adopt" # See 'Supported distributions' for available options java-version: "17" - - name: Cache Flutter - id: cache-flutter - uses: actions/cache@v3 + - name: Set up Flutter + uses: subosito/flutter-action@v2 with: - path: | - flutter - ~/.pub-cache - key: flutter-${{ hashFiles('**/pubspec.lock') }} + flutter-version: "3.44.0" + channel: stable + cache: true + pub-cache: true - name: Cache Gradle id: cache-gradle - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper/ key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.44.0 $FOLDER - - - name: Add Flutter to PATH - run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install dependencies - run: flutter pub get - - name: Configure Keystore run: | echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > app/upload-keystore.jks diff --git a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml index b2dd1cca..7a9d913c 100644 --- a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml +++ b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml @@ -15,35 +15,23 @@ jobs: distribution: "adopt" # See 'Supported distributions' for available options java-version: "17" - - name: Cache Flutter - id: cache-flutter - uses: actions/cache@v3 + - name: Set up Flutter + uses: subosito/flutter-action@v2 with: - path: | - flutter - ~/.pub-cache - key: flutter-${{ hashFiles('**/pubspec.lock') }} + flutter-version: "3.44.0" + channel: stable + cache: true + pub-cache: true - name: Cache Gradle id: cache-gradle - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper/ key: gradle-ubuntu-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install Flutter - run: git clone https://github.com/flutter/flutter.git --depth 1 -b 3.44.0 $FOLDER - - - name: Add Flutter to PATH - run: echo "$GITHUB_WORKSPACE/flutter/bin" >> $GITHUB_PATH - - - if: ${{ steps.cache-flutter.outputs.cache-hit != 'true' }} - name: Install dependencies - run: flutter pub get - # Setup Ruby, Bundler, and Gemfile dependencies - name: Setup Fastlane uses: ruby/setup-ruby@v1 From 599b47066980dab8ec909920fff7354e230ea3d7 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 8 Jun 2026 13:10:31 +0200 Subject: [PATCH 44/94] fix: review fixes, changelog keyed to build number, 4.3.0 bump - Address PR #545 review: consolidate MessageType icon mapping into lib/ui/helpers.dart, drop the duplicate UI-in-view-model copy, remove the no-op bluetooth UUID filter, type stopRecording, use the theme colour for the message badge, delete dead styles. - Write Play changelog from the auto-incremented build number so release notes match the uploaded versionCode; drop dead workflow steps. - Bump version to 4.3.0+1. - Remove legacy iOS AppIcon26 appiconset (migrated to AppIcon26.icon). --- ..._to_google_play_store_fastlane_release.yml | 32 ---- android/fastlane/Fastfile | 18 ++ .../AppIcon26.appiconset/100.png | Bin 1158 -> 0 bytes .../AppIcon26.appiconset/1024.png | Bin 23882 -> 0 bytes .../AppIcon26.appiconset/114.png | Bin 1357 -> 0 bytes .../AppIcon26.appiconset/120.png | Bin 1310 -> 0 bytes .../AppIcon26.appiconset/144.png | Bin 1676 -> 0 bytes .../AppIcon26.appiconset/152.png | Bin 1747 -> 0 bytes .../AppIcon26.appiconset/167.png | Bin 2070 -> 0 bytes .../AppIcon26.appiconset/180.png | Bin 2173 -> 0 bytes .../AppIcon26.appiconset/20.png | Bin 362 -> 0 bytes .../AppIcon26.appiconset/29.png | Bin 494 -> 0 bytes .../AppIcon26.appiconset/40.png | Bin 565 -> 0 bytes .../AppIcon26.appiconset/50.png | Bin 650 -> 0 bytes .../AppIcon26.appiconset/57.png | Bin 767 -> 0 bytes .../AppIcon26.appiconset/58.png | Bin 739 -> 0 bytes .../AppIcon26.appiconset/60.png | Bin 733 -> 0 bytes .../AppIcon26.appiconset/72.png | Bin 892 -> 0 bytes .../AppIcon26.appiconset/76.png | Bin 901 -> 0 bytes .../AppIcon26.appiconset/80.png | Bin 924 -> 0 bytes .../AppIcon26.appiconset/87.png | Bin 1111 -> 0 bytes .../AppIcon26.appiconset/Contents.json | 158 ------------------ lib/main.dart | 1 + lib/ui/carp_study_style.dart | 3 - lib/ui/helpers.dart | 14 ++ ...evices_page.bluetooth_connection_page.dart | 35 +--- lib/ui/pages/message_details_page.dart | 2 +- lib/ui/pages/study_page.dart | 8 +- lib/ui/tasks/camera_page.dart | 2 +- lib/view_models/study_page_model.dart | 12 -- pubspec.yaml | 2 +- 31 files changed, 38 insertions(+), 249 deletions(-) delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/100.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/1024.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/114.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/120.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/144.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/152.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/167.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/180.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/20.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/29.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/40.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/50.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/57.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/58.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/60.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/72.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/76.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/80.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/87.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon26.appiconset/Contents.json create mode 100644 lib/ui/helpers.dart diff --git a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml index 7a9d913c..48eeb2ce 100644 --- a/.github/workflows/deploy_to_google_play_store_fastlane_release.yml +++ b/.github/workflows/deploy_to_google_play_store_fastlane_release.yml @@ -40,38 +40,6 @@ jobs: bundler-cache: false working-directory: android - - name: Extract last commit message - id: extract-commit-message - run: | - # Subject line only: Play release notes are capped at 500 chars. - COMMIT_MESSAGE=$(git log -1 --pretty=%s | tr -d '\n') - COMMIT_MESSAGE="${COMMIT_MESSAGE}" - echo "Commit Message: $COMMIT_MESSAGE" - echo "commit_message=$COMMIT_MESSAGE" >> $GITHUB_ENV - - - name: Extract version code and version name from pubspec.yaml - id: extract-version - run: | - # Navigate to the root directory (if not already there) - pwd - cd $GITHUB_WORKSPACE - - # Extract version name and version code from pubspec.yaml - VERSION_NAME=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1) - VERSION_CODE=$(grep 'version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2) - - echo "Version Name: $VERSION_NAME" - echo "Version Code: $VERSION_CODE" - - echo "version_name=$VERSION_NAME" >> $GITHUB_ENV - echo "version_code=$VERSION_CODE" >> $GITHUB_ENV - - - name: Create or update changelog file - run: | - pwd - mkdir -p ./android/fastlane/metadata/android/en-GB/changelogs - echo "${{ env.commit_message }}" > "./android/fastlane/metadata/android/en-GB/changelogs/${{ env.version_code }}.txt" - - name: Configure Keystore run: | echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > app/upload-keystore.jks diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 79008c26..918b2b95 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -10,8 +10,22 @@ # https://docs.fastlane.tools/plugins/available-plugins # +require 'fileutils' + default_platform(:android) +# Write the latest commit subject as the Play release notes for [version_code]. +# Keyed on the uploaded versionCode so Play matches the note to the release. +# Play caps release notes at 500 chars and rejects empty notes. +def write_play_changelog(version_code) + notes = `git log -1 --pretty=%s`.strip[0, 500] + notes = 'Bug fixes and improvements.' if notes.empty? + dir = 'metadata/android/en-GB/changelogs' + FileUtils.mkdir_p(dir) + File.write("#{dir}/#{version_code}.txt", notes) + UI.message("Changelog #{version_code}.txt: #{notes}") +end + platform :android do desc "Submit a new release build to Google Play's Production track" @@ -29,6 +43,8 @@ platform :android do sh "flutter pub get" sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --build-name=#{build_name} --build-number=#{build_number}" + write_play_changelog(build_number) + upload_to_play_store( track: 'production', aab: '../build/app/outputs/bundle/release/app-release.aab', @@ -49,6 +65,8 @@ platform :android do sh "flutter pub get" sh "flutter build appbundle --release --no-deferred-components --no-tree-shake-icons --dart-define='deployment-mode=test' --build-name=#{build_name} --build-number=#{build_number}" + write_play_changelog(build_number) + upload_to_play_store( track: 'internal', aab: '../build/app/outputs/bundle/release/app-release.aab', diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/100.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/100.png deleted file mode 100644 index 46146da4397e5bce954f7be4657fb5d62053d57d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1158 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/114.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/114.png deleted file mode 100644 index 01eb2e2c4aefd12130e07792351b83af52beee33..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1357 zcmeAS@N?(olHy`uVBq!ia0vp^MIg+<1SA>u3uysK#^NA%Cx&(BWL^R}7O4@QX}-P; zAPEiz#`a7G7LXDkmI7i12Brl}aFL<~%m_9}GI-kt9R>!L!=5gVAr*{o=Wfiq;vnK0 z$-ygPqOg#oV?xFXCrPg#92!3vR$X|a?NP@lrSgC~LqkzDa{=EX2QFULOpQanZZ^F( zaqlZ??&sO;(+tZ`e{22u+?~p8*W+f)oiiuqg8b|QuGZ{Xsn0h2%;+wy3L!O#M0|U>MSrwLHCmsf6ow~}v!Z$x> z`y|;G(OtspH#aiGN!L4XbXu3JYhtzfQ|{|YstLR)t6iqe49U{r3p*2$-FZ?bjCFQn z&YYuBf6_d99@YN;eO!@i-S50Tx4rjIJoIr}51+1NYF#IN>*&jeVYfc@$G#|e{5J37jLWixkM(lTWFGW*a!n>+?{>j!_f8a* z6&&34hik&*SC_V3R6NbSwLALr{C^+3vX}0PYuB+6*nVkG*!`W|(c$y2dAT!$@0_#! zk(uL1&-uav?{_nQ-0<Uc^G(GQ-13nzQ`G07^a*3UMzHIugAXV!*`E= zF?+t^#1wwBADum>alZ>=?9LRv^E@Usf1}@KzLuk2$Fk1LA4m~i!~9G|ElhZ8>+Eee z+(X%(X#qK=DVg21A@Ks$9c5ElFNSsO$nQJ8`Bn3bG!gqIx8iK=;4i&g)~&%!H&&gP z)hrr*=jEG7@5SEP>(xBMwHUQscJu%Gu_b9!ME6TC-9L;IC3WX&{MO*RU|y#h_OvI2# zW=CIEgU+?`a;rWK)rjh~e?`9EIGOY(qHKEpU7z61)9R)(2e4f3i!{31deB2(*hu#( zbGEx?BuC*rwj=jvoq64K_1@{A$Oz-*D=Zp|`L>H+U|7hV-56sS`G#Xh{kfa_zl8WK zV9nlmO<>u_eGk(uR#&Z(ewl2*%&)k4@!fDoj;JZgfjk)%GJ0oX=1=|XQNQfJW$}+Y z%ekJ0N3NU})zh#+iYrZFjnAxQs%zSsBssJvFU#ayy)c38(5WSvQHQcLW;AXv%A7Tg cM#{eOf5^>!rdG7v4pgjpy85}Sb4q9e0Ie=#D*ylh diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/120.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/120.png deleted file mode 100644 index 2ebe6aa3141bdb1d3c3a5d252c6fd066ecdaacc7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1310 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1SGeyEo=o+jKx9jP7LeL$-D$|EK(yp(|mmy zw18|52FCVG1{RPKAeI7R1_q`DOmLBk1%HKSr-}N0&XLs=av9c_cU%JHl+K*SuW}6-pmW@)ldwJ(-pDmHea$B^GC+@C# z5h-PU|9-8|yQP|k>(8GnXJ>!+!p7kL`_*>o3G=w@X01qUY&$pQnmLng;NiN1O@|Nt zd)v;XG&5D$&GgNMv-AG^^1W7P&zr5{X#M(aiQqjGGlthQ+BWv{X&?RdI^tx(&0lTF zQY$;lf?g~PSYT&*TkLDrlg49QZ|}?84qJU{=`N|83m$GgyH81M%C*cxKlb)TINyEw z`T*7V!e`b63v+O)-_MaptFpTy3) z9tWaaI|G!BO}5GJE)?aOGxdwq&xISMEpKn=(uz~IH`x~d{7NFn95=y?|FxX2bIWdI ziZp(^KmF{ZHtBaJT@H(Ts`~%0Ie)I(@%01=59KRM7c^^0<**pVOIBU6zg}hh{>A6c z{olT>HnIBf<@xl+%AmUJwl*Q#gxQ~!9iLy3$yhD2<=h9S5?LPS-c5&>3f;ZawWmgE z$D3E{mwyeq@LDiojiymJ*Dd>N=Wg^Jz24xq@^eOxb=3ZK&W}Fy2W{N^W{UCCi?y*@ z7ZMNtzDW}xzPC9-f6$j>V_4byc{ic^Vf@O zd~bvvg!z>xtqtBKB^f8iBzBAU_u{yHxB2BC)ZW&$iMYIaBIB;@cjL0>)P>~BUiNrv zm0&*Wser6;c+EER1ru3%%XsyJ5+-V`Z3m{7AAJ&2Vs~v+5aXY`@7u#b-eh^%*PY9% z6aR-_j`zQ~A!fsoy5iaE-aIRr`@2Xl<=MLZv+EZf+rvFgbN9IoyDEfBdhcvDS?2V3 zc3)Zdz4$o6?T*QNUK^NXahQsIT;A#Jw>?sMRz`8R+0$!5@3ww=+Oa&_L+~w7K zI2OC^?wI!SgNEMCzp=jG3wF!P#$EW6sn|RB-}36udNa2!_}i2kCV5sTBZ!SfH)9gR g${rzFP`*w7*i#SsxbwRU8-vOzPgg&ebxsLQ0P`P9^#A|> diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/144.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/144.png deleted file mode 100644 index edb39e4e508a19335d7357c10d48099e1929f8d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1676 zcmb_ddpy$%6#rS0G10WlNGjyU<>%4zT5BH772Xs|ULC&%OWOd;j>J$M>A`$2p(RIVbI)J5famqXYne3dz~= zuuRj|pa_%6V7-W0nUcG7m}n1FcWX;zj#Z!+iQ?u4?3USzfZVk+fc#pD3>X;z0LFm* z&CnR@{;}7B+DnEk08oID90@-~%S{(Q&C+&4J3YllGB@gieJ}iLwULDD4$jfA(H_

BCXBy>VGQEf!ju3S{9GwU7&qkt@9O0tu9VT zheqjI7n+xwKIud*&O?YgZh_%1@t?H!;_AO7RJaX%#BF)fdfS>u1GUOLYm7Dz6hpaS zo#4h5Z#MHNVs?36ayrhpH*-pmFfz+~Pv-;>MR6*t*0{N>s?fUJkzE{qweLzAzPSI4 zf9}m|wNC@Df8ZReYE)u}gXGnf>7`_?pBMeY9Z+LC%)M``=Jvt_zc^o>9&{Aac^kQX>J|sbSSL-*~ z;v6EbSx5%JBtu>SSMB|~c6DoIqqJcRMd*d@^P@UiR1k{xCqi*hv}g23>3FEF7-OL3 zC-gy=G4NfcT$i#s6Eo=!#F;XWlHS&{UWl|wr==geBVtnx zeyr`BZrVC49?!23Fep(kn0G+=w&a-b)iQ&RCdJC$uIDC20`OK)n+`9l(21nFyky zL(PdzPbM<Z||(!jmtzK z_N|S^v~*fzTP@xU+nF*rbsK({p5{BTqbmn)Q6(B>y7Er0>Mz)qAOTvRW z9LqA139so~T2j9c^@eh;DJcZM@6m@n9df0{7dw>C&c)kfZMM8onD}EWX2y-8g&)8D zQiWVwPrQ%#=7SVBm5YXjzhZlGen_G;kBx~J!h@q#m>H#Bt*Xx?H=U1|kMWIpCMc}v zRfzFPiv^q5f=(oFky}7XW!HwPH7joq(kTXH1ZdefbEjc_x#Mi!?*zd4G&E7+7o$;b z7aQY}Sr2^%c?EeJ=Qo$MZ_uF|=qkA9Lu`$1!<;Ka7g3nO-t5-;a_L~C5>KJf(az!i zd_GL0`$I$}s+-WKIpBk=!`*6j{?hw(GjzWz$Rok4e~N7^TcgmH%b%xAB?f)o@64)N zkDKtgj~xLg54y*RLkV8;E&iiT`zq;Xoh9M<5$X{ZB48bt3O7!lePte|!lNoxN+<)w z0mAa+oKF4;98V8g5C?GJmRS5iS8z3o=LAgvZx!qgt)G`aUj_=2M~)V8e0$6;sNEC~ ztEVSYt8R9-wwCx0!*srb%6kRL}rsgOPaw8$sRc=~RSUMWc5QL`Re^SO(UTr$%PAH63Lq|!c!reDkti28~6`$2CY6;Tqsu4TDX&9AIWw~X+qbNlRNDzu!8*_Ge-V0otD zus9U5rBh@liy%!aB3}WTxE!>AUl1cW2R~;sMlY1Q*naU99~jbm=1)Wk&fe{9d>WG& zcV~;W{}Ipy(E64a)ku!I!zRUUMfQcg(2?l$^utP}h$<`BCv@XsQxtE*@e5#Rn%NYP?6LO)urk|82~+C#8}V z9<}E*eCe#ghYCmD<<>{NmdR*-K!HO9Z!+7WFDAId>HJr{Jd=xQ9g3*HiKKY1M#Z|3 z)`sO;3CS?w{m^0p;r^F}rarINGR;)M)3$k7f~&3yv!Xd><@$5C{uATfd?6v#8KeS) zV0~NWQ}-GxVU1mbg(hdP8a{T^gu+b=!>=vk$gb-xd;JX%!)B-#V5>$Pk$OJ9NNS2IckZez zyEZeC@s>c-J2`3fz611t%BPw9Hd~=zT3e`<8r;{E)v?I#pcU#x!b1y;lQ;r)Tl^^S z7B~aEukpt!Z?d}zfn!!ZE=RFFqjQSwnzEc|-@gC(UTgbHOQVFznXeV#QY(y8=&~Xn zM$q+vZ&WV9aQ2q;8}kjm(3hq-o8S;8mrh+6cm^E&zN6;Q8NV`LYpVS`)TVMYf376C z`DlwLyRIuCMKrulV!)poscE8~DrTu%8$5b0A+DXVy8>31MC zOPvIhRN-UWJG&I5!dF1Wj2z6kbz{VWCrEU2SXmB5EH;TSX$~KMwRvc8l_OvO;Uq_7 zlj0CIiViL>jx-^Ko-SK#u);(={jpxIyL!GY|@q@NaTfy+5^+?}3Vidod;c(=9ZNawpDO!evn<`WW~`)EOE zx~T;{JsT~shu3ZRkzh#q2vaIUqVM6EeV1Ah{evV)78V^Q=t~zhA)@0L p$D!7QIDCOBMt@CQcl=+()yQnB%4vv$Kd|;(ux@zQO6Q>DzX9sI4b=bu diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/167.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/167.png deleted file mode 100644 index 6fa42b91994c6416294fa59e9935e55180122d51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2070 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/29.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/29.png deleted file mode 100644 index 0a0e91f3933e58402529d17d7550742a5a64183a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 494 zcmeAS@N?(olHy`uVBq!ia0vp^vLMXC1SD^M{15@87>k44ofy`glX(f`SfoaHruq6Z zXaU(A42Q5o_!n^5Ps(4g@Po2WtB`bV>%s{a!=$01w1i1uZyms8*UbCb+DH&v%~@54=M-}98;Gtt!(d~v?;XWtB6i&M>U z6YDMWzNOr(`~0#gVgJoT6Y8>^FT9>EIVrSTBZueLEd5u1)zkPFv-@7p;SpvQe|_Gj zBZ+-RbGkLJ2IJ@26Q}-}?DC%&+^WfxbXeN7^W#5{CiNSvzwWJ>&)Bj$r}Kx*f#~Hw51mfRn10i);Ed-H zPr;(vhfEV>OpO0TPS)fvkmpyd^mK8ZaDUIF-${ZoU+d>|t@!S!S^X)y)%zvi?8a)1 TlH7@RLE-M{>gTe~DWM4fMtI1> diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/40.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/40.png deleted file mode 100644 index 02f5bcb8c847370e41d5c0573d9ec020b65023ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 565 zcmeAS@N?(olHy`uVBq!ia0vp^8X(NU1SFZ~=vx6P#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>k0%imoBpIpaG7YGBwWo_?NCfBG@P$E#9R!{- zgtD-yluTHm(A?6`aFTt&srCu^D_Hh1G_!KD>7)vrX>bf^=Fq=8)3>O4yNPArtk*6% z-^%XauHCyg+c$EWclQq+{iZFCe2)BIRx`=UQ`VR9^N!LDmR5}a66*6Oq>Ej={B7aW zmtR&Xgdgc)&1*4x$=oUWWa{OwvT^_FYJcz9Ww!a<$)ABem8M4~oX|~bns?9E;CsBF zcglChPjNkt@|PCrPoH@Ah=+5Fe&qM}`|doDj1${u;Pg$%KmWb5sqcxnq$}MV@mCw` z6xM4A#K-PS>siElRPH9jgy>=&MW-=-@IyUwmO zYndrCKTz2cYiTgTokXLNR!xfA|SJwteB zX+uMUc2byGhSQY2rzT#SaN^m|j24SG`M=Jk_B~~^E!0l-(qssk;QMQPhNkq}tG0d@ oUt~RSiOCkc=@sNIwxjJoCry^$UO4+_CT8iCdV`YBob`!pgB=`B<>___b^^a{{ng1y5 z>#oYWkG`kYtMxw3EHUhZYy z5)4LK6I)nqC#Y&Z{drvYFWYqoRjvfZ(+8HSC?AUG;MOy&bdcbl6wfRt7BWBZQ)`gZ zoCiCMw@vn!6TfpaufSh((T1AIkq2%T#BDm~mu>ZAQCEhq^aI0Fx*<#26Q7+tq&9Qz zzNe0QT-@LHh;e`1x-q{XLy+a2iG1%(`B0bV>HNmtx6EQVk}q_JN;_A_es-<8_o#Nymsoc()4v) z&P8Phds)K|9BJoGcRz2Y-T&^A!ORKEKc8*?w9V(V*ENqHu^dGSn{-|zSSqvZlYPjt zGb#3NvcxU1^$y9r5A6c)GODO+i~Y1eJMHwP#lL;0r12O{+h}5z4v(C_%-i=dE?T@Z U=FKUSHc*=IboFyt=akR{08D!Z^Z)<= diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/57.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/57.png deleted file mode 100644 index a4a6dc9c40cdf165e01ce84a37e6cd06c6ea5fa3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 767 zcmeAS@N?(olHy`uVBq!ia0vp^mLSZ*1SFZOL@og-#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>@0%imoBsnE=#VnwbQcoAhkPOzhk-Pnl7zn7? z%v!vFk#lZE$0RNUbI273_b$f9U2$h)4l>;4ZJb`nvnnz%R$o25=`gR<9S_3`-TCX= zSBuJaPG;a{I>z(bNrqQZ%5Q?duzrVVL&|bFnWh8V-PU@)(Unb?i+#CmgWrK87Gf{Y zFM7I5g-L6j%3AIf?RA&pj*8|S^17W^`C;L-)z?F1m*tmoANlk=Oxq=VgQptHf%R%U zQyW&gUC?|#FP2|o%7)&@=iPY~=Gi&TxW>%nlJD&N=+>e2ALhJKcTD*FW}!rInbPj5 zk=)a_KZttfa{W_-{-zqSkkd~4Cus)-yk?wt;;Kt+efG{KaWAI2J73O-Y`a*b+WvR? zE|c}A*0(8nt}dCOu*cI>RYddYbFM{dxrVlS;`_Ax5;_$>1P7_eEWUEuX%^3e)@4$$!m$?=1A>$w-05NyFx=P56s=AlviV4yq7P*WZSL@$u;JW zU*~_*4xi`#*=+3$`A;=p`ro=SmCa>6DDZsJ=824xqCVfcQnCJO=gp5bkA9zHK6QU{ zK`+OVt&2_suE_OUw9URckn^?bgr)xruetJHcx<_u>5KrMQX%hhRd=slMW%GOPCFG5mDst|%!V=Q7em+&g%4a&ubgjqY4%>bk)*BrFX8xH<@$GKCv%j>C(X+64tln&yJ(KC=GrOFr(AX@e9*IaA2`uSQ*YB}z6ZAQrl)gq^g>=! zuav25g}1>>Az#yAtw)tFr8oVJo1itXX6M_c^K(+RavYo5(s!`2J2-#SCA+)R3#aNb z#@Afk{IH2LW##f^MvrdnyjyiXVNzs-=GzR51Ft!*zIT1mc&;EJXsM5B!>oU2-$eM- zCf!??#MvsoSz=q9RjR6``_k{?Pj@mlY}DDb=;)8mNjv7wEttmg#jWu<7n4Jzy0b`~ z@U|QJyHA!WYDY&W{%=^VFx8uF_ZlZfjxy0DMJE%chib4KT-Ow$chg{Zy|CYzMc0*UKx^{^<0VgowgfmamkO{7ZCSFAxo9|Ub*u%|7>5o2kDA+-&<9CEsS@^Gu;b2)7EOlPDvBdczLixcuY~5=gGPM3HI1sU4KGtY$qi<1<)Gdx}=5lZQ+>bej`f}yE zq!{R2Shabpzh4Gh^53%GMF$(SD0)b8}wV z3zl7-595~aR}c2+n|HUagvZ<@;-VsJ;od`f`<`4CIg!VnoBP+dfG4_W{!Gf*nie-oUN1$lhbpG5U88J-~7iqo&xRTD^AJ(I%9GqXHwo{!(_JKS{Duq zamk7-ZFORuExNQ>Rm4iei@TM}OxugyTcadkiWrw@&Nu$WpAR2f+$p9AN@kv}elF{r G5}E*C{v*f$ diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/72.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/72.png deleted file mode 100644 index 5fe7ec80e6df3453ba3c3ce47ec26508102f8432..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 892 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|Wxo#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>l0%imoB)R!#%2l9}Po6H0AsMW1XKf6UHWX;f zb&?8dVM?39b7Zpm2JhWIvuqeA8}U1^^7=E{FsoKh+|eT)wA=621*`vO{misq`fZ=| zcK23yNq@IV{?+&Q|MyzX+_w5^)`tH7eBS~?magQuZJOSleAG}`Qe$bx5e`$gLZ7)> z%Hm?jdn6Vf)970nociR@qZxwAS0t)m-}vzJ(c_CbO)h(8_I><+@@u$nP~E=&OLpc2 zUr!MTJGAGvGMCFS(J~#K<_{NUH@`dmUH->~ikLWy?Xv}W6MGGset-Rt5h;D+QOo*u z_v$*9{7l^^!N(Tz=+mbA|5HvMjJxIA@^-h9(B?lym$|omTDG<2?I8hK)7+S7j}=00 zx86*NUpx7N8t2wS0^EIz7i2d}Nlt0uJmhhEn`y#gsoLrX{yXxIGA`)V^AHZRb=a1F z>te|nc`N>-M^EI>H1L&hD)36N2+mdV5o?<=W0K$@fy7M)EUy+wIp0|#DDaHqc4t0kE6Jy z^xVwi!!n60^H%2`^B*qn zd#y6VId_R7=hkeWa}xzt_$cr0a9<(BRuUH#B)Zkz|NGu-ZqD1@g2fGTuO=!VpFY2O z&B>@kVu>HV-@Uu`%7rU#U*fiy3Yxv^VmK=E=B2OKp+dduJ(pu8)|RUHMJVgoDc0Fv zTUOK9@1FfD%=yRv@U0z_vH~ynR?dm@d=Z*3*WGeTfRF9N4Sz(Ce1* zI@T{sH$>=^ZTq`<_gfb2I=7YO=BSB04hIA|%s0JlO_968;rTqnVpsBtX%0agQ?+g8 zKR)^A+dWIIod=ztdNf@(U$yV^T=r-Vz4p5An{<0hHB~-F9M9kXf|qBN zaqsrW*OfNxnmBV^#072n)a?wWlT&W)aVnXqveBu@RM<-InUsZhp7+FH{4q$F(n#0s`6XG&VjuQ;ESQN58eDdpaysRvFS zX8^iv<&zNU>z~$cdB1Usnc^A6P91flr|CM5XBA%-ZLKln%qlA7y>j?z&E7BNQ#Y{8jd6t`y=DQRXBbBrb&80^-_eRTSMeTCC|87S^>Cv^q_I6iTCaL>Be(^=1I5_;u zKlwbiiX|$F;wN$rwXZAsvc~yo-LB#(0upTtjSqhmTEFgYs(!D=6*kq4HH!}~ViD-L z+@F4{elc5p)#S!qfu21pU+cNd{HPT1!ZPa`hpE%1LZ6alzkCCZx?N9`Uh$jf9mm2j zl|--Rl@%V-;teG0J@@BcI(KhUvD}r$zdu{UJ$&<>OG6Jgvu3H>{Gjp5L{+r&sFSHG bVfJ6v(qv|?l0Bl|K-t05)z4*}Q$iB}x6FTB diff --git a/ios/Runner/Assets.xcassets/AppIcon26.appiconset/80.png b/ios/Runner/Assets.xcassets/AppIcon26.appiconset/80.png deleted file mode 100644 index 32b614cec547c4e6f959b3a14eae1bc8a01a1792..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 924 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51SA=YQ-6V}Aa^H*b?0PW0y!3`5uRzjz6@GG zHU|S^dnN-5NC^;20Wkvu(*h>ANWcPS1REqNq>)$3z`!iz>Eakt!T5IeZok6@B5l!n z-jWdxNe`6V)Fe)G@G~m&9|)0av13-YVbSg>Y23MH!=b2u2`4wz$Q-xxlsaxJx=QBy z^#9iT^$V5Sx{oGp@S49hv`IID)uR0P(ZoWF%^W8toI4ZHq|7&YLdrBnzVtMWgM#gz z9!94H+Kr8;Jm@&AqB1je&78xBzc+VZ{3)4pM{53~>hvPvyqCuxrdB*m^WJ1Ceqfr& z7S$m{g=Mnn zk9l?xmYp$GCgIG@3)j?bIv{t}@j&X=U^czWGF)%g%(vJOTzseZ`1LQ0hoyPHzDUmO zV`h8)C+m`Olr5Fr+Fx_*G59UY?Q_ zcH6Qup5=INyxTH;dznJ0;OosdnNIPX2<9qYZpwYRB*%L3#S{=W2#?_EtPd?u~ z<@0TB_qCO#?);guT6e{SZfKo7edW@!XC3Sx4>lu%(XUKr{7w9 zbGz3oCGWZ@I++O+P z%NN1l!Y2YslaJG+$To{PGZ(VB%cv=y)Nz)< dmmq;jvEIY}2M)H#kcEmbZ?Gt z&id>BH>Il!yLO(>?oY4#TzTzQuTkv!>yxejm%cwI{DOglb>RZDrVPg|3Q++pF5F!$ zS6D9sxq;WnZqf+JD|G?;rnv?)<{txmyoAzgX(P*e<Kl?P|CE&VK!|g&~}M%YtUci_6N{nH)J*Z+~CvvPaPCV18@#nw_;fBdi=<0vswf zT-v2HN1^EzlmGgS2cuSU+qA6y9Pc)>`!dtbLu}04dE0kx6LjKYJ;I|syK9fN_@?(M z<`o>#nPD4tE3~f&&bsCFZOvZ(<$HunyN(~$|9Ly)@r#-{D_C_VY}BZSnxE!f=$TXbyy%8VWK@Av z?mFSwO5WTYKite&=Y8QZI(jqlx~a$Y`s=Qn^`|#wuD_GsBK~Ouce>c`8<#@=%_xq2 z*SCXpJ7>!T?}ah%%riBoGnMT2{La00t~aCqJMPL&hQ=N#d#?(H%<(Vjnbhf&aedhh z>u^S{gv{R)=SSWsnPsY}GcC~E^|s*&%W`)KyPMf27bm+mdL^H@xhzf0BSnl=tM}W& zrO#&Y-q)BIDRP44&8xPJZhz%9C%zQfAh?C=)?Ja1>z6b?*p#>bWb600s_ZQHI%EEh z??q(qrxz!G7IiLH*|XN!W66T&uT>S<8&2#g&CZ_J_F3Se8|Az24|wrOL!_nY*7-$*GYdRt9jUj`*ZzKatDh_=gL}IAxvX switch (this) { + MessageType.announcement => Icons.campaign, + MessageType.news => Icons.newspaper, + MessageType.article => Icons.article, + }; +} diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 0d0be9e1..870e6cca 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -41,10 +41,6 @@ class _BluetoothConnectionPageState extends State { BluetoothDevice? selectedDevice; int selected = 40; - /// Set of normalized UUIDs (no dashes, lower-case) to filter discovered devices by - /// If empty, no UUID filtering is applied. - final Set _filterUuids = {}; - @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; @@ -324,8 +320,7 @@ class _BluetoothConnectionPageState extends State { child: Column( children: snapshot.data! .where((element) => - element.device.platformName.isNotEmpty && - _matchesUuid(element, _filterUuids)) + element.device.platformName.isNotEmpty) .toList() .asMap() .entries @@ -402,34 +397,6 @@ class _BluetoothConnectionPageState extends State { ); } - /// Returns true if [scanResult] advertises any UUID present in [filterUuids]. - /// If [filterUuids] is empty, always returns true. - bool _matchesUuid(ScanResult scanResult, Set filterUuids) { - if (filterUuids.isEmpty) return true; - - // Normalize helper: remove dashes and lowercase - String normalize(String u) => u.replaceAll('-', '').toLowerCase(); - - try { - // FlutterBluePlus ScanResult contains advertisementData with serviceUuids - final adv = scanResult.advertisementData; - final serviceUuids = adv.serviceUuids; - for (var u in serviceUuids) { - final us = u.toString(); - if (filterUuids.contains(normalize(us))) return true; - } - - // Also check device id (remoteId) as fallback - final devId = scanResult.device.remoteId.str; - if (filterUuids.contains(normalize(devId))) return true; - } catch (_) { - // If structure differs, fall back to allowing the device - return true; - } - - return false; - } - Widget connectionInstructions(DeviceViewModel device, BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; AssetImage? assetImage; diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 757cf560..912b8c2d 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -64,7 +64,7 @@ class MessageDetailsPage extends StatelessWidget { Padding( padding: const EdgeInsets.only(right: 24), child: Material( - color: CACHET.DEPLOYMENT_DEPLOYING, + color: Theme.of(context).extension()!.primary, borderRadius: BorderRadius.circular(100.0), child: Padding( padding: const EdgeInsets.symmetric( diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 68976f03..a8c7d08a 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -409,7 +409,7 @@ class StudyPageState extends State { borderRadius: BorderRadius.circular(100.0), child: Padding( padding: const EdgeInsets.all(4.0), - child: messageTypeIcon[message.type], + child: Icon(message.type.icon, color: Colors.white), ), ), ], @@ -511,12 +511,6 @@ class StudyPageState extends State { StudyDeploymentStatusTypes.Running: 'pages.about.status.running.message', StudyDeploymentStatusTypes.Stopped: 'pages.about.status.stopped.message', }; - - static Map messageTypeIcon = { - MessageType.announcement: Icon(Icons.campaign, color: Colors.white), - MessageType.news: Icon(Icons.newspaper, color: Colors.white), - MessageType.article: Icon(Icons.article, color: Colors.white), - }; } extension CopyWithAdditional on DateTime { diff --git a/lib/ui/tasks/camera_page.dart b/lib/ui/tasks/camera_page.dart index eaef6e53..352b08dc 100644 --- a/lib/ui/tasks/camera_page.dart +++ b/lib/ui/tasks/camera_page.dart @@ -101,7 +101,7 @@ class CameraPageState extends State { } } - void stopRecording(dynamic details) async { + void stopRecording(LongPressEndDetails details) async { try { var video = await _cameraController.stopVideoRecording(); diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index b77f3f45..94ad2aab 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -40,18 +40,6 @@ class StudyPageViewModel extends ViewModel { /// The list of messages to be displayed. List get messages => bloc.messages.reversed.toList(); - /// The icon for a type of message - Icon getMessageTypeIcon(MessageType type) { - switch (type) { - case MessageType.announcement: - return const Icon(Icons.new_releases); - case MessageType.article: - return const Icon(Icons.description); - case MessageType.news: - return const Icon(Icons.create_new_folder); - } - } - /// Get the image based on [imagePath]. Can be both an asset and a network /// image. See [Message.imagePath]. /// diff --git a/pubspec.yaml b/pubspec.yaml index 81963469..39b3f24d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: carp_study_app description: The generic CARP study app. publish_to: "none" -version: 4.2.0+94 +version: 4.3.0+1 environment: sdk: ">=3.2.0 <4.0.0" From 696d56e62a208dd116cb4f0fd15871c83643f826 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 8 Jun 2026 13:14:16 +0200 Subject: [PATCH 45/94] fix: dart format --- lib/ui/pages/devices_page.bluetooth_connection_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 870e6cca..30cf2389 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -319,8 +319,8 @@ class _BluetoothConnectionPageState extends State { padding: const EdgeInsets.only(top: 16), child: Column( children: snapshot.data! - .where((element) => - element.device.platformName.isNotEmpty) + .where( + (element) => element.device.platformName.isNotEmpty) .toList() .asMap() .entries From 4d990e0ad6d8e0c56c51f6f2bd70b58e68366131 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 11 Jun 2026 11:30:22 +0200 Subject: [PATCH 46/94] fix: dart format set to line length of 120 --- analysis_options.yaml | 3 + lib/blocs/app_bloc.dart | 89 ++++-------- lib/blocs/sensing.dart | 40 ++---- lib/blocs/util.dart | 3 +- lib/carp_study_app.dart | 27 ++-- lib/data/carp_backend.dart | 29 ++-- lib/data/local_participation_service.dart | 7 +- lib/data/local_resource_manager.dart | 42 ++---- lib/data/local_settings.dart | 45 ++---- lib/data/localization_loader.dart | 3 +- lib/data/participant.dart | 6 +- lib/main.g.dart | 101 ++++++-------- lib/ui/cards/activity_card.dart | 44 ++---- lib/ui/cards/anonymous_card.dart | 10 +- lib/ui/cards/distance_card.dart | 16 +-- lib/ui/cards/heart_rate_card.dart | 75 ++++------ lib/ui/cards/media_card.dart | 20 +-- lib/ui/cards/mobility_card.dart | 24 +--- lib/ui/cards/scoreboard_card.dart | 36 ++--- lib/ui/cards/steps_card.dart | 16 +-- lib/ui/cards/study_progress_card.dart | 35 ++--- lib/ui/cards/survey_card.dart | 17 +-- lib/ui/carp_study_style.dart | 22 +-- lib/ui/colors.dart | 3 +- lib/ui/pages/data_visualization_page.dart | 26 ++-- lib/ui/pages/device_list_page.dart | 88 ++++-------- .../devices_page.authorization_dialog.dart | 9 +- ...evices_page.bluetooth_connection_page.dart | 76 ++++------ .../devices_page.disconnection_dialog.dart | 3 +- .../devices_page.enable_bluetooth_dialog.dart | 15 +- .../devices_page.health_service_connect.dart | 48 ++----- lib/ui/pages/devices_page.list_title.dart | 6 +- lib/ui/pages/enable_connection_dialog.dart | 23 ++- lib/ui/pages/home_page.dart | 3 +- ...me_page.install_health_connect_dialog.dart | 4 +- lib/ui/pages/invitation_list_page.dart | 25 ++-- lib/ui/pages/invitation_page.dart | 30 ++-- lib/ui/pages/message_details_page.dart | 46 ++---- lib/ui/pages/process_message_page.dart | 15 +- lib/ui/pages/profile_page.dart | 54 +++----- lib/ui/pages/qr_scanner.dart | 15 +- lib/ui/pages/study_details_page.dart | 121 ++++------------ lib/ui/pages/study_page.dart | 91 ++++-------- lib/ui/pages/task_list_page.dart | 126 ++++++----------- lib/ui/tasks/audio_page.dart | 102 ++++---------- lib/ui/tasks/audio_task_page.dart | 22 +-- lib/ui/tasks/camera_page.dart | 21 +-- lib/ui/tasks/camera_task_page.dart | 46 ++---- lib/ui/tasks/display_picture_page.dart | 21 +-- lib/ui/tasks/participant_data_page.dart | 131 ++++++------------ lib/ui/widgets/battery_icon.dart | 6 +- lib/ui/widgets/carp_app_bar.dart | 5 +- lib/ui/widgets/charts_legend.dart | 10 +- lib/ui/widgets/details_banner.dart | 6 +- lib/ui/widgets/dialog_title.dart | 18 +-- lib/ui/widgets/horizontal_bar.dart | 33 ++--- lib/ui/widgets/location_usage_dialog.dart | 13 +- .../cards/activity_data_model.dart | 52 +++---- .../cards/heart_rate_data_model.dart | 21 +-- .../cards/measurements_data_model.dart | 15 +- .../cards/mobility_data_model.dart | 36 ++--- lib/view_models/cards/steps_data_model.dart | 34 ++--- .../cards/study_progress_data_model.dart | 5 +- lib/view_models/cards/task_data_model.dart | 13 +- .../data_visualization_page_model.dart | 34 ++--- lib/view_models/device_view_models.dart | 52 +++---- lib/view_models/invitations_view_model.dart | 6 +- .../participant_data_page_model.dart | 3 +- lib/view_models/profile_page_model.dart | 12 +- lib/view_models/study_page_model.dart | 21 +-- lib/view_models/tasklist_page_model.dart | 9 +- lib/view_models/user_tasks.dart | 12 +- lib/view_models/view_model.dart | 31 ++--- test/cams_app_test.dart | 6 +- test/heart_rate_data_model_test.dart | 28 ++-- test/heart_rate_data_model_test.mocks.dart | 91 +++++------- 76 files changed, 776 insertions(+), 1676 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index b449163e..6a7acd6c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,6 +3,9 @@ include: package:lints/recommended.yaml +formatter: + page_width: 120 + analyzer: exclude: [build/**] language: diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index a26611d7..ea5e9f02 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -53,8 +53,7 @@ class StudyAppBLoC extends ChangeNotifier { final CarpBackend _backend = CarpBackend(); final CarpStudyAppViewModel _appViewModel = CarpStudyAppViewModel(); List _messages = []; - final StreamController _messageStreamController = - StreamController.broadcast(); + final StreamController _messageStreamController = StreamController.broadcast(); /// The state of this BloC. StudyAppState get state => _state; @@ -86,14 +85,11 @@ class StudyAppBLoC extends ChangeNotifier { /// Create the BLoC for the app. StudyAppBLoC() : super() { - const dep = - String.fromEnvironment('deployment-mode', defaultValue: 'production'); - deploymentMode = - DeploymentMode.values.where((element) => element.name == dep).first; + const dep = String.fromEnvironment('deployment-mode', defaultValue: 'production'); + deploymentMode = DeploymentMode.values.where((element) => element.name == dep).first; const deb = String.fromEnvironment('debug-level', defaultValue: 'info'); - debugLevel = - DebugLevel.values.where((element) => element.name == deb).first; + debugLevel = DebugLevel.values.where((element) => element.name == deb).first; info('$runtimeType created. ' 'DeploymentMode: ${deploymentMode.name}, ' @@ -101,28 +97,22 @@ class StudyAppBLoC extends ChangeNotifier { } LocalizationManager get localizationManager => - (deploymentMode == DeploymentMode.local - ? LocalResourceManager() - : CarpResourceManager()) as LocalizationManager; + (deploymentMode == DeploymentMode.local ? LocalResourceManager() : CarpResourceManager()) as LocalizationManager; LocalizationLoader get localizationLoader { debug('$runtimeType - using localizationManager: $localizationManager'); return ResourceLocalizationLoader(localizationManager); } - MessageManager get messageManager => (deploymentMode == DeploymentMode.local - ? LocalResourceManager() - : CarpResourceManager()) as MessageManager; + MessageManager get messageManager => + (deploymentMode == DeploymentMode.local ? LocalResourceManager() : CarpResourceManager()) as MessageManager; InformedConsentManager get informedConsentManager => - (bloc.deploymentMode == DeploymentMode.local - ? LocalResourceManager() - : CarpResourceManager()) as InformedConsentManager; + (bloc.deploymentMode == DeploymentMode.local ? LocalResourceManager() : CarpResourceManager()) + as InformedConsentManager; ParticipationService get participationService => - (bloc.deploymentMode == DeploymentMode.local - ? LocalParticipationService() - : CarpParticipationService()); + (bloc.deploymentMode == DeploymentMode.local ? LocalParticipationService() : CarpParticipationService()); CarpBackend get backend => _backend; @@ -138,13 +128,11 @@ class StudyAppBLoC extends ChangeNotifier { /// The deployment running on this phone. SmartphoneDeployment? get deployment => Sensing().controller?.deployment; - Set get expectedParticipantData => - deployment?.expectedParticipantData ?? {}; + Set get expectedParticipantData => deployment?.expectedParticipantData ?? {}; /// Get the status for the current study deployment. /// Returns null if the study is not yet deployed on this phone. - Future get studyDeploymentStatus async => - await Sensing().getStudyDeploymentStatus(); + Future get studyDeploymentStatus async => await Sensing().getStudyDeploymentStatus(); /// When was this study deployed on this phone. DateTime? get studyStartTimestamp => deployment?.deployed; @@ -186,12 +174,9 @@ class StudyAppBLoC extends ChangeNotifier { /// Is the phone connected to the internet either via wifi or mobile network? Future checkConnectivity() async { - final List results = - await (Connectivity().checkConnectivity()); + final List results = await (Connectivity().checkConnectivity()); - return results.any((element) => - element == ConnectivityResult.mobile || - element == ConnectivityResult.wifi); + return results.any((element) => element == ConnectivityResult.mobile || element == ConnectivityResult.wifi); } /// Check if the Health database is installed on this phone. @@ -202,8 +187,7 @@ class StudyAppBLoC extends ChangeNotifier { if (Platform.isIOS) return true; try { - return await appCheck - .isAppInstalled(LocalSettings.healthConnectPackageName); + return await appCheck.isAppInstalled(LocalSettings.healthConnectPackageName); } catch (e) { debug("$runtimeType - Error checking Health Connect installation: $e"); return false; @@ -243,8 +227,7 @@ class StudyAppBLoC extends ChangeNotifier { var protocol = await LocalResourceManager().getStudyProtocol(''); // Deploy this protocol using the on-phone deployment service. - final status = - await SmartphoneDeploymentService().createStudyDeployment(protocol!); + final status = await SmartphoneDeploymentService().createStudyDeployment(protocol!); // Save the participant and study on the phone for use across app restart. var participant = Participant( @@ -322,10 +305,7 @@ class StudyAppBLoC extends ChangeNotifier { } Future> getParticipantDataListFromDeployment() async => - (deployment == null) - ? [] - : await participationService - .getParticipantDataList([deployment!.studyDeploymentId]); + (deployment == null) ? [] : await participationService.getParticipantDataList([deployment!.studyDeploymentId]); /// Set the participant data for this study. void setParticipantData( @@ -353,12 +333,10 @@ class StudyAppBLoC extends ChangeNotifier { /// backend and falls back to the locally stored flag. Future get hasInformedConsentBeenAccepted async { if (deploymentMode == DeploymentMode.local || study == null) { - return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? - false; + return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; } try { - final consent = await backend.getInformedConsentByRole( - study!.studyDeploymentId, study!.participantRoleName); + final consent = await backend.getInformedConsentByRole(study!.studyDeploymentId, study!.participantRoleName); return consent != null; } catch (e) { warning('Could not fetch informed consent status from backend: $e'); @@ -408,11 +386,10 @@ class StudyAppBLoC extends ChangeNotifier { /// Does this [deployment] have any measures? bool hasMeasures() => (deployment == null) ? false - : (deployment!.measures.any((measure) => - (measure.type != SurveyUserTask.VIDEO_TYPE && - measure.type != SurveyUserTask.IMAGE_TYPE && - measure.type != SurveyUserTask.AUDIO_TYPE && - measure.type != SurveyUserTask.SURVEY_TYPE))); + : (deployment!.measures.any((measure) => (measure.type != SurveyUserTask.VIDEO_TYPE && + measure.type != SurveyUserTask.IMAGE_TYPE && + measure.type != SurveyUserTask.AUDIO_TYPE && + measure.type != SurveyUserTask.SURVEY_TYPE))); /// Does this [deployment] have the measure of type [type]? bool hasMeasure(String type) { @@ -428,21 +405,16 @@ class StudyAppBLoC extends ChangeNotifier { } /// Does this [deployment] have any user tasks? - bool hasUserTasks() => (deployment == null) - ? false - : deployment!.tasks.whereType().isNotEmpty; + bool hasUserTasks() => (deployment == null) ? false : deployment!.tasks.whereType().isNotEmpty; /// Does this [deployment] have any connected devices? - bool hasDevices() => - (deployment == null) ? false : deployment!.connectedDevices.isNotEmpty; + bool hasDevices() => (deployment == null) ? false : deployment!.connectedDevices.isNotEmpty; /// Is sensing running, i.e. has the study executor been resumed? bool get isRunning => Sensing().isRunning; /// the list of running - i.e. used - probes in this study. - List get runningProbes => (Sensing().controller != null) - ? Sensing().controller!.executor.probes - : []; + List get runningProbes => (Sensing().controller != null) ? Sensing().controller!.executor.probes : []; DeploymentService get deploymentService => Sensing().deploymentService; @@ -452,8 +424,7 @@ class StudyAppBLoC extends ChangeNotifier { /// Start sensing. Future start() async { - assert(Sensing().controller != null, - 'No Study Controller - the study has not been deployed.'); + assert(Sensing().controller != null, 'No Study Controller - the study has not been deployed.'); if (!Sensing().isRunning) Sensing().controller?.start(); } @@ -468,12 +439,10 @@ class StudyAppBLoC extends ChangeNotifier { } /// Add [measurement] to the stream of collected measurements. - void addMeasurement(Measurement measurement) => - Sensing().controller?.executor.addMeasurement(measurement); + void addMeasurement(Measurement measurement) => Sensing().controller?.executor.addMeasurement(measurement); /// Add [error] to the stream of measurements. - void addError(Object error, [StackTrace? stacktrace]) => - Sensing().controller?.executor.addError(error, stacktrace); + void addError(Object error, [StackTrace? stacktrace]) => Sensing().controller?.executor.addError(error, stacktrace); /// Leave the study deployed on this phone. /// diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 01e34c7e..78a25151 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -26,9 +26,7 @@ class Sensing { /// The deployment service used in this app. DeploymentService get deploymentService => - bloc.deploymentMode == DeploymentMode.local - ? SmartphoneDeploymentService() - : CarpDeploymentService(); + bloc.deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); /// The study running on this phone. /// Only available after [addStudy] is called. @@ -51,13 +49,10 @@ class Sensing { SmartphoneDeploymentController? get controller => _controller; /// Is sensing running, i.e. has the study executor been started? - bool get isRunning => - (controller != null) && - controller!.executor.state == ExecutorState.started; + bool get isRunning => (controller != null) && controller!.executor.state == ExecutorState.started; /// The list of running - i.e. used - probes in this study. - List get runningProbes => - (_controller != null) ? _controller!.executor.probes : []; + List get runningProbes => (_controller != null) ? _controller!.executor.probes : []; /// The list of all device managers used in the current deployment. /// @@ -69,8 +64,7 @@ class Sensing { .deviceController .devices .values - .where((manager) => deployment!.devices - .any((element) => element.type == manager.type)) + .where((manager) => deployment!.devices.any((element) => element.type == manager.type)) .toList() : []; @@ -79,8 +73,7 @@ class Sensing { SmartPhoneClientManager().deviceController.smartphoneDeviceManager; /// The list of connected devices. - List? get connectedDevices => - SmartPhoneClientManager().deviceController.connectedDevices; + List? get connectedDevices => SmartPhoneClientManager().deviceController.connectedDevices; /// The singleton sensing instance factory Sensing() => _instance; @@ -131,13 +124,11 @@ class Sensing { Future addStudy() async { assert(SmartPhoneClientManager().isConfigured, 'The client manager is not yet configured. Call SmartPhoneClientManager().configure() before adding a study.'); - assert(bloc.study != null, - 'No study is provided. Cannot start deployment w/o a study.'); + assert(bloc.study != null, 'No study is provided. Cannot start deployment w/o a study.'); _study = await SmartPhoneClientManager().addStudy(bloc.study!); - _controller = - SmartPhoneClientManager().getStudyRuntime(study!.studyDeploymentId); + _controller = SmartPhoneClientManager().getStudyRuntime(study!.studyDeploymentId); return await tryDeployment(); } @@ -149,8 +140,7 @@ class Sensing { /// If not deployed before (i.e., cached) the study deployment will be /// fetched from the deployment service. Future tryDeployment() async { - assert(controller != null, - 'No study or controller is provided. Cannot start deployment w/o a study.'); + assert(controller != null, 'No study or controller is provided. Cannot start deployment w/o a study.'); StudyStatus status = await controller!.tryDeployment(useCached: true); @@ -162,8 +152,7 @@ class Sensing { // — matches the gating in carp_mobile_sensing's debug()/info() helpers so a // release build (or any non-debug level) doesn't get spammed. if (Settings().debugLevel.index >= DebugLevel.debug.index) { - controller?.measurements - .listen((measurement) => debugPrint(toJsonString(measurement))); + controller?.measurements.listen((measurement) => debugPrint(toJsonString(measurement))); } info('$runtimeType - Study added, deployment id: $studyDeploymentId'); @@ -193,10 +182,7 @@ class Sensing { /// Get the status for the current study deployment. /// Returns null if the study is not yet deployed on this phone. Future getStudyDeploymentStatus() async => - studyDeploymentId != null - ? _status = await deploymentService - .getStudyDeploymentStatus(studyDeploymentId!) - : null; + studyDeploymentId != null ? _status = await deploymentService.getStudyDeploymentStatus(studyDeploymentId!) : null; /// Translate the title and description of all AppTask in the study protocol /// of the current master deployment. @@ -207,8 +193,7 @@ class Sensing { if (bloc.localization == null) return; // Fast out, if not configured or no protocol - if (controller?.status != StudyStatus.Deployed || - controller?.deployment == null) { + if (controller?.status != StudyStatus.Deployed || controller?.deployment == null) { return; } @@ -219,7 +204,6 @@ class Sensing { } } - info( - "$runtimeType - Study protocol translated to locale '${bloc.localization!.locale}'"); + info("$runtimeType - Study protocol translated to locale '${bloc.localization!.locale}'"); } } diff --git a/lib/blocs/util.dart b/lib/blocs/util.dart index f2c35de1..1713255c 100644 --- a/lib/blocs/util.dart +++ b/lib/blocs/util.dart @@ -1,8 +1,7 @@ part of carp_study_app; extension StringExtension on String { - String truncateTo(int maxLength) => - (length <= maxLength) ? this : '${substring(0, maxLength)}...'; + String truncateTo(int maxLength) => (length <= maxLength) ? this : '${substring(0, maxLength)}...'; } extension Humanize on Duration { diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index eb34048e..eeaddadf 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -8,8 +8,7 @@ class CarpStudyApp extends StatefulWidget { /// Reload language translations and re-build the entire app. static void reloadLocale(BuildContext context) async { - CarpStudyAppState? state = - context.findAncestorStateOfType(); + CarpStudyAppState? state = context.findAncestorStateOfType(); state?.reloadLocale(); } @@ -38,8 +37,7 @@ class CarpStudyAppState extends State { 'studyDeployed=${bloc.hasStudyBeenDeployed}'); // 1) Not authenticated → login page. - if (bloc.deploymentMode != DeploymentMode.local && - !bloc.backend.isAuthenticated) { + if (bloc.deploymentMode != DeploymentMode.local && !bloc.backend.isAuthenticated) { debugPrint('[redirect] → /login (not authenticated)'); return LoginPage.route; } @@ -47,8 +45,7 @@ class CarpStudyAppState extends State { // 2) No study deployed → user belongs on the invitation list (or its // details page). Anywhere else gets bounced to the list. if (!bloc.hasStudyBeenDeployed) { - if (loc == InvitationListPage.route || - loc.startsWith('${InvitationDetailsPage.route}/')) { + if (loc == InvitationListPage.route || loc.startsWith('${InvitationDetailsPage.route}/')) { debugPrint('[redirect] → null (already on invitation route)'); return null; } @@ -63,8 +60,7 @@ class CarpStudyAppState extends State { routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, - builder: (BuildContext context, GoRouterState state, Widget child) => - HomePage(child: child), + builder: (BuildContext context, GoRouterState state, Widget child) => HomePage(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. @@ -109,8 +105,7 @@ class CarpStudyAppState extends State { path: DataVisualizationPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: DataVisualizationPage( - bloc.appViewModel.dataVisualizationPageViewModel), + child: DataVisualizationPage(bloc.appViewModel.dataVisualizationPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), @@ -142,8 +137,7 @@ class CarpStudyAppState extends State { GoRoute( path: ParticipantDataPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => ParticipantDataPage( - model: bloc.appViewModel.participantDataPageViewModel), + builder: (context, state) => ParticipantDataPage(model: bloc.appViewModel.participantDataPageViewModel), ), GoRoute( path: '/task/:taskId', @@ -162,8 +156,7 @@ class CarpStudyAppState extends State { GoRoute( path: '${MessageDetailsPage.route}/:messageId', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => MessageDetailsPage( - messageId: state.pathParameters['messageId'] ?? ''), + builder: (context, state) => MessageDetailsPage(messageId: state.pathParameters['messageId'] ?? ''), ), GoRoute( path: '${InvitationDetailsPage.route}/:invitationId', @@ -176,8 +169,7 @@ class CarpStudyAppState extends State { GoRoute( path: InvitationListPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => InvitationListPage( - model: bloc.appViewModel.invitationsListViewModel), + builder: (context, state) => InvitationListPage(model: bloc.appViewModel.invitationsListViewModel), ), ], debugLogDiagnostics: true, @@ -185,8 +177,7 @@ class CarpStudyAppState extends State { /// Research Package translations, incl. both local language assets plus /// translations of informed consent and surveys downloaded from CARP - final RPLocalizationsDelegate rpLocalizationsDelegate = - RPLocalizationsDelegate( + final RPLocalizationsDelegate rpLocalizationsDelegate = RPLocalizationsDelegate( loaders: [ const AssetLocalizationLoader(), bloc.localizationLoader, diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index 52a08e8a..8d016c3c 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -161,17 +161,14 @@ class CarpBackend { Future> getInvitations() async { CarpParticipationService().configureFrom(CarpService()); - invitations = - await CarpParticipationService().getActiveParticipationInvitations(); + invitations = await CarpParticipationService().getActiveParticipationInvitations(); // Filter the invitations to only include those that // have a smartphone as a device in [ActiveParticipationInvitation.assignedDevices] list // (i.e. the invitation is for a smartphone). // This is done to avoid showing invitations for other devices (e.g. [WebBrowser]). - invitations.removeWhere((invitation) => - invitation.assignedDevices - ?.any((device) => device.device is! Smartphone) ?? - false); + invitations.removeWhere( + (invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) ?? false); return invitations; } @@ -198,8 +195,7 @@ class CarpBackend { return null; } if (participant == null) { - warning( - '$runtimeType - No participant (no invitation has been accepted).'); + warning('$runtimeType - No participant (no invitation has been accepted).'); return null; } @@ -209,8 +205,7 @@ class CarpBackend { (result) => result is RPConsentSignatureResult, ) as RPConsentSignatureResult; } catch (_) { - warning( - '$runtimeType - No signed informed consent found to be uploaded.'); + warning('$runtimeType - No signed informed consent found to be uploaded.'); return null; } @@ -225,24 +220,18 @@ class CarpBackend { ); try { - await CarpParticipationService() - .participation() - .setInformedConsent(uploadedConsent); + await CarpParticipationService().participation().setInformedConsent(uploadedConsent); info('$runtimeType - Informed consent document uploaded successfully for ' 'deployment id: ${bloc.study?.studyDeploymentId}'); } on Exception { - warning( - '$runtimeType - Informed consent upload failed for username: $username'); + warning('$runtimeType - Informed consent upload failed for username: $username'); } return uploadedConsent; } - Future? getInformedConsentByRole( - String studyDeploymentId, String? role) async { - return await CarpParticipationService() - .participation(studyDeploymentId) - .getInformedConsentByRole(role); + Future? getInformedConsentByRole(String studyDeploymentId, String? role) async { + return await CarpParticipationService().participation(studyDeploymentId).getInformedConsentByRole(role); } } diff --git a/lib/data/local_participation_service.dart b/lib/data/local_participation_service.dart index 610ac23b..67c543d2 100644 --- a/lib/data/local_participation_service.dart +++ b/lib/data/local_participation_service.dart @@ -3,8 +3,7 @@ part of carp_study_app; /// A local [ParticipationService] that does not connect to any backend. /// This is used when running in [DeploymentMode.local]. class LocalParticipationService implements ParticipationService { - static final LocalParticipationService _instance = - LocalParticipationService._(); + static final LocalParticipationService _instance = LocalParticipationService._(); LocalParticipationService._(); @@ -12,9 +11,7 @@ class LocalParticipationService implements ParticipationService { factory LocalParticipationService() => _instance; @override - Future> getActiveParticipationInvitations( - [String? accountId]) async => - []; + Future> getActiveParticipationInvitations([String? accountId]) async => []; @override Future getParticipantData(String studyDeploymentId) async => diff --git a/lib/data/local_resource_manager.dart b/lib/data/local_resource_manager.dart index fafdba4b..4afe1432 100644 --- a/lib/data/local_resource_manager.dart +++ b/lib/data/local_resource_manager.dart @@ -17,11 +17,7 @@ part of carp_study_app; /// Note that the 'id' of the protocol in the [getStudyProtocol] method is ignored. /// The 'protocol.json' file is always loaded. class LocalResourceManager - implements - InformedConsentManager, - LocalizationManager, - MessageManager, - StudyProtocolManager { + implements InformedConsentManager, LocalizationManager, MessageManager, StudyProtocolManager { /// The path to the json files to be loaded using this resource manager. static final String basePath = 'assets/carp'; @@ -49,10 +45,8 @@ class LocalResourceManager Future getInformedConsent({bool refresh = false}) async { if (_informedConsent == null) { try { - var jsonString = - await rootBundle.loadString('$basePath/resources/consent.json'); - Map jsonMap = - json.decode(jsonString) as Map; + var jsonString = await rootBundle.loadString('$basePath/resources/consent.json'); + Map jsonMap = json.decode(jsonString) as Map; _informedConsent = RPOrderedTask.fromJson(jsonMap); } catch (error) { warning("$runtimeType - Could not load a local informed consent. " @@ -85,10 +79,8 @@ class LocalResourceManager var path = '$basePath/lang/${locale.languageCode}.json'; var jsonString = await rootBundle.loadString(path); - Map jsonMap = - json.decode(jsonString) as Map; - _translations = - jsonMap.map((key, value) => MapEntry(key, value.toString())); + Map jsonMap = json.decode(jsonString) as Map; + _translations = jsonMap.map((key, value) => MapEntry(key, value.toString())); } return _translations!; } @@ -119,36 +111,28 @@ class LocalResourceManager }) async { if (_messages.isEmpty) { final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle); - final files = assetManifest - .listAssets() - .where((string) => string.startsWith("$basePath/messages/")) - .toList(); + final files = assetManifest.listAssets().where((string) => string.startsWith("$basePath/messages/")).toList(); for (var file in files) { var jsonString = await rootBundle.loadString(file); - Map jsonMap = - json.decode(jsonString) as Map; + Map jsonMap = json.decode(jsonString) as Map; var message = Message.fromJson(jsonMap); _messages[message.id] = message; } } - return _messages.values - .toList() - .sublist(0, (count! < _messages.length) ? count : _messages.length); + return _messages.values.toList().sublist(0, (count! < _messages.length) ? count : _messages.length); } @override Future getMessage(String messageId) async => _messages[messageId]; @override - Future setMessage(Message message) async => - _messages[message.id] = message; + Future setMessage(Message message) async => _messages[message.id] = message; @override - Future deleteMessage(String messageId) async => - _messages.remove(messageId); + Future deleteMessage(String messageId) async => _messages.remove(messageId); @override Future deleteAllMessages() async => _messages.clear(); @@ -159,11 +143,9 @@ class LocalResourceManager Future getStudyProtocol(String id) async { if (_protocol == null) { try { - var jsonString = - await rootBundle.loadString('$basePath/resources/protocol.json'); + var jsonString = await rootBundle.loadString('$basePath/resources/protocol.json'); - Map jsonMap = - json.decode(jsonString) as Map; + Map jsonMap = json.decode(jsonString) as Map; _protocol = SmartphoneStudyProtocol.fromJson(jsonMap); if (_protocol?.dataEndPoint?.type != null) { diff --git a/lib/data/local_settings.dart b/lib/data/local_settings.dart index a5194c1a..461d8034 100644 --- a/lib/data/local_settings.dart +++ b/lib/data/local_settings.dart @@ -35,9 +35,7 @@ class LocalSettings { if (_user == null) { String? userString = Settings().preferences!.getString(userKey); - _user = (userString != null) - ? CarpUser.fromJson(jsonDecode(userString) as Map) - : null; + _user = (userString != null) ? CarpUser.fromJson(jsonDecode(userString) as Map) : null; } return _user; } @@ -62,9 +60,7 @@ class LocalSettings { Participant? get participant { if (_participant == null) { String? userString = Settings().preferences!.getString(participantKey); - _participant = (userString != null) - ? Participant.fromJson(jsonDecode(userString) as Map) - : null; + _participant = (userString != null) ? Participant.fromJson(jsonDecode(userString) as Map) : null; } return _participant; } @@ -72,9 +68,7 @@ class LocalSettings { set participant(Participant? participant) { _participant = participant; if (participant != null) { - Settings() - .preferences! - .setString(participantKey, jsonEncode(participant.toJson())); + Settings().preferences!.setString(participantKey, jsonEncode(participant.toJson())); } else { Settings().preferences!.remove(participantKey); } @@ -86,10 +80,8 @@ class LocalSettings { SmartphoneStudy? get study { if (_study != null) return _study; var jsonString = Settings().preferences?.getString(studyKey); - return _study = (jsonString == null) - ? null - : _$SmartphoneStudyFromJson( - json.decode(jsonString) as Map); + return _study = + (jsonString == null) ? null : _$SmartphoneStudyFromJson(json.decode(jsonString) as Map); } set study(SmartphoneStudy? study) { @@ -105,10 +97,7 @@ class LocalSettings { } bool get hasSeenBluetoothConnectionInstructions => - Settings() - .preferences - ?.getBool('hasSeenBluetoothConnectionInstructions') ?? - false; + Settings().preferences?.getBool('hasSeenBluetoothConnectionInstructions') ?? false; set hasSeenBluetoothConnectionInstructions(bool seen) { Settings().preferences?.setBool( @@ -117,10 +106,8 @@ class LocalSettings { ); } - bool get isAnonymous => - Settings().preferences!.getBool('isAnonymous') ?? false; - set isAnonymous(bool value) => - Settings().preferences!.setBool('isAnonymous', value); + bool get isAnonymous => Settings().preferences!.getBool('isAnonymous') ?? false; + set isAnonymous(bool value) => Settings().preferences!.setBool('isAnonymous', value); /// The study deployment id for the currently running deployment. String? get studyDeploymentId => _study?.studyDeploymentId; @@ -142,18 +129,15 @@ class LocalSettings { await Settings().preferences!.remove(userKey); } - Future get deploymentBasePath async => (studyDeploymentId == null) - ? null - : await Settings().getDeploymentBasePath(studyDeploymentId!); + Future get deploymentBasePath async => + (studyDeploymentId == null) ? null : await Settings().getDeploymentBasePath(studyDeploymentId!); - Future get cacheBasePath async => (studyDeploymentId == null) - ? null - : await Settings().getCacheBasePath(studyDeploymentId!); + Future get cacheBasePath async => + (studyDeploymentId == null) ? null : await Settings().getCacheBasePath(studyDeploymentId!); } // Need to create our own JSON serializers here, since SmartphoneStudy is not made serializable -Map _$SmartphoneStudyToJson(SmartphoneStudy study) => - { +Map _$SmartphoneStudyToJson(SmartphoneStudy study) => { 'studyId': study.studyId, 'studyDeploymentId': study.studyDeploymentId, 'deviceRoleName': study.deviceRoleName, @@ -161,8 +145,7 @@ Map _$SmartphoneStudyToJson(SmartphoneStudy study) => 'participantRoleName': study.participantRoleName, }; -SmartphoneStudy _$SmartphoneStudyFromJson(Map json) => - SmartphoneStudy( +SmartphoneStudy _$SmartphoneStudyFromJson(Map json) => SmartphoneStudy( studyId: json['studyId'] as String?, studyDeploymentId: json['studyDeploymentId'] as String, deviceRoleName: json['deviceRoleName'] as String, diff --git a/lib/data/localization_loader.dart b/lib/data/localization_loader.dart index 4f7c81e1..5ec67c9b 100644 --- a/lib/data/localization_loader.dart +++ b/lib/data/localization_loader.dart @@ -15,8 +15,7 @@ class ResourceLocalizationLoader implements LocalizationLoader { translations = await localizationManager.getLocalizations(locale) ?? {}; info("$runtimeType - translations for ´$locale' loaded."); } catch (error) { - warning( - "$runtimeType - could not load translations for '$locale' - $error"); + warning("$runtimeType - could not load translations for '$locale' - $error"); } return translations; diff --git a/lib/data/participant.dart b/lib/data/participant.dart index df71905e..e8f88857 100644 --- a/lib/data/participant.dart +++ b/lib/data/participant.dart @@ -29,11 +29,9 @@ class Participant { studyDeploymentId: invitation.studyDeploymentId, deviceRoleName: invitation.assignedDevices?.first.device.roleName, participantId: invitation.participation.participantId, - participantRoleName: - invitation.participation.assignedRoles.roleNames?.first, + participantRoleName: invitation.participation.assignedRoles.roleNames?.first, ); - factory Participant.fromJson(Map json) => - _$ParticipantFromJson(json); + factory Participant.fromJson(Map json) => _$ParticipantFromJson(json); Map toJson() => _$ParticipantToJson(this); } diff --git a/lib/main.g.dart b/lib/main.g.dart index cb297781..b3cd016d 100644 --- a/lib/main.g.dart +++ b/lib/main.g.dart @@ -12,37 +12,30 @@ 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) => - { +Map _$ParticipantToJson(Participant instance) => { if (instance.studyId case final value?) 'studyId': value, - if (instance.studyDeploymentId case final value?) - 'studyDeploymentId': value, + if (instance.studyDeploymentId case final value?) 'studyDeploymentId': value, if (instance.deviceRoleName case final value?) 'deviceRoleName': value, if (instance.participantId case final value?) 'participantId': value, - if (instance.participantRoleName case final value?) - 'participantRoleName': value, + if (instance.participantRoleName case final value?) 'participantRoleName': value, '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()), - )), - ); - -Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => - { - 'activities': instance.activities.map((k, e) => MapEntry( - _$ActivityTypeEnumMap[k]!, - e.map((k, e) => MapEntry(k.toString(), e)))), +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()), + )), + ); + +Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => { + 'activities': instance.activities + .map((k, e) => MapEntry(_$ActivityTypeEnumMap[k]!, e.map((k, e) => MapEntry(k.toString(), e)))), }; const _$ActivityTypeEnumMap = { @@ -54,29 +47,23 @@ 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)), +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( +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) => - { +Map _$DailyMobilityToJson(DailyMobility instance) => { 'weekday': instance.weekday, 'places': instance.places, 'homeStay': instance.homeStay, @@ -88,41 +75,31 @@ WeeklySteps _$WeeklyStepsFromJson(Map json) => WeeklySteps() (k, e) => MapEntry(int.parse(k), (e as num).toInt()), ); -Map _$WeeklyStepsToJson(WeeklySteps instance) => - { - 'weeklySteps': - instance.weeklySteps.map((k, e) => MapEntry(k.toString(), e)), +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)), +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(), if (instance.maxHeartRate case final value?) 'maxHeartRate': value, if (instance.minHeartRate case final value?) 'minHeartRate': value, }; -HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson( - Map json) => - HeartRateMinMaxPrHour( +HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson(Map json) => HeartRateMinMaxPrHour( (json['min'] as num?)?.toDouble(), (json['max'] as num?)?.toDouble(), ); -Map _$HeartRateMinMaxPrHourToJson( - HeartRateMinMaxPrHour instance) => - { +Map _$HeartRateMinMaxPrHourToJson(HeartRateMinMaxPrHour instance) => { if (instance.min case final value?) 'min': value, if (instance.max case final value?) 'max': value, }; diff --git a/lib/ui/cards/activity_card.dart b/lib/ui/cards/activity_card.dart index 68fe931f..8847b104 100644 --- a/lib/ui/cards/activity_card.dart +++ b/lib/ui/cards/activity_card.dart @@ -3,9 +3,7 @@ part of carp_study_app; class ActivityCard extends StatefulWidget { final ActivityCardViewModel model; final List colors; - const ActivityCard(this.model, - {super.key, - this.colors = const [CACHET.CAQUI, CACHET.OCEAN, CACHET.BLUE_2]}); + const ActivityCard(this.model, {super.key, this.colors = const [CACHET.CAQUI, CACHET.OCEAN, CACHET.BLUE_2]}); @override State createState() => ActivityCardState(); @@ -21,18 +19,14 @@ class ActivityCardState extends State { final betweenSpace = 2.4; - List> activitiesList = List.generate( - 7, (_) => List.generate(4, (index) => index, growable: false), - growable: false); + List> activitiesList = + List.generate(7, (_) => List.generate(4, (index) => index, growable: false), growable: false); @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]; + _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]; /// 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 @@ -125,10 +119,7 @@ class ActivityCardState extends State { padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.activity.walking'), - style: fs12fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey800), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], @@ -150,10 +141,7 @@ class ActivityCardState extends State { padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.activity.running'), - style: fs12fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey800), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], @@ -174,9 +162,7 @@ class ActivityCardState extends State { child: Text( locale.translate('cards.activity.cycling'), style: fs12fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey800, + color: Theme.of(context).extension()!.grey800, ), ), ), @@ -216,16 +202,12 @@ class ActivityCardState extends State { enabled: false, touchCallback: (p0, p1) { setState(() { - touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? - DateTime.now().weekday - 1) + - 1; + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; }); }, ), groupsSpace: 4, - barGroups: activitiesList - .map((e) => generateGroupData(e[0], e[1], e[2], e[3])) - .toList(), + barGroups: activitiesList.map((e) => generateGroupData(e[0], e[1], e[2], e[3])).toList(), maxY: (maxValue) * 1.2, gridData: FlGridData( show: true, @@ -301,9 +283,7 @@ class ActivityCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', style: fs14ls1.copyWith( color: Theme.of(context).extension()!.grey600, ), diff --git a/lib/ui/cards/anonymous_card.dart b/lib/ui/cards/anonymous_card.dart index c135d51a..d776831c 100644 --- a/lib/ui/cards/anonymous_card.dart +++ b/lib/ui/cards/anonymous_card.dart @@ -22,15 +22,13 @@ class AnonymousCard extends StatelessWidget { children: [ Expanded( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 22.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 22.0), child: Row( children: [ Column( children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6.0, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), child: CircleAvatar( radius: 18, backgroundColor: CACHET.ANONYMOUS, @@ -56,9 +54,7 @@ class AnonymousCard extends StatelessWidget { child: Text( locale.translate('pages.about.anonymous.message'), style: fs16fw600.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, fontSize: 14, ), ), diff --git a/lib/ui/cards/distance_card.dart b/lib/ui/cards/distance_card.dart index 857ad3ae..e5466e81 100644 --- a/lib/ui/cards/distance_card.dart +++ b/lib/ui/cards/distance_card.dart @@ -4,9 +4,7 @@ class DistanceCard extends StatefulWidget { final List colors; final MobilityCardViewModel model; - const DistanceCard(this.model, - {super.key, - this.colors = const [CACHET.BLUE_1, CACHET.BLUE_2, CACHET.BLUE_3]}); + const DistanceCard(this.model, {super.key, this.colors = const [CACHET.BLUE_1, CACHET.BLUE_2, CACHET.BLUE_3]}); @override State createState() => _DistanceCardState(); @@ -107,9 +105,7 @@ class _DistanceCardState extends State { enabled: false, touchCallback: (p0, p1) { setState(() { - touchedIndex = - (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + - 1; + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; }); }, ), @@ -138,9 +134,7 @@ class _DistanceCardState extends State { } List get barChartsGroups { - return widget.model.weekData.entries - .map((e) => generateGroupData(e.key, e.value.distance)) - .toList(); + return widget.model.weekData.entries.map((e) => generateGroupData(e.key, e.value.distance)).toList(); } BarChartGroupData generateGroupData(int x, double step) { @@ -171,9 +165,7 @@ class _DistanceCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', style: fs14ls1.copyWith( color: Theme.of(context).extension()!.grey600, ), diff --git a/lib/ui/cards/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index 570fc7de..5a6879e1 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -4,15 +4,13 @@ class HeartRateCardWidget extends StatefulWidget { final HeartRateCardViewModel model; const HeartRateCardWidget(this.model, {super.key}); - factory HeartRateCardWidget.withSampleData(HeartRateCardViewModel model) => - HeartRateCardWidget(model); + factory HeartRateCardWidget.withSampleData(HeartRateCardViewModel model) => HeartRateCardWidget(model); @override HeartRateCardWidgetState createState() => HeartRateCardWidgetState(); } -class HeartRateCardWidgetState extends State - with SingleTickerProviderStateMixin { +class HeartRateCardWidgetState extends State with SingleTickerProviderStateMixin { late AnimationController animationController; late Animation animation; @@ -83,18 +81,14 @@ class HeartRateCardWidgetState extends State Container( margin: const EdgeInsets.only(left: 8, right: 4, bottom: 4), child: Text( - min == null || max == null - ? '-' - : '${(min.toInt())} - ${(max.toInt())}', + min == null || max == null ? '-' : '${(min.toInt())} - ${(max.toInt())}', style: fs28fw700, ), ), Padding( padding: const EdgeInsets.only(bottom: 10), child: Text( - min == null || max == null - ? '' - : locale.translate('cards.heartrate.bpm'), + min == null || max == null ? '' : locale.translate('cards.heartrate.bpm'), style: fs10fw700.copyWith( fontSize: 12, color: Theme.of(context).extension()!.grey600, @@ -134,8 +128,7 @@ class HeartRateCardWidgetState extends State children: [ RepaintBoundary( child: ScaleTransition( - scale: Tween(begin: 1, end: 1) - .animate(animationController), + scale: Tween(begin: 1, end: 1).animate(animationController), child: Icon( Icons.favorite, color: CACHET.HEART_RATE_RED, @@ -146,8 +139,7 @@ class HeartRateCardWidgetState extends State Text( locale.translate('cards.heartrate.bpm'), style: fs10fw700.copyWith( - color: - Theme.of(context).extension()!.grey600, + color: Theme.of(context).extension()!.grey600, ), ), ], @@ -177,11 +169,9 @@ class HeartRateCardWidgetState extends State textAlign: TextAlign.start, children: [ TextSpan( - text: - locale.translate('cards.heartrate.range').toUpperCase(), + text: locale.translate('cards.heartrate.range').toUpperCase(), style: TextStyle( - color: - Theme.of(context).primaryTextTheme.bodySmall?.color, + color: Theme.of(context).primaryTextTheme.bodySmall?.color, fontWeight: FontWeight.bold, ), ), @@ -196,16 +186,14 @@ class HeartRateCardWidgetState extends State text: "${locale.translate('cards.heartrate.bpm')}\n", style: TextStyle( fontWeight: FontWeight.bold, - color: - Theme.of(context).primaryTextTheme.bodySmall?.color, + color: Theme.of(context).primaryTextTheme.bodySmall?.color, fontSize: 20, ), ), TextSpan( text: "$groupIndex-${groupIndex + 1} ", style: TextStyle( - color: - Theme.of(context).primaryTextTheme.bodySmall?.color, + color: Theme.of(context).primaryTextTheme.bodySmall?.color, fontWeight: FontWeight.bold, fontSize: 20, ), @@ -248,10 +236,8 @@ class HeartRateCardWidgetState extends State strokeWidth: 1, ), checkToShowHorizontalLine: (value) => value % 100 == 0, - getDrawingVerticalLine: (value) => FlLine( - color: Colors.grey.withValues(alpha: 0.2), - strokeWidth: 1, - dashArray: [3, 2]), + 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; @@ -308,9 +294,7 @@ class HeartRateCardWidgetState extends State meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', style: fs14ls1.copyWith( color: Theme.of(context).extension()!.grey600, ), @@ -319,20 +303,19 @@ class HeartRateCardWidgetState extends State ); } - 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(); + 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 { @@ -340,12 +323,10 @@ class HeartRateOuterStatefulWidget extends StatefulWidget { const HeartRateOuterStatefulWidget(this.model, {super.key}); @override - HeartRateOuterStatefulWidgetState createState() => - HeartRateOuterStatefulWidgetState(); + HeartRateOuterStatefulWidgetState createState() => HeartRateOuterStatefulWidgetState(); } -class HeartRateOuterStatefulWidgetState - extends State { +class HeartRateOuterStatefulWidgetState extends State { @override Widget build(BuildContext context) { return HeartRateCardWidget.withSampleData(widget.model); diff --git a/lib/ui/cards/media_card.dart b/lib/ui/cards/media_card.dart index c69d3c0f..a8c715e8 100644 --- a/lib/ui/cards/media_card.dart +++ b/lib/ui/cards/media_card.dart @@ -3,8 +3,7 @@ part of carp_study_app; class MediaCardWidget extends StatefulWidget { final List modelsList; final List colors; - const MediaCardWidget(this.modelsList, - {super.key, this.colors = CACHET.COLOR_LIST}); + const MediaCardWidget(this.modelsList, {super.key, this.colors = CACHET.COLOR_LIST}); @override MediaCardWidgetState createState() => MediaCardWidgetState(); } @@ -41,27 +40,20 @@ class MediaCardWidgetState extends State { .entries .map( (entry) => Column( - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 15), Text( '${entry.value.tasksDone} ${locale.translate('cards.${entry.value.taskType}.title')}', - style: - fs16fw400ls1.copyWith(fontSize: 14), + style: fs16fw400ls1.copyWith(fontSize: 14), ), - LayoutBuilder(builder: - (BuildContext context, - BoxConstraints constraints) { + LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { return HorizontalBar( parentWidth: constraints.maxWidth, names: entry.value.taskCount - .map((task) => locale - .translate(task.title)) - .toList(), - values: entry.value.taskCount - .map((task) => task.size) + .map((task) => locale.translate(task.title)) .toList(), + values: entry.value.taskCount.map((task) => task.size).toList(), colors: CACHET.COLOR_LIST, height: 18); }), diff --git a/lib/ui/cards/mobility_card.dart b/lib/ui/cards/mobility_card.dart index 20278945..b2362860 100644 --- a/lib/ui/cards/mobility_card.dart +++ b/lib/ui/cards/mobility_card.dart @@ -4,9 +4,7 @@ class MobilityCard extends StatefulWidget { final List colors; final MobilityCardViewModel model; - const MobilityCard(this.model, - {super.key, - this.colors = const [CACHET.CAQUI, CACHET.ORANGE, CACHET.BLUE_3]}); + const MobilityCard(this.model, {super.key, this.colors = const [CACHET.CAQUI, CACHET.ORANGE, CACHET.BLUE_3]}); @override State createState() => _MobilityCardState(); @@ -48,8 +46,7 @@ class _MobilityCardState extends State { child: Text( "${locale.translate('cards.mobility.homestay')} ${_getDayName(touchedIndex)}", style: fs12fw700.copyWith( - color: - Theme.of(context).extension()!.grey900!, + color: Theme.of(context).extension()!.grey900!, ), ), ), @@ -90,10 +87,7 @@ class _MobilityCardState extends State { padding: const EdgeInsets.all(4.0), child: Text( locale.translate('cards.mobility.places'), - style: fs12fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey800), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], @@ -131,9 +125,7 @@ class _MobilityCardState extends State { enabled: false, touchCallback: (p0, p1) { setState(() { - touchedIndex = - (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + - 1; + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; }); }, ), @@ -204,9 +196,7 @@ class _MobilityCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', style: fs14ls1.copyWith( color: Theme.of(context).extension()!.grey600, ), @@ -219,9 +209,7 @@ class _MobilityCardState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', style: fs14ls1.copyWith( color: Theme.of(context).extension()!.grey600, ), diff --git a/lib/ui/cards/scoreboard_card.dart b/lib/ui/cards/scoreboard_card.dart index bcdc706e..09486500 100644 --- a/lib/ui/cards/scoreboard_card.dart +++ b/lib/ui/cards/scoreboard_card.dart @@ -29,8 +29,7 @@ class ScoreboardCardState extends State { /// 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 { +class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { TaskListPageViewModel model; RPLocalizations locale; @override @@ -46,8 +45,7 @@ class ScoreboardPersistentHeaderDelegate }); @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { double height = 110; double offsetForShrink = 50; @@ -55,40 +53,34 @@ class ScoreboardPersistentHeaderDelegate List childrenDays = [ Text(model.daysInStudy.toString(), style: fs36fw800.copyWith( - fontSize: calculateScrollAwareSizing( - shrinkOffset, fs20fw800.fontSize!, fs36fw800.fontSize!), + 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)), + 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)), + 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!), + 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)), + 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)), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary)), ), ) ]; @@ -121,8 +113,7 @@ class ScoreboardPersistentHeaderDelegate Expanded( flex: 0, child: Container( - height: calculateScrollAwareSizing( - shrinkOffset, minExtent * 0.6, maxExtent * 0.6), + height: calculateScrollAwareSizing(shrinkOffset, minExtent * 0.6, maxExtent * 0.6), width: 2, decoration: BoxDecoration( color: Theme.of(context).dividerColor, @@ -150,8 +141,7 @@ class ScoreboardPersistentHeaderDelegate // 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) { + double calculateScrollAwareSizing(double shrinkOffset, double minSize, double maxSize) { // Calculate the normalized shrinkOffset value in the range [0, 1] double normalizedShrinkOffset = shrinkOffset / maxExtent; @@ -168,13 +158,11 @@ class ScoreboardPersistentHeaderDelegate } @override - FloatingHeaderSnapConfiguration get snapConfiguration => - FloatingHeaderSnapConfiguration( + FloatingHeaderSnapConfiguration get snapConfiguration => FloatingHeaderSnapConfiguration( curve: Curves.linear, duration: const Duration(milliseconds: 100), ); @override - OverScrollHeaderStretchConfiguration get stretchConfiguration => - OverScrollHeaderStretchConfiguration(); + OverScrollHeaderStretchConfiguration get stretchConfiguration => OverScrollHeaderStretchConfiguration(); } diff --git a/lib/ui/cards/steps_card.dart b/lib/ui/cards/steps_card.dart index ffb40e14..681096dc 100644 --- a/lib/ui/cards/steps_card.dart +++ b/lib/ui/cards/steps_card.dart @@ -4,9 +4,7 @@ class StepsCardWidget extends StatefulWidget { final List colors; final StepsCardViewModel model; - const StepsCardWidget(this.model, - {super.key, - this.colors = const [CACHET.ORANGE, CACHET.BLUE_2, CACHET.BLUE_3]}); + const StepsCardWidget(this.model, {super.key, this.colors = const [CACHET.ORANGE, CACHET.BLUE_2, CACHET.BLUE_3]}); @override StepsCardWidgetState createState() => StepsCardWidgetState(); @@ -105,9 +103,7 @@ class StepsCardWidgetState extends State { enabled: false, touchCallback: (p0, p1) { setState(() { - touchedIndex = - (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + - 1; + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; }); }, ), @@ -136,9 +132,7 @@ class StepsCardWidgetState extends State { } List get barChartsGroups { - return widget.model.weeklySteps.entries - .map((e) => generateGroupData(e.key, e.value)) - .toList(); + return widget.model.weeklySteps.entries.map((e) => generateGroupData(e.key, e.value)).toList(); } BarChartGroupData generateGroupData(int x, int step) { @@ -169,9 +163,7 @@ class StepsCardWidgetState extends State { meta: meta, space: 6, child: Text( - value.toInt() % meta.appliedInterval == 0 - ? value.toInt().toString() - : '', + value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', style: fs14ls1.copyWith( color: Theme.of(context).extension()!.grey600, ), diff --git a/lib/ui/cards/study_progress_card.dart b/lib/ui/cards/study_progress_card.dart index 800b6b46..268b0dea 100644 --- a/lib/ui/cards/study_progress_card.dart +++ b/lib/ui/cards/study_progress_card.dart @@ -5,8 +5,7 @@ class StudyProgressCardWidget extends StatefulWidget { final List colors; const StudyProgressCardWidget(this.model, - {super.key, - this.colors = const [CACHET.BLUE_1, CACHET.RED_1, CACHET.GREY_6]}); + {super.key, this.colors = const [CACHET.BLUE_1, CACHET.RED_1, CACHET.GREY_6]}); @override StudyProgressCardWidgetState createState() => StudyProgressCardWidgetState(); @@ -32,34 +31,29 @@ class StudyProgressCardWidgetState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - Text(locale.translate('cards.study_progress.title'), - style: fs16fw400ls1), + Text(locale.translate('cards.study_progress.title'), style: fs16fw400ls1), ], ), ), SizedBox( height: 130, child: LayoutBuilder( - builder: - (BuildContext context, BoxConstraints constraints) { + builder: (BuildContext context, BoxConstraints constraints) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), + 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), + padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( children: [ Text( - widget.model.progress[index].value - .toString(), + widget.model.progress[index].value.toString(), style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, @@ -68,8 +62,7 @@ class StudyProgressCardWidgetState extends State { ), const SizedBox(width: 4), Text( - locale.translate( - widget.model.progress[index].state), + locale.translate(widget.model.progress[index].state), style: const TextStyle(fontSize: 16), ), ], @@ -80,20 +73,15 @@ class StudyProgressCardWidgetState extends State { ), // Circular Progress Representation Padding( - padding: - const EdgeInsets.only(bottom: 18, right: 24.0), + 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(), + values: widget.model.progress.map((p) => p.value).toList(), colors: widget.colors, - faintColors: widget.colors - .map((c) => c.withValues(alpha: 0.2)) - .toList(), + faintColors: widget.colors.map((c) => c.withValues(alpha: 0.2)).toList(), ), ), ), @@ -118,8 +106,7 @@ class TaskProgressPainter extends CustomPainter { final List faintColors; final double pi = 3.141592; - TaskProgressPainter( - {required this.values, required this.colors, required this.faintColors}); + TaskProgressPainter({required this.values, required this.colors, required this.faintColors}); @override void paint(Canvas canvas, Size size) { diff --git a/lib/ui/cards/survey_card.dart b/lib/ui/cards/survey_card.dart index 579b1b1f..844985de 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -29,8 +29,7 @@ class _SurveyCardState extends State { children: [ Padding( padding: const EdgeInsets.only(left: 10.0), - child: Text(locale.translate('cards.survey.title').toUpperCase(), - style: fs16fw400ls1), + child: Text(locale.translate('cards.survey.title').toUpperCase(), style: fs16fw400ls1), ), SizedBox( height: 160, @@ -40,8 +39,7 @@ class _SurveyCardState extends State { Expanded( flex: 2, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: widget.model.tasksTable.entries.map((entry) { @@ -49,9 +47,7 @@ class _SurveyCardState extends State { width: 10, height: 10, decoration: BoxDecoration( - color: widget.colors[widget.model.tasksTable.keys - .toList() - .indexOf(entry.key)], + color: widget.colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], shape: BoxShape.circle, ), ); @@ -84,9 +80,7 @@ class _SurveyCardState extends State { Text( '$totalSurveys', style: fs24fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey800, + color: Theme.of(context).extension()!.grey800, ), ) ], @@ -105,8 +99,7 @@ class _SurveyCardState extends State { (entry) { return PieChartSectionData( // Color should be the next color in the list - color: widget - .colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], + color: widget.colors[widget.model.tasksTable.keys.toList().indexOf(entry.key)], value: entry.value.toDouble(), title: '${entry.value}', showTitle: false, diff --git a/lib/ui/carp_study_style.dart b/lib/ui/carp_study_style.dart index 0f002429..632c5b71 100644 --- a/lib/ui/carp_study_style.dart +++ b/lib/ui/carp_study_style.dart @@ -151,10 +151,10 @@ ThemeData carpStudyTheme = ThemeData.light().copyWith( fontWeight: FontWeight.w400, fontSize: 16.0, ), - titleMedium: ThemeData.light().textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.w600, - fontSize: 20.0, - color: const Color(0xFF206FA2)), + titleMedium: ThemeData.light() + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.w600, fontSize: 20.0, color: const Color(0xFF206FA2)), titleLarge: ThemeData.light().textTheme.titleLarge!.copyWith( fontWeight: FontWeight.w500, fontSize: 20.0, @@ -163,8 +163,10 @@ ThemeData carpStudyTheme = ThemeData.light().copyWith( fontWeight: FontWeight.w700, fontSize: 30.0, ), - labelLarge: ThemeData.light().textTheme.labelLarge!.copyWith( - fontWeight: FontWeight.w500, fontSize: 16.0, color: Colors.white), + labelLarge: ThemeData.light() + .textTheme + .labelLarge! + .copyWith(fontWeight: FontWeight.w500, fontSize: 16.0, color: Colors.white), ) .apply( fontFamily: 'OpenSans', @@ -234,10 +236,10 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( fontWeight: FontWeight.w700, fontSize: 30.0, ), - labelLarge: ThemeData.dark().textTheme.labelLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 16.0, - color: Colors.grey.shade800), + labelLarge: ThemeData.dark() + .textTheme + .labelLarge! + .copyWith(fontWeight: FontWeight.w500, fontSize: 16.0, color: Colors.grey.shade800), ) .apply( fontFamily: 'OpenSans', diff --git a/lib/ui/colors.dart b/lib/ui/colors.dart index 4e04adb8..877216aa 100644 --- a/lib/ui/colors.dart +++ b/lib/ui/colors.dart @@ -57,8 +57,7 @@ class CACHET { static const Color HEART_RATE_RED = Color.fromRGBO(235, 75, 98, 1.0); - static Color pie = - createMaterialColor(const Color.fromRGBO(225, 244, 250, 1)); + static Color pie = createMaterialColor(const Color.fromRGBO(225, 244, 250, 1)); static const List COLOR_LIST = [ Color(0xFF7FC9E3), diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart index 373b3ca6..73192d16 100644 --- a/lib/ui/pages/data_visualization_page.dart +++ b/lib/ui/pages/data_visualization_page.dart @@ -15,16 +15,14 @@ class _DataVisualizationPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + 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), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Container( @@ -39,9 +37,7 @@ class _DataVisualizationPageState extends State { children: [ Text(locale.translate('pages.data_viz.title'), style: fs24fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold, )), ], @@ -57,13 +53,10 @@ class _DataVisualizationPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: 15, vertical: 24.0), + 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, + color: Theme.of(context).extension()!.grey600, )), ), ..._dataVizCards, @@ -81,15 +74,12 @@ class _DataVisualizationPageState extends State { // Show user task progress, if study has any tasks. if (bloc.hasUserTasks()) { - widgets.add( - StudyProgressCardWidget(widget.model.studyProgressCardDataModel)); + widgets.add(StudyProgressCardWidget(widget.model.studyProgressCardDataModel)); } // Show HR if there is a POLAR or MOVESENSE device in the study - if (bloc.hasMeasure(PolarSamplingPackage.HR) || - bloc.hasMeasure(MovesenseSamplingPackage.HR)) { - widgets.add( - HeartRateOuterStatefulWidget(widget.model.heartRateCardDataModel)); + if (bloc.hasMeasure(PolarSamplingPackage.HR) || bloc.hasMeasure(MovesenseSamplingPackage.HR)) { + widgets.add(HeartRateOuterStatefulWidget(widget.model.heartRateCardDataModel)); } // check to show surveys stats diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 9e563688..e28894ac 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -16,19 +16,16 @@ class DeviceListPageState extends State { StreamSubscription? bluetoothStateStream; BluetoothAdapterState? bluetoothAdapterState; - final List _smartphoneDevice = bloc.deploymentDevices - .where((element) => element.deviceManager is SmartphoneDeviceManager) - .toList(); + final List _smartphoneDevice = + bloc.deploymentDevices.where((element) => element.deviceManager is SmartphoneDeviceManager).toList(); final List _hardwareDevices = bloc.deploymentDevices .where((element) => - element.deviceManager is HardwareDeviceManager && - element.deviceManager is! SmartphoneDeviceManager) + element.deviceManager is HardwareDeviceManager && element.deviceManager is! SmartphoneDeviceManager) .toList(); - final List _onlineServices = bloc.deploymentDevices - .where((element) => element.deviceManager is OnlineServiceManager) - .toList(); + final List _onlineServices = + bloc.deploymentDevices.where((element) => element.deviceManager is OnlineServiceManager).toList(); @override void initState() { @@ -49,16 +46,14 @@ class DeviceListPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + 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), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Container( @@ -74,9 +69,7 @@ class DeviceListPageState extends State { Text( locale.translate('pages.devices.title'), style: fs24fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold, ), ), @@ -97,9 +90,7 @@ class DeviceListPageState extends State { children: [ Text(locale.translate("pages.devices.message"), style: fs16fw600.copyWith( - color: Theme.of(context) - .extension()! - .grey600, + color: Theme.of(context).extension()!.grey600, )), const SizedBox(height: 15), ], @@ -112,10 +103,8 @@ class DeviceListPageState extends State { child: CustomScrollView( slivers: [ ..._smartphoneDeviceList(locale), - if (_hardwareDevices.isNotEmpty) - ..._hardwareDevicesList(locale), - if (_onlineServices.isNotEmpty) - ..._onlineServicesList(locale), + if (_hardwareDevices.isNotEmpty) ..._hardwareDevicesList(locale), + if (_onlineServices.isNotEmpty) ..._onlineServicesList(locale), ], ), ), @@ -135,8 +124,7 @@ class DeviceListPageState extends State { listenable: _smartphoneDevice[index], builder: (BuildContext context, Widget? widget) => Center( child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: _cardListBuilder( leading: _smartphoneDevice[index].icon!, title: ( @@ -167,23 +155,16 @@ class DeviceListPageState extends State { () => _cardListBuilder( enableFeedback: true, leading: device.icon!, - title: ( - locale.translate(device.typeName), - device.batteryLevel ?? 0 - ), + title: (locale.translate(device.typeName), device.batteryLevel ?? 0), subtitle: device.name, onTap: () async => await _hardwareDeviceClicked(device), trailing: device.getDeviceStatusIcon is Icon ? device.getDeviceStatusIcon as Icon : Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: CACHET.DEPLOYMENT_DEPLOYING, - borderRadius: BorderRadius.circular(100)), - child: Text( - locale.translate( - device.getDeviceStatusIcon as String), + color: CACHET.DEPLOYMENT_DEPLOYING, borderRadius: BorderRadius.circular(100)), + child: Text(locale.translate(device.getDeviceStatusIcon as String), style: fs20fw700.copyWith(color: Colors.white)), ), ), @@ -211,14 +192,11 @@ class DeviceListPageState extends State { onTap: () async => await _onlineServiceClicked(service), trailing: service.getServiceStatusIcon is String ? Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: CACHET.DEPLOYMENT_DEPLOYING, - borderRadius: BorderRadius.circular(100)), + color: CACHET.DEPLOYMENT_DEPLOYING, borderRadius: BorderRadius.circular(100)), child: Text( - locale.translate( - service.getServiceStatusIcon as String), + locale.translate(service.getServiceStatusIcon as String), style: fs20fw700.copyWith(color: Colors.white), ), ) @@ -260,8 +238,7 @@ class DeviceListPageState extends State { ), ), SizedBox(width: 6), - if (title.$2 != null && title.$2! > 0) - BatteryPercentage(batteryLevel: title.$2 ?? 0), + if (title.$2 != null && title.$2! > 0) BatteryPercentage(batteryLevel: title.$2 ?? 0), ], ), ), @@ -276,8 +253,7 @@ class DeviceListPageState extends State { child: Text( subtitle, style: fs12fw700.copyWith( - color: - Theme.of(context).extension()!.grey700, + color: Theme.of(context).extension()!.grey700, ), ), ), @@ -311,8 +287,7 @@ class DeviceListPageState extends State { ); Future _onlineServiceClicked(DeviceViewModel service) async { - if (service.status == DeviceStatus.connected || - service.status == DeviceStatus.connecting) { + if (service.status == DeviceStatus.connected || service.status == DeviceStatus.connecting) { return; } @@ -320,8 +295,7 @@ class DeviceListPageState extends State { if (service.type == HealthService.DEVICE_TYPE) { Navigator.push( context, - MaterialPageRoute( - builder: (context) => HealthServiceConnectPage()), + MaterialPageRoute(builder: (context) => HealthServiceConnectPage()), ); } else { await service.deviceManager.requestPermissions(); @@ -338,16 +312,14 @@ class DeviceListPageState extends State { if (Platform.isAndroid) await FlutterBluePlus.turnOn(); if (context.mounted) { - if (bluetoothAdapterState == BluetoothAdapterState.off && - Platform.isIOS) { + if (bluetoothAdapterState == BluetoothAdapterState.off && Platform.isIOS) { await showDialog( context: context, barrierDismissible: true, builder: (context) => EnableBluetoothDialog(device: device), ); } else if (bluetoothAdapterState == BluetoothAdapterState.on) { - if (device.status == DeviceStatus.connected || - device.status == DeviceStatus.connecting) { + if (device.status == DeviceStatus.connected || device.status == DeviceStatus.connecting) { bool disconnect = await showDialog( context: context, barrierDismissible: true, @@ -356,22 +328,18 @@ class DeviceListPageState extends State { false; if (disconnect) await device.disconnectFromDevice(); } else { - final hasSeenInstructions = - LocalSettings().hasSeenBluetoothConnectionInstructions; + final hasSeenInstructions = LocalSettings().hasSeenBluetoothConnectionInstructions; Navigator.push( context, MaterialPageRoute( builder: (context) => BluetoothConnectionPage( - hasSeenInstructions - ? CurrentStep.scan - : CurrentStep.instructions, + hasSeenInstructions ? CurrentStep.scan : CurrentStep.instructions, device: device, ), ), ); } - } else if (bluetoothAdapterState == BluetoothAdapterState.unauthorized && - Platform.isIOS) { + } else if (bluetoothAdapterState == BluetoothAdapterState.unauthorized && Platform.isIOS) { await showDialog( context: context, barrierDismissible: true, diff --git a/lib/ui/pages/devices_page.authorization_dialog.dart b/lib/ui/pages/devices_page.authorization_dialog.dart index f7a6f370..5c5781fb 100644 --- a/lib/ui/pages/devices_page.authorization_dialog.dart +++ b/lib/ui/pages/devices_page.authorization_dialog.dart @@ -20,8 +20,7 @@ class AuthorizationDialog extends StatelessWidget { )); } - Widget authorizationInstructions( - BuildContext context, DeviceViewModel device) { + Widget authorizationInstructions(BuildContext context, DeviceViewModel device) { RPLocalizations locale = RPLocalizations.of(context)!; return Column( children: [ @@ -30,8 +29,7 @@ class AuthorizationDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.devices.connection.bluetooth_authorization.message"), + locale.translate("pages.devices.connection.bluetooth_authorization.message"), style: fs16fw400, textAlign: TextAlign.justify, ), @@ -55,8 +53,7 @@ class AuthorizationDialog extends StatelessWidget { }, ), TextButton( - child: - Text(locale.translate("pages.devices.connection.settings")), + child: Text(locale.translate("pages.devices.connection.settings")), onPressed: () => OpenSettingsPlusIOS().bluetooth(), ), ], diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 30cf2389..b74efd71 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -6,15 +6,13 @@ enum CurrentStep { scan, instructions, done } class BluetoothConnectionPage extends StatefulWidget { final DeviceViewModel device; - const BluetoothConnectionPage(CurrentStep currentStep, - {super.key, required this.device}) + const BluetoothConnectionPage(CurrentStep currentStep, {super.key, required this.device}) : _currentStep = currentStep; final CurrentStep _currentStep; @override - State createState() => - _BluetoothConnectionPageState(_currentStep); + State createState() => _BluetoothConnectionPageState(_currentStep); } class _BluetoothConnectionPageState extends State { @@ -46,8 +44,7 @@ class _BluetoothConnectionPageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Stack( children: [ @@ -55,8 +52,7 @@ class _BluetoothConnectionPageState extends State { child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16), child: const CarpAppBar(hasProfileIcon: true), ), Expanded( @@ -74,8 +70,7 @@ class _BluetoothConnectionPageState extends State { ), ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _buildActionButtons(locale), @@ -103,13 +98,10 @@ class _BluetoothConnectionPageState extends State { Widget _buildDialogTitle(RPLocalizations locale) { final stepTitleMap = { - CurrentStep.scan: - locale.translate("pages.devices.connection.step.start.title"), - CurrentStep.instructions: - locale.translate("pages.devices.connection.step.how_to.title"), + CurrentStep.scan: locale.translate("pages.devices.connection.step.start.title"), + CurrentStep.instructions: locale.translate("pages.devices.connection.step.how_to.title"), CurrentStep.done: - locale.translate("pages.devices.connection.step.confirm.title") + - (" ${selectedDevice?.platformName} "), + locale.translate("pages.devices.connection.step.confirm.title") + (" ${selectedDevice?.platformName} "), }; return Padding( padding: const EdgeInsets.only(bottom: 16), @@ -144,8 +136,8 @@ class _BluetoothConnectionPageState extends State { } List _buildActionButtons(RPLocalizations locale) { - Widget buildTranslatedButton(String key, VoidCallback onPressed, - bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { + Widget buildTranslatedButton( + String key, VoidCallback onPressed, bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { return ElevatedButton( onPressed: enabled ? onPressed : null, child: Text( @@ -176,9 +168,7 @@ class _BluetoothConnectionPageState extends State { ], CurrentStep.instructions: [ buildTranslatedButton("settings", () { - Platform.isAndroid - ? OpenSettingsPlusAndroid().bluetooth() - : OpenSettingsPlusIOS().bluetooth(); + Platform.isAndroid ? OpenSettingsPlusAndroid().bluetooth() : OpenSettingsPlusIOS().bluetooth(); }, true, null, null), buildTranslatedButton( "ok", @@ -260,10 +250,8 @@ class _BluetoothConnectionPageState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: Text(locale.translate( - "pages.devices.connection.connection_failed.title")), - content: Text(locale.translate( - "pages.devices.connection.connection_failed.message")), + title: Text(locale.translate("pages.devices.connection.connection_failed.title")), + content: Text(locale.translate("pages.devices.connection.connection_failed.message")), actions: [ TextButton( onPressed: () { @@ -319,17 +307,14 @@ class _BluetoothConnectionPageState extends State { padding: const EdgeInsets.only(top: 16), child: Column( children: snapshot.data! - .where( - (element) => element.device.platformName.isNotEmpty) + .where((element) => element.device.platformName.isNotEmpty) .toList() .asMap() .entries .map( (bluetoothDevice) => StudiesMaterial( // hasBorder: true, - backgroundColor: Theme.of(context) - .extension()! - .grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, child: InkWell( child: ListTile( selected: bluetoothDevice.key == selected, @@ -339,9 +324,7 @@ class _BluetoothConnectionPageState extends State { fontSize: 20, ), ), - selectedTileColor: Theme.of(context) - .primaryColor - .withValues(alpha: 0.2), + selectedTileColor: Theme.of(context).primaryColor.withValues(alpha: 0.2), ), onTap: () { selectedDevice = bluetoothDevice.value.device; @@ -364,12 +347,10 @@ class _BluetoothConnectionPageState extends State { TextSpan( children: [ TextSpan( - text: locale - .translate("pages.devices.connection.step.start.1"), + text: locale.translate("pages.devices.connection.step.start.1"), ), TextSpan( - text: locale - .translate("pages.devices.connection.instructions"), + text: locale.translate("pages.devices.connection.instructions"), style: TextStyle( color: Theme.of(context).extension()!.primary, decoration: TextDecoration.underline, @@ -387,8 +368,7 @@ class _BluetoothConnectionPageState extends State { ), ], ), - style: fs22fw700.copyWith( - color: Theme.of(context).extension()!.grey900), + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.center, ), ) @@ -404,28 +384,22 @@ class _BluetoothConnectionPageState extends State { switch (device.deviceManager) { case PolarDeviceManager _ when device.type == PolarDevice.DEVICE_TYPE && - (device.polarDeviceType == PolarDeviceType.H10 || - device.polarDeviceType == PolarDeviceType.H9): - assetImage = - AssetImage('assets/instructions/polar_h9_h10_instructions.png'); + (device.polarDeviceType == PolarDeviceType.H10 || device.polarDeviceType == PolarDeviceType.H9): + assetImage = AssetImage('assets/instructions/polar_h9_h10_instructions.png'); break; case PolarDeviceManager _ - when device.type == PolarDevice.DEVICE_TYPE && - device.polarDeviceType == PolarDeviceType.SENSE: - assetImage = - AssetImage('assets/instructions/polar_sense_instructions.png'); + when device.type == PolarDevice.DEVICE_TYPE && device.polarDeviceType == PolarDeviceType.SENSE: + assetImage = AssetImage('assets/instructions/polar_sense_instructions.png'); break; // if device type is not defined in the protocol, show h9, h10 instructions case PolarDeviceManager _: - assetImage = - AssetImage('assets/instructions/polar_h9_h10_instructions.png'); + assetImage = AssetImage('assets/instructions/polar_h9_h10_instructions.png'); break; case MovesenseDeviceManager _: - assetImage = - AssetImage('assets/instructions/movesense_instructions.png'); + assetImage = AssetImage('assets/instructions/movesense_instructions.png'); break; default: diff --git a/lib/ui/pages/devices_page.disconnection_dialog.dart b/lib/ui/pages/devices_page.disconnection_dialog.dart index 0390df0e..90ab9c7d 100644 --- a/lib/ui/pages/devices_page.disconnection_dialog.dart +++ b/lib/ui/pages/devices_page.disconnection_dialog.dart @@ -45,8 +45,7 @@ class DisconnectionDialog extends StatelessWidget { if (context.canPop()) context.pop(true); }, child: Text( - locale.translate( - "pages.devices.connection.disconnect_bluetooth.disconnect"), + locale.translate("pages.devices.connection.disconnect_bluetooth.disconnect"), ), ), ], diff --git a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart index 72944dfc..93390b82 100644 --- a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart +++ b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart @@ -11,16 +11,14 @@ class EnableBluetoothDialog extends StatelessWidget { scrollable: true, titlePadding: const EdgeInsets.symmetric(vertical: 4), insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.devices.connection.enable_bluetooth.title"), + title: const DialogTitle(title: "pages.devices.connection.enable_bluetooth.title"), content: SizedBox( height: MediaQuery.of(context).size.height * 0.45, child: enableBluetoothInstructions(context, device), )); } - Widget enableBluetoothInstructions( - BuildContext context, DeviceViewModel device) { + Widget enableBluetoothInstructions(BuildContext context, DeviceViewModel device) { RPLocalizations locale = RPLocalizations.of(context)!; return Column( children: [ @@ -29,8 +27,7 @@ class EnableBluetoothDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.devices.connection.enable_bluetooth.message1"), + locale.translate("pages.devices.connection.enable_bluetooth.message1"), style: fs16fw400, textAlign: TextAlign.justify, ), @@ -38,8 +35,7 @@ class EnableBluetoothDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.0), ), Text( - locale.translate( - "pages.devices.connection.enable_bluetooth.message2"), + locale.translate("pages.devices.connection.enable_bluetooth.message2"), style: fs16fw400, textAlign: TextAlign.justify, ), @@ -52,8 +48,7 @@ class EnableBluetoothDialog extends StatelessWidget { ), ), Text( - locale.translate( - "pages.devices.connection.enable_bluetooth.message3"), + locale.translate("pages.devices.connection.enable_bluetooth.message3"), style: fs16fw400, textAlign: TextAlign.justify, ), diff --git a/lib/ui/pages/devices_page.health_service_connect.dart b/lib/ui/pages/devices_page.health_service_connect.dart index 0a35387b..214f486c 100644 --- a/lib/ui/pages/devices_page.health_service_connect.dart +++ b/lib/ui/pages/devices_page.health_service_connect.dart @@ -8,9 +8,7 @@ class HealthServiceConnectPage extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; DeviceViewModel healthServive = bloc.deploymentDevices - .where((element) => - element.deviceManager is OnlineServiceManager && - element.type == HealthService.DEVICE_TYPE) + .where((element) => element.deviceManager is OnlineServiceManager && element.type == HealthService.DEVICE_TYPE) .first; return Scaffold( @@ -20,8 +18,7 @@ class HealthServiceConnectPage extends StatelessWidget { child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), child: const CarpAppBar(), ), Expanded( @@ -46,51 +43,36 @@ class HealthServiceConnectPage extends StatelessWidget { TextSpan( children: [ TextSpan( - text: - "${locale.translate("pages.devices.type.health.instructions.page2.part1")} ", + text: "${locale.translate("pages.devices.type.health.instructions.page2.part1")} ", style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, ), ), TextSpan( text: "${Platform.isAndroid ? locale.translate("pages.devices.type.health.instructions.page2.android.allow_all") : locale.translate("pages.devices.type.health.instructions.page2.ios.turn_on_all")} ", style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .primary, // Change to desired color + color: Theme.of(context).extension()!.primary, // Change to desired color ), ), TextSpan( - text: - "${locale.translate("pages.devices.type.health.instructions.page2.part2")} ", + text: "${locale.translate("pages.devices.type.health.instructions.page2.part2")} ", style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, ), ), TextSpan( - text: - "${locale.translate("pages.devices.type.health.instructions.page2.allow")} ", + text: "${locale.translate("pages.devices.type.health.instructions.page2.allow")} ", style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .primary, // Change to desired color + color: Theme.of(context).extension()!.primary, // Change to desired color ), ), TextSpan( text: Platform.isAndroid - ? locale.translate( - "pages.devices.type.health.instructions.page2.part3.android") - : locale.translate( - "pages.devices.type.health.instructions.page2.part3.ios"), + ? locale.translate("pages.devices.type.health.instructions.page2.part3.android") + : locale.translate("pages.devices.type.health.instructions.page2.part3.ios"), style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, ), ), ], @@ -125,10 +107,8 @@ class HealthServiceConnectPage extends StatelessWidget { ), ), style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).extension()!.primary, - padding: - const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), onPressed: () async { await healthServive.deviceManager.requestPermissions(); diff --git a/lib/ui/pages/devices_page.list_title.dart b/lib/ui/pages/devices_page.list_title.dart index 19f57309..97d3fd73 100644 --- a/lib/ui/pages/devices_page.list_title.dart +++ b/lib/ui/pages/devices_page.list_title.dart @@ -21,11 +21,9 @@ class DevicesPageListTitle extends StatelessWidget { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), - child: Text( - locale.translate("pages.devices.${type.name}.title").toUpperCase(), + child: Text(locale.translate("pages.devices.${type.name}.title").toUpperCase(), style: fs16fw400ls1.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold)), + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold)), ), ); } diff --git a/lib/ui/pages/enable_connection_dialog.dart b/lib/ui/pages/enable_connection_dialog.dart index cf5c7776..e8d7f656 100644 --- a/lib/ui/pages/enable_connection_dialog.dart +++ b/lib/ui/pages/enable_connection_dialog.dart @@ -9,9 +9,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { scrollable: true, titlePadding: const EdgeInsets.symmetric(vertical: 4), insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: DialogTitle( - title: - "pages.login.internet_connection.enable_internet_connections.title"), + title: DialogTitle(title: "pages.login.internet_connection.enable_internet_connections.title"), content: SizedBox( height: MediaQuery.of(context).size.height * 0.45, child: (() { @@ -35,16 +33,14 @@ class EnableInternetConnectionDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.general_message"), + locale.translate("pages.login.internet_connection.enable_internet_connections.general_message"), style: fs16fw400, textAlign: TextAlign.justify, ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.wifi_message"), + locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), style: fs16fw400, textAlign: TextAlign.justify, )), @@ -58,8 +54,7 @@ class EnableInternetConnectionDialog extends StatelessWidget { Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.mobile_data_message"), + locale.translate("pages.login.internet_connection.enable_internet_connections.mobile_data_message"), style: fs16fw400, textAlign: TextAlign.justify, ), @@ -109,16 +104,14 @@ class EnableInternetConnectionDialog extends StatelessWidget { child: Column( children: [ Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.general_message"), + locale.translate("pages.login.internet_connection.enable_internet_connections.general_message"), style: fs16fw400, textAlign: TextAlign.justify, ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.wifi_message"), + locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), style: fs16fw400, textAlign: TextAlign.justify, )), @@ -133,8 +126,8 @@ class EnableInternetConnectionDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.0), child: Column(children: [ Text( - locale.translate( - "pages.login.internet_connection.enable_internet_connections.mobile_data_message"), + locale + .translate("pages.login.internet_connection.enable_internet_connections.mobile_data_message"), style: fs16fw400, textAlign: TextAlign.justify, ), diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 7dc95954..3b4f7a7a 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -98,8 +98,7 @@ class HomePageState extends State { }); return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: widget.child, ), diff --git a/lib/ui/pages/home_page.install_health_connect_dialog.dart b/lib/ui/pages/home_page.install_health_connect_dialog.dart index 2129ece0..7f6e6ea7 100644 --- a/lib/ui/pages/home_page.install_health_connect_dialog.dart +++ b/lib/ui/pages/home_page.install_health_connect_dialog.dart @@ -34,8 +34,8 @@ class InstallHealthConnectDialog extends StatelessWidget { } void _redirectToHealthConnectPlayStore() async { - final Uri url = Uri.parse( - 'https://play.google.com/store/apps/details?id=${LocalSettings.healthConnectPackageName}'); + final Uri url = + Uri.parse('https://play.google.com/store/apps/details?id=${LocalSettings.healthConnectPackageName}'); var canLaunch = await canLaunchUrl(url); if (canLaunch) { await launchUrl(url); diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index 547d52fc..bfe9c761 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -30,8 +30,7 @@ class _InvitationListPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: RefreshIndicator( onRefresh: _refresh, child: FutureBuilder>( @@ -64,8 +63,7 @@ class _InvitationListPageState extends State { physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverAppBar( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, title: const CarpAppBar(), centerTitle: true, pinned: true, @@ -103,8 +101,7 @@ class _InvitationListPageState extends State { ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only( - bottom: 8.0, left: 16.0, right: 16.0), + padding: const EdgeInsets.only(bottom: 8.0, left: 16.0, right: 16.0), child: Container( padding: EdgeInsets.all(10.0), child: Text( @@ -146,8 +143,7 @@ class InvitationMaterial extends StatelessWidget { ), child: InkWell( onTap: () { - context.push( - '${InvitationDetailsPage.route}/${invitation.participation.participantId}'); + context.push('${InvitationDetailsPage.route}/${invitation.participation.participantId}'); }, child: Padding( padding: const EdgeInsets.all(16.0), @@ -157,27 +153,22 @@ class InvitationMaterial extends StatelessWidget { Text( invitation.invitation.name, maxLines: 1, - style: fs24fw600.copyWith( - color: CACHET.TASK_COMPLETED_BLUE, - overflow: TextOverflow.ellipsis), + style: fs24fw600.copyWith(color: CACHET.TASK_COMPLETED_BLUE, overflow: TextOverflow.ellipsis), ), Text.rich( TextSpan( children: [ TextSpan( - text: locale.translate( - 'invitation_list.roles_in_the_study.description'), + text: locale.translate('invitation_list.roles_in_the_study.description'), style: fs16fw700.copyWith( - color: - Theme.of(context).extension()!.grey600, + color: Theme.of(context).extension()!.grey600, fontSize: 12, ), ), TextSpan( text: invitation.participantRoleName, style: fs16fw700.copyWith( - color: - Theme.of(context).extension()!.grey600, + color: Theme.of(context).extension()!.grey600, fontSize: 12, ), ), diff --git a/lib/ui/pages/invitation_page.dart b/lib/ui/pages/invitation_page.dart index 217123a1..bda254e7 100644 --- a/lib/ui/pages/invitation_page.dart +++ b/lib/ui/pages/invitation_page.dart @@ -17,8 +17,7 @@ class InvitationDetailsPage extends StatelessWidget { var invitation = model.getInvitation(invitationId); return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: SafeArea( @@ -59,8 +58,7 @@ class InvitationDetailsPage extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), @@ -70,8 +68,7 @@ class InvitationDetailsPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - locale - .translate('invitation.roles_in_the_study.title'), + locale.translate('invitation.roles_in_the_study.title'), style: TextStyle( fontWeight: FontWeight.bold, fontSize: 20.0, @@ -93,8 +90,7 @@ class InvitationDetailsPage extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.white!, + backgroundColor: Theme.of(context).extension()!.white!, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), @@ -118,14 +114,11 @@ class InvitationDetailsPage extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, fontSize: 22.0, - color: Theme.of(context) - .extension()! - .primary, + color: Theme.of(context).extension()!.primary, ), ), Padding( - padding: const EdgeInsets.only( - top: 8, bottom: 24), + padding: const EdgeInsets.only(top: 8, bottom: 24), child: FittedBox( fit: BoxFit.scaleDown, child: Text( @@ -133,9 +126,7 @@ class InvitationDetailsPage extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14, - color: Theme.of(context) - .extension()! - .grey600, + color: Theme.of(context).extension()!.grey600, ), maxLines: 1, textScaler: TextScaler.linear(0.9), @@ -144,9 +135,7 @@ class InvitationDetailsPage extends StatelessWidget { ), Text( invitation.invitation.description ?? '', - style: const TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold), + style: const TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold), ), ], ), @@ -173,8 +162,7 @@ class InvitationDetailsPage extends StatelessWidget { }, child: Text( locale.translate("invitation.accept_invite"), - style: - const TextStyle(color: Color(0xffffffff), fontSize: 22), + style: const TextStyle(color: Color(0xffffffff), fontSize: 22), textAlign: TextAlign.center, ), ), diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 912b8c2d..8a8c6b45 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -13,8 +13,7 @@ class MessageDetailsPage extends StatelessWidget { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - Message message = bloc.messages - .firstWhere((element) => element.id == messageId, orElse: () { + Message message = bloc.messages.firstWhere((element) => element.id == messageId, orElse: () { return Message( id: '0', title: 'Unknown message', @@ -31,15 +30,13 @@ class MessageDetailsPage extends StatelessWidget { child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), child: const CarpAppBar(hasProfileIcon: true), ), Row( children: [ IconButton( - padding: const EdgeInsets.only( - left: 26, right: 10, top: 16, bottom: 16), + padding: const EdgeInsets.only(left: 26, right: 10, top: 16, bottom: 16), icon: Icon( Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600, @@ -55,10 +52,7 @@ class MessageDetailsPage extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), child: Text(locale.translate(message.title!), - style: fs20fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900)), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.grey900)), ), Spacer(), Padding( @@ -67,14 +61,8 @@ class MessageDetailsPage extends StatelessWidget { color: Theme.of(context).extension()!.primary, borderRadius: BorderRadius.circular(100.0), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, vertical: 6.0), - child: Text( - locale.translate(message.type - .toString() - .split('.') - .last - .toLowerCase()), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + child: Text(locale.translate(message.type.toString().split('.').last.toLowerCase()), style: fs16fw600.copyWith(color: Colors.white)), ), ), @@ -83,18 +71,13 @@ class MessageDetailsPage extends StatelessWidget { ), Flexible( child: ListView( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ message.subTitle != null ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, vertical: 6.0), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), child: Text(locale.translate(message.subTitle!), - style: fs16fw400.copyWith( - color: Theme.of(context) - .extension()! - .grey700)), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700)), ) : const SizedBox.shrink(), if (message.image != null && message.image!.isNotEmpty) @@ -108,24 +91,19 @@ class MessageDetailsPage extends StatelessWidget { ), child: FittedBox( fit: BoxFit.contain, - child: bloc.appViewModel.studyPageViewModel - .getMessageImage(message.image)), + child: bloc.appViewModel.studyPageViewModel.getMessageImage(message.image)), ); }), // DetailsBanner(message.title ?? '', message.image), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (message.message != null) Text( locale.translate(message.message!), - style: fs16fw400.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.justify, ) ], diff --git a/lib/ui/pages/process_message_page.dart b/lib/ui/pages/process_message_page.dart index d9b4b131..cca49cc4 100644 --- a/lib/ui/pages/process_message_page.dart +++ b/lib/ui/pages/process_message_page.dart @@ -41,18 +41,15 @@ class ProcessMessagePage extends StatelessWidget { switch (statusType) { case ProcessStatus.done: image = Image( - image: const AssetImage('assets/icons/done.png'), - height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/done.png'), height: MediaQuery.of(context).size.height * 0.35); break; case ProcessStatus.error: image = Image( - image: const AssetImage('assets/icons/error.png'), - height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/error.png'), height: MediaQuery.of(context).size.height * 0.35); break; case ProcessStatus.other: image = Image( - image: const AssetImage('assets/icons/info.png'), - height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/info.png'), height: MediaQuery.of(context).size.height * 0.35); break; } @@ -88,8 +85,7 @@ class ProcessMessagePage extends StatelessWidget { Navigator.of(context).pop(); }, child: Text(locale.translate('cancel').toUpperCase(), - style: TextStyle( - color: Theme.of(context).primaryColor))), + style: TextStyle(color: Theme.of(context).primaryColor))), const SizedBox(width: 10), ], ) @@ -98,8 +94,7 @@ class ProcessMessagePage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor), + style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor), onPressed: () { actionFunction(); }, diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 4b390de4..9d438717 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -40,15 +40,12 @@ class ProfilePageState extends State { children: [ TextButton.icon( onPressed: () {}, - icon: Icon(Icons.account_circle, - color: Theme.of(context).primaryColor, size: 30), + 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)), + style: fs20fw700.copyWith(color: Theme.of(context).primaryColor)), ), IconButton( - icon: Icon(Icons.close, - color: Theme.of(context).primaryColor, size: 30), + icon: Icon(Icons.close, color: Theme.of(context).primaryColor, size: 30), tooltip: locale.translate('Back'), onPressed: () { Navigator.of(context).pop(); @@ -78,15 +75,13 @@ class ProfilePageState extends State { _buildListTile( locale.translate('pages.profile.full_name'), LocalSettings().isAnonymous - ? locale - .translate('pages.about.anonymous.anonymous') + ? locale.translate('pages.about.anonymous.anonymous') : widget.model.fullName, ), _buildListTile( locale.translate('pages.profile.email'), LocalSettings().isAnonymous - ? locale - .translate('pages.about.anonymous.anonymous') + ? locale.translate('pages.about.anonymous.anonymous') : widget.model.email, ), ], @@ -142,10 +137,8 @@ class ProfilePageState extends State { context, [ _buildActionListTile( - leading: Icon(Icons.mail, - color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), + 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( @@ -155,10 +148,8 @@ class ProfilePageState extends State { }, ), _buildActionListTile( - leading: Icon(Icons.policy, - color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), + 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 { @@ -167,10 +158,8 @@ class ProfilePageState extends State { }, ), _buildActionListTile( - leading: Icon(Icons.public, - color: Theme.of(context).primaryColor), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), + 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 { @@ -191,8 +180,7 @@ class ProfilePageState extends State { ]), _buildSectionCard(context, [ _buildActionListTile( - leading: const Icon(Icons.power_settings_new, - color: CACHET.RED_1), + leading: const Icon(Icons.power_settings_new, color: CACHET.RED_1), title: locale.translate('pages.profile.log_out'), onTap: () async { bool isConnected = await bloc.checkConnectivity(); @@ -256,9 +244,7 @@ class ProfilePageState extends State { }) { return ListTile( leading: leading, - title: Text(title, - style: fs16fw600.copyWith( - color: Theme.of(context).extension()!.grey900)), + title: Text(title, style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900)), trailing: trailing, onTap: onTap, contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), @@ -277,12 +263,8 @@ class ProfilePageState extends State { /// 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"); + final url = + Uri(scheme: 'mailto', path: email, queryParameters: {'subject': subject}).toString().replaceAll("+", "%20"); try { await launchUrl(Uri.parse(url)); } finally {} @@ -326,8 +308,7 @@ class ProfilePageState extends State { context: context, builder: (BuildContext builderContext) { return AlertDialog( - title: - Text(locale.translate("pages.profile.leave_study.confirmation")), + title: Text(locale.translate("pages.profile.leave_study.confirmation")), actions: [ TextButton( child: Text(locale.translate("NO")), @@ -371,8 +352,7 @@ class SlidePageRoute extends PageRouteBuilder { 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 tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); var offsetAnimation = animation.drive(tween); return SlideTransition( position: offsetAnimation, diff --git a/lib/ui/pages/qr_scanner.dart b/lib/ui/pages/qr_scanner.dart index b67ec16e..430337aa 100644 --- a/lib/ui/pages/qr_scanner.dart +++ b/lib/ui/pages/qr_scanner.dart @@ -84,21 +84,15 @@ class _QRViewExampleState extends State { Widget _buildQrView(BuildContext context) { // For this example we check how width or tall the device is and change the scanArea and overlay accordingly. - var scanArea = (MediaQuery.of(context).size.width < 400 || - MediaQuery.of(context).size.height < 400) - ? 150.0 - : 300.0; + var scanArea = + (MediaQuery.of(context).size.width < 400 || MediaQuery.of(context).size.height < 400) ? 150.0 : 300.0; // To ensure the Scanner view is properly sizes after rotation // we need to listen for Flutter SizeChanged notification and update controller return qr.QRView( key: qrKey, onQRViewCreated: _onQRViewCreated, overlay: qr.QrScannerOverlayShape( - borderColor: Colors.red, - borderRadius: 10, - borderLength: 30, - borderWidth: 10, - cutOutSize: scanArea), + borderColor: Colors.red, borderRadius: 10, borderLength: 30, borderWidth: 10, cutOutSize: scanArea), onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), ); } @@ -125,8 +119,7 @@ class _QRViewExampleState extends State { }); } - void _onPermissionSet( - BuildContext context, qr.QRViewController ctrl, bool p) { + void _onPermissionSet(BuildContext context, qr.QRViewController ctrl, bool p) { if (!p) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('no Permission')), diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index 6f1673fc..da41a976 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -10,23 +10,20 @@ class StudyDetailsPage extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Container( color: Theme.of(context).extension()!.backgroundGray, child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), child: const CarpAppBar(hasProfileIcon: true), ), Row( children: [ IconButton( - padding: const EdgeInsets.only( - left: 26, right: 10, top: 16, bottom: 16), + padding: const EdgeInsets.only(left: 26, right: 10, top: 16, bottom: 16), icon: Icon( Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600, @@ -42,17 +39,13 @@ class StudyDetailsPage extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), child: Text(locale.translate(model.title), - style: fs20fw700.copyWith( - color: Theme.of(context) - .extension()! - .primary)), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.primary)), ), ], ), Flexible( child: ListView( - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), @@ -75,12 +68,8 @@ class StudyDetailsPage extends StatelessWidget { [ _buildActionListTile( context: context, - leading: Icon(Icons.mail, - color: Theme.of(context) - .extension()! - .primary), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), + 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( @@ -91,18 +80,12 @@ class StudyDetailsPage extends StatelessWidget { ), _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'), + 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))); + await launchUrl(Uri.parse(locale.translate(model.privacyPolicyUrl))); } catch (error) { warning( "Could not launch study description URL - ${locale.translate(model.privacyPolicyUrl)}"); @@ -110,17 +93,12 @@ class StudyDetailsPage extends StatelessWidget { }), _buildActionListTile( context: context, - leading: Icon(Icons.public, - color: Theme.of(context) - .extension()! - .primary), - trailing: const Icon(Icons.arrow_forward_ios, - color: CACHET.GREY_6), + 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))); + await launchUrl(Uri.parse(locale.translate(model.studyDescriptionUrl))); } catch (error) { warning( "Could not launch study description URL - ${locale.translate(model.studyDescriptionUrl)}"); @@ -137,54 +115,35 @@ class StudyDetailsPage extends StatelessWidget { children: [ Text( locale.translate('widgets.study_card.responsible'), - style: fs16fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + 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), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), Text( - locale.translate( - 'widgets.study_card.participant_role'), - style: fs16fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + locale.translate('widgets.study_card.participant_role'), + style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 8), child: Text( locale.translate(model.participantRole), - style: fs12fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey700), + 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), + 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), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), ], @@ -200,39 +159,25 @@ class StudyDetailsPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - locale.translate( - 'widgets.study_card.study_description'), - style: fs16fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900), + 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), + 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), + 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), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), ], @@ -278,9 +223,7 @@ class StudyDetailsPage extends StatelessWidget { }) { return ListTile( leading: leading, - title: Text(title, - style: fs16fw600.copyWith( - color: Theme.of(context).extension()!.grey900)), + title: Text(title, style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900)), trailing: trailing, onTap: onTap, contentPadding: EdgeInsets.symmetric(vertical: 4, horizontal: 16), @@ -289,12 +232,8 @@ class StudyDetailsPage extends StatelessWidget { // 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"); + final url = + Uri(scheme: 'mailto', path: email, queryParameters: {'subject': subject}).toString().replaceAll("+", "%20"); try { await launchUrl(Uri.parse(url)); } finally {} diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index a8c7d08a..39804f27 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -13,16 +13,14 @@ class StudyPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + 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), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Flexible( @@ -44,8 +42,7 @@ class StudyPageState extends State { if (status == StudyStatus.Deployed) { bloc.start(); } - bloc.deploymentService.getStudyDeploymentStatus( - widget.model.studyDeploymentId); + bloc.deploymentService.getStudyDeploymentStatus(widget.model.studyDeploymentId); }, child: ListView.builder( itemCount: cards.length, @@ -81,8 +78,7 @@ class StudyPageState extends State { if (widget.model.messages.isNotEmpty) { items.add(_buildAnnouncementsTitle(context)); // Show newest announcements first: sort by timestamp descending - final messages = List.from(widget.model.messages) - ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final messages = List.from(widget.model.messages)..sort((a, b) => b.timestamp.compareTo(a.timestamp)); items.addAll(messages.map((message) { return _announcementCard(context, message); }).toList()); @@ -97,8 +93,7 @@ class StudyPageState extends State { builder: (context, snapshot) { if (snapshot.data == true) { return StudiesMaterial( - backgroundColor: - Theme.of(context).extension()!.grey50!, + backgroundColor: Theme.of(context).extension()!.grey50!, elevation: 8, child: Padding( padding: const EdgeInsets.only(left: 16.0), @@ -110,9 +105,7 @@ class StudyPageState extends State { child: Text( locale.translate('pages.about.app_update'), style: fs16fw600.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, ), ), ), @@ -186,10 +179,7 @@ class StudyPageState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text(locale.translate(message.title!), - style: fs24fw700.copyWith( - color: Theme.of(context) - .extension()! - .primary)), + style: fs24fw700.copyWith(color: Theme.of(context).extension()!.primary)), ), if (message.subTitle != null && message.subTitle!.isNotEmpty) Row( @@ -198,9 +188,7 @@ class StudyPageState extends State { child: Text( locale.translate(message.subTitle!), style: fs16fw400.copyWith( - color: Theme.of(context) - .extension()! - .grey700, + color: Theme.of(context).extension()!.grey700, ), ), ), @@ -211,9 +199,7 @@ class StudyPageState extends State { Expanded( child: Text( "${locale.translate(message.message!).substring(0, (message.message!.length > 150) ? 150 : null)}...", - style: fs16fw400.copyWith( - color: - Theme.of(context).extension()!.grey900), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.start, )), ]), @@ -274,37 +260,26 @@ class StudyPageState extends State { children: [ Expanded( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 22.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 22.0), child: Row( children: [ Column( children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6.0, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), child: CircleAvatar( radius: 18, - backgroundColor: - studyStatusColors[deploymentStatus], + backgroundColor: studyStatusColors[deploymentStatus], ), ), Padding( - padding: - const EdgeInsets.symmetric(horizontal: 6.0), + padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Text( - deploymentStatus == - StudyDeploymentStatusTypes - .DeployingDevices - ? locale.translate( - 'pages.about.status.deploying_devices') - : deploymentStatus - .toString() - .split('.') - .last, + deploymentStatus == StudyDeploymentStatusTypes.DeployingDevices + ? locale.translate('pages.about.status.deploying_devices') + : deploymentStatus.toString().split('.').last, maxLines: 2, - style: fs16fw600.copyWith( - color: studyStatusColors[deploymentStatus]), + style: fs16fw600.copyWith(color: studyStatusColors[deploymentStatus]), ), ), ], @@ -315,9 +290,7 @@ class StudyPageState extends State { child: Text( getStatusText(locale, deploymentStatus, snapshot), style: fs16fw600.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, fontSize: 14, ), ), @@ -391,15 +364,12 @@ class StudyPageState extends State { children: [ Expanded( child: Padding( - padding: const EdgeInsets.only( - top: 8.0, bottom: 8, right: 8), + padding: const EdgeInsets.only(top: 8.0, bottom: 8, right: 8), child: Text( locale.translate(message.title!), overflow: TextOverflow.ellipsis, style: fs20fw700.copyWith( - color: Theme.of(context) - .extension()! - .grey900, + color: Theme.of(context).extension()!.grey900, ), ), ), @@ -418,15 +388,12 @@ class StudyPageState extends State { padding: const EdgeInsets.only(bottom: 12.0), child: Row( children: [ - if (message.subTitle != null && - message.subTitle!.isNotEmpty) + if (message.subTitle != null && message.subTitle!.isNotEmpty) Expanded( child: Text( locale.translate(message.subTitle!), style: fs16fw400.copyWith( - color: Theme.of(context) - .extension()! - .grey700, + color: Theme.of(context).extension()!.grey700, ), ), ), @@ -434,9 +401,7 @@ class StudyPageState extends State { Text( timeago.format(message.timestamp.toLocal()), style: fs10fw600.copyWith( - color: Theme.of(context) - .extension()! - .grey600, + color: Theme.of(context).extension()!.grey600, ), ) ], @@ -467,8 +432,7 @@ class StudyPageState extends State { PackageInfo packageInfo = await PackageInfo.fromPlatform(); Uri url; if (Platform.isAndroid) { - url = Uri.parse( - 'https://play.google.com/store/apps/details?id=${packageInfo.packageName}'); + url = Uri.parse('https://play.google.com/store/apps/details?id=${packageInfo.packageName}'); } else if (Platform.isIOS) { url = Uri.parse('https://apps.apple.com/app/id1569798025'); } else { @@ -489,9 +453,7 @@ class StudyPageState extends State { ) { if (deploymentStatusType == StudyDeploymentStatusTypes.DeployingDevices) { return locale.translate('pages.about.status.deploying_devices.message') + - snapshot.data!.deviceStatusList.first - .remainingDevicesToRegisterBeforeDeployment! - .join(' | '); + snapshot.data!.deviceStatusList.first.remainingDevicesToRegisterBeforeDeployment!.join(' | '); } else { return locale.translate(studyStatusText[deploymentStatusType]!); } @@ -506,8 +468,7 @@ class StudyPageState extends State { static Map studyStatusText = { StudyDeploymentStatusTypes.Invited: 'pages.about.status.invited.message', - StudyDeploymentStatusTypes.DeployingDevices: - 'pages.about.status.deploying_devices.message', + StudyDeploymentStatusTypes.DeployingDevices: 'pages.about.status.deploying_devices.message', StudyDeploymentStatusTypes.Running: 'pages.about.status.running.message', StudyDeploymentStatusTypes.Stopped: 'pages.about.status.stopped.message', }; diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 5abea3c3..3b82bef3 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -22,8 +22,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { double get maxExtent => _tabBar.preferredSize.height; @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 4), @@ -41,8 +40,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { } } -class TaskListPageState extends State - with TickerProviderStateMixin { +class TaskListPageState extends State with TickerProviderStateMixin { late TabController _tabController; bool showParticipantDataCard = false; @@ -68,15 +66,13 @@ class TaskListPageState extends State return DefaultTabController( length: 2, child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray, + backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar(hasProfileIcon: true), ), Expanded( @@ -91,16 +87,13 @@ class TaskListPageState extends State slivers: [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 16), + 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, + color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold, ), ), @@ -109,49 +102,38 @@ class TaskListPageState extends State ), // Scoreboard showing days in study and tasks completed SliverPadding( - padding: const EdgeInsets.only( - top: 4, bottom: 6, left: 40, right: 40), + padding: const EdgeInsets.only(top: 4, bottom: 6, left: 40, right: 40), sliver: ScoreboardCard(widget.model), ), // Tab holder SliverPadding( - padding: const EdgeInsets.only( - top: 8, bottom: 24, left: 64, right: 64), + padding: const EdgeInsets.only(top: 8, bottom: 24, left: 64, right: 64), sliver: SliverPersistentHeader( pinned: true, delegate: _SliverAppBarDelegate( TabBar( controller: _tabController, - labelPadding: const EdgeInsets.only( - top: 4, bottom: 4, left: 4, right: 4), - labelColor: Theme.of(context) - .extension()! - .grey900, - unselectedLabelColor: Theme.of(context) - .extension()! - .grey900, + labelPadding: const EdgeInsets.only(top: 4, bottom: 4, left: 4, right: 4), + labelColor: Theme.of(context).extension()!.grey900, + unselectedLabelColor: Theme.of(context).extension()!.grey900, dividerColor: Colors.transparent, indicator: ShapeDecoration( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - color: Theme.of(context) - .extension()! - .white, + color: Theme.of(context).extension()!.white, ), tabs: [ Container( width: double.infinity, child: Tab( - text: locale.translate( - 'pages.task_list.pending'), + text: locale.translate('pages.task_list.pending'), ), ), Container( width: double.infinity, child: Tab( - text: locale.translate( - 'pages.task_list.completed'), + text: locale.translate('pages.task_list.completed'), ), ), ], @@ -169,14 +151,11 @@ class TaskListPageState extends State UserTask userTask = widget.model.tasks[index]; if (_tabController.index == 0) { if (userTask.availableForUser) { - return _buildAvailableTaskCard( - context, userTask); + return _buildAvailableTaskCard(context, userTask); } } else if (_tabController.index == 1) { - if (userTask.state == UserTaskState.done || - userTask.state == UserTaskState.expired) { - return _buildCompletedTaskCard( - context, userTask); + if (userTask.state == UserTaskState.done || userTask.state == UserTaskState.expired) { + return _buildCompletedTaskCard(context, userTask); } } return const SizedBox.shrink(); @@ -229,8 +208,7 @@ class TaskListPageState extends State child: Text( "Input Data", style: TextStyle( - color: - taskTypeColors["ExpectedParticipantData"], + color: taskTypeColors["ExpectedParticipantData"], fontWeight: FontWeight.bold, ), ), @@ -288,10 +266,9 @@ class TaskListPageState extends State right: Radius.circular(8.0), ), ), - backgroundColor: - userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 - ? CACHET.TASK_TO_EXPIRE_BACKGROUND - : Theme.of(context).extension()!.grey50!, + backgroundColor: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? CACHET.TASK_TO_EXPIRE_BACKGROUND + : Theme.of(context).extension()!.grey50!, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: IntrinsicHeight( @@ -306,15 +283,12 @@ class TaskListPageState extends State children: [ Row( children: [ - if (userTask.state == UserTaskState.started) - CircularProgressIndicator(), - if (userTask.state != UserTaskState.started) - _taskTypeIcon(userTask), + if (userTask.state == UserTaskState.started) CircularProgressIndicator(), + if (userTask.state != UserTaskState.started) _taskTypeIcon(userTask), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( - userTask.type[0].toUpperCase() + - userTask.type.substring(1), + userTask.type[0].toUpperCase() + userTask.type.substring(1), style: TextStyle( color: taskTypeColors[userTask.type], fontWeight: FontWeight.bold, @@ -325,11 +299,8 @@ class TaskListPageState extends State if (_timeRemainingSubtitle(userTask).isNotEmpty) Icon( Icons.alarm, - color: userTask.expiresIn != null && - userTask.expiresIn!.inHours < 24 - ? Theme.of(context) - .extension()! - .warningColor + color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? Theme.of(context).extension()!.warningColor : Colors.grey, ), const SizedBox(width: 4.0), @@ -338,11 +309,8 @@ class TaskListPageState extends State child: Text( _timeRemainingSubtitle(userTask), style: TextStyle( - color: userTask.expiresIn != null && - userTask.expiresIn!.inHours < 24 - ? Theme.of(context) - .extension()! - .warningColor + color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? Theme.of(context).extension()!.warningColor : Colors.grey, fontSize: 12.0, ), @@ -401,8 +369,7 @@ class TaskListPageState extends State ), onTap: () { // only start if not already started, done, or expired - if (userTask.state == UserTaskState.enqueued || - userTask.state == UserTaskState.canceled) { + if (userTask.state == UserTaskState.enqueued || userTask.state == UserTaskState.canceled) { userTask.onStart(); if (userTask.hasWidget) { context.push('/task/${userTask.id}'); @@ -410,8 +377,7 @@ class TaskListPageState extends State Timer(const Duration(seconds: 10), () { userTask.onDone(); ScaffoldMessenger.of(context).showSnackBar(SnackBar( - backgroundColor: - Theme.of(context).extension()!.grey700, + backgroundColor: Theme.of(context).extension()!.grey700, content: Text(locale.translate('Done!')), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), @@ -436,8 +402,7 @@ class TaskListPageState extends State builder: (context, snapshot) { if (taskTypeIcons[userTask.type] != null && userTask.availableForUser) { return originalIcon; - } else if (taskTypeIcons[userTask.type] != null && - userTask.state == UserTaskState.started) { + } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.started) { return Padding( padding: const EdgeInsets.all(4), child: SizedBox( @@ -448,12 +413,10 @@ class TaskListPageState extends State width: 14, ), ); - } else if (taskTypeIcons[userTask.type] != null && - userTask.state == UserTaskState.done) { + } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.done) { return Icon(originalIcon.icon, color: CACHET.TASK_COMPLETED_BLUE); } else { - return Icon(originalIcon.icon, - color: Theme.of(context).extension()!.grey600); + return Icon(originalIcon.icon, color: Theme.of(context).extension()!.grey600); } }, ); @@ -501,9 +464,7 @@ class TaskListPageState extends State right: Radius.circular(8.0), ), ), - borderColor: (userTask.state == UserTaskState.done) - ? CACHET.TASK_COMPLETED_BLUE - : CACHET.GREY_6, + borderColor: (userTask.state == UserTaskState.done) ? CACHET.TASK_COMPLETED_BLUE : CACHET.GREY_6, child: Padding( padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16), child: IntrinsicHeight( @@ -534,15 +495,11 @@ class TaskListPageState extends State Spacer(), Text( userTask.doneTime != null - ? DateFormat('MMMM dd yyyy') - .format(userTask.doneTime!) + ? DateFormat('MMMM dd yyyy').format(userTask.doneTime!) : 'Done time null', style: TextStyle( - color: userTask.expiresIn != null && - userTask.expiresIn!.inHours < 24 - ? Theme.of(context) - .extension()! - .warningColor + color: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 + ? Theme.of(context).extension()!.warningColor : Colors.grey, fontSize: 12.0, ), @@ -733,13 +690,10 @@ class TaskListPageState extends State }; static Map get taskStateIcon => { - UserTaskState.initialized: - const Icon(Icons.stream, color: CACHET.YELLOW), - UserTaskState.enqueued: - const Icon(Icons.notifications, color: CACHET.YELLOW), + UserTaskState.initialized: const Icon(Icons.stream, color: CACHET.YELLOW), + UserTaskState.enqueued: const Icon(Icons.notifications, color: CACHET.YELLOW), UserTaskState.dequeued: const Icon(Icons.stop, color: CACHET.YELLOW), - UserTaskState.started: - const Icon(Icons.play_arrow, color: CACHET.GREY_4), + UserTaskState.started: const Icon(Icons.play_arrow, color: CACHET.GREY_4), UserTaskState.canceled: const Icon(Icons.pause, color: CACHET.GREY_4), UserTaskState.done: const Icon(Icons.check, color: CACHET.GREEN), }; diff --git a/lib/ui/tasks/audio_page.dart b/lib/ui/tasks/audio_page.dart index f236941e..f5c526be 100644 --- a/lib/ui/tasks/audio_page.dart +++ b/lib/ui/tasks/audio_page.dart @@ -28,17 +28,14 @@ class AudioPageState extends State { Row( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar( hasProfileIcon: false, ), ), Spacer(), IconButton( - color: Theme.of(context) - .extension()! - .grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, @@ -60,34 +57,26 @@ class AudioPageState extends State { child: Column( children: [ Padding( - padding: - const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.only(bottom: 24), child: Text( locale.translate( widget.audioUserTask!.title, ), style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .primary, + color: Theme.of(context).extension()!.primary, ), ), ), StudiesMaterial( - backgroundColor: Theme.of(context) - .extension()! - .white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Scrollbar( child: SingleChildScrollView( - scrollDirection: - Axis.vertical, //.horizontal + scrollDirection: Axis.vertical, //.horizontal child: Padding( - padding: - const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8.0), child: Text( locale.translate( - widget.audioUserTask! - .instructions, + widget.audioUserTask!.instructions, ), style: fs16fw600, ), @@ -98,12 +87,9 @@ class AudioPageState extends State { Spacer(), CircleAvatar( radius: 30, - backgroundColor: Theme.of(context) - .extension()! - .primary, + backgroundColor: Theme.of(context).extension()!.primary, child: IconButton( - onPressed: () => widget.audioUserTask! - .onRecordStart(), + onPressed: () => widget.audioUserTask!.onRecordStart(), padding: const EdgeInsets.all(0), icon: const Icon( Icons.mic, @@ -113,11 +99,9 @@ class AudioPageState extends State { ), ), Padding( - padding: const EdgeInsets.only( - top: 8, bottom: 40), + padding: const EdgeInsets.only(top: 8, bottom: 40), child: Text( - locale.translate( - "pages.audio_task.play"), + locale.translate("pages.audio_task.play"), style: fs16fw600, ), ), @@ -129,34 +113,25 @@ class AudioPageState extends State { child: Column( children: [ Padding( - padding: - const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.only(bottom: 24), child: Text( locale.translate( widget.audioUserTask!.title, ), style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .primary, + color: Theme.of(context).extension()!.primary, ), ), ), StudiesMaterial( - backgroundColor: Theme.of(context) - .extension()! - .white!, + backgroundColor: Theme.of(context).extension()!.white!, child: Scrollbar( child: SingleChildScrollView( - scrollDirection: - Axis.vertical, //.horizontal + scrollDirection: Axis.vertical, //.horizontal child: Padding( - padding: - const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8.0), child: Text( - locale.translate(widget - .audioUserTask! - .instructions), + locale.translate(widget.audioUserTask!.instructions), style: fs16fw600, ), ), @@ -165,16 +140,13 @@ class AudioPageState extends State { ), Spacer(), Row( - mainAxisAlignment: - MainAxisAlignment.spaceAround, + mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ CircleAvatar( radius: 30, backgroundColor: CACHET.RED_1, child: IconButton( - onPressed: () => widget - .audioUserTask! - .onRecordStop(), + onPressed: () => widget.audioUserTask!.onRecordStop(), padding: const EdgeInsets.all(0), icon: const Icon( Icons.stop, @@ -191,8 +163,7 @@ class AudioPageState extends State { bottom: 40, ), child: Text( - locale.translate( - "pages.audio_task.recording"), + locale.translate("pages.audio_task.recording"), style: fs22fw700, ), ), @@ -204,24 +175,17 @@ class AudioPageState extends State { child: Column( children: [ Padding( - padding: - const EdgeInsets.only(bottom: 32), + padding: const EdgeInsets.only(bottom: 32), child: Text( - locale.translate( - 'pages.audio_task.done'), + locale.translate('pages.audio_task.done'), style: fs22fw700.copyWith( - color: Theme.of(context) - .extension()! - .primary, + color: Theme.of(context).extension()!.primary, ), ), ), Padding( - padding: - const EdgeInsets.only(bottom: 20), - child: Text( - locale.translate( - 'pages.audio_task.recording_completed'), + padding: const EdgeInsets.only(bottom: 20), + child: Text(locale.translate('pages.audio_task.recording_completed'), style: fs16fw600), ), Spacer(), @@ -233,20 +197,15 @@ class AudioPageState extends State { right: 20, ), child: Row( - crossAxisAlignment: - CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( flex: 1, child: IconButton( - onPressed: () => widget - .audioUserTask! - .onRecordReset(), + onPressed: () => widget.audioUserTask!.onRecordReset(), icon: Icon( Icons.replay, - color: Theme.of(context) - .extension()! - .grey700, + color: Theme.of(context).extension()!.grey700, size: 30, ), ), @@ -261,8 +220,7 @@ class AudioPageState extends State { Navigator.of(context).pop(); Navigator.of(context).pop(); }, - padding: - const EdgeInsets.all(0), + padding: const EdgeInsets.all(0), icon: const Icon( Icons.check_circle_outline, color: Colors.white, diff --git a/lib/ui/tasks/audio_task_page.dart b/lib/ui/tasks/audio_task_page.dart index 46518caa..6e2bb751 100644 --- a/lib/ui/tasks/audio_task_page.dart +++ b/lib/ui/tasks/audio_task_page.dart @@ -25,17 +25,14 @@ class AudioTaskPageState extends State { Row( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar( hasProfileIcon: false, ), ), Spacer(), IconButton( - color: Theme.of(context) - .extension()! - .grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, @@ -48,19 +45,14 @@ class AudioTaskPageState extends State { ), Padding( padding: const EdgeInsets.symmetric(vertical: 30), - child: const Image( - image: AssetImage('assets/icons/audio.png'), - width: 220, - height: 220), + child: const Image(image: AssetImage('assets/icons/audio.png'), width: 220, height: 220), ), Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: Text(locale.translate(widget.audioUserTask!.title), - style: fs22fw700), + child: Text(locale.translate(widget.audioUserTask!.title), style: fs22fw700), ), Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), child: Text( '${locale.translate(widget.audioUserTask!.description)}\n\n' '${locale.translate('pages.audio_task.play')}', @@ -89,9 +81,7 @@ class AudioTaskPageState extends State { ), ), style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric( horizontal: 30, vertical: 12, diff --git a/lib/ui/tasks/camera_page.dart b/lib/ui/tasks/camera_page.dart index 352b08dc..64ccec2c 100644 --- a/lib/ui/tasks/camera_page.dart +++ b/lib/ui/tasks/camera_page.dart @@ -109,10 +109,8 @@ class CameraPageState extends State { if (context.mounted) { await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => DisplayPicturePage( - file: video, - isVideo: true, - videoUserTask: widget.videoUserTask)), + builder: (context) => + DisplayPicturePage(file: video, isVideo: true, videoUserTask: widget.videoUserTask)), ); } setState(() { @@ -142,8 +140,7 @@ class CameraPageState extends State { builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { return LayoutBuilder( - builder: - (BuildContext context, BoxConstraints constraints) { + builder: (BuildContext context, BoxConstraints constraints) { return SizedBox( width: constraints.maxWidth, height: constraints.maxHeight, @@ -152,10 +149,8 @@ class CameraPageState extends State { child: FittedBox( fit: BoxFit.cover, child: SizedBox( - width: - _cameraController.value.previewSize!.height, - height: - _cameraController.value.previewSize!.width, + width: _cameraController.value.previewSize!.height, + height: _cameraController.value.previewSize!.width, child: CameraPreview(_cameraController), ), ), @@ -221,8 +216,7 @@ class CameraPageState extends State { height: 65, child: CircularProgressIndicator( backgroundColor: Colors.white54, - valueColor: - AlwaysStoppedAnimation(Colors.black54), + valueColor: AlwaysStoppedAnimation(Colors.black54), strokeWidth: 5, ), ), @@ -286,8 +280,7 @@ class CameraPageState extends State { actions: [ TextButton( child: Text(locale.translate("NO")), - onPressed: () => - Navigator.of(context).pop(), // Dismissing the pop-up + onPressed: () => Navigator.of(context).pop(), // Dismissing the pop-up ), TextButton( child: Text(locale.translate("YES")), diff --git a/lib/ui/tasks/camera_task_page.dart b/lib/ui/tasks/camera_task_page.dart index 6d827520..dd0916ea 100644 --- a/lib/ui/tasks/camera_task_page.dart +++ b/lib/ui/tasks/camera_task_page.dart @@ -28,25 +28,21 @@ class CameraTaskPageState extends State { return StreamBuilder( stream: widget.mediaUserTask.stateEvents, initialData: UserTaskState.enqueued, - builder: - (context, AsyncSnapshot snapshot) { + builder: (context, AsyncSnapshot snapshot) { return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar( hasProfileIcon: false, ), ), Spacer(), IconButton( - color: Theme.of(context) - .extension()! - .grey900!, + color: Theme.of(context).extension()!.grey900!, onPressed: () { _showCancelConfirmationDialog(); }, @@ -60,60 +56,46 @@ class CameraTaskPageState extends State { Column( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 30), + padding: const EdgeInsets.symmetric(vertical: 30), child: const Image( - image: AssetImage( - 'assets/icons/camera.png'), - width: 220, - height: 220), + image: AssetImage('assets/icons/camera.png'), width: 220, height: 220), ), Padding( - padding: const EdgeInsets.symmetric( - vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 12), child: Text( - locale.translate( - widget.mediaUserTask.title), + locale.translate(widget.mediaUserTask.title), style: fs22fw700, ), ), Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), child: Text( - locale.translate( - widget.mediaUserTask.description), + locale.translate(widget.mediaUserTask.description), style: fs16fw600, ), ), Padding( - padding: const EdgeInsets.symmetric( - vertical: 30), + padding: const EdgeInsets.symmetric(vertical: 30), child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ OutlinedButton( onPressed: () { Navigator.pop(context); }, - child: - Text(locale.translate("Cancel")), + child: Text(locale.translate("Cancel")), ), ElevatedButton( onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (context) => CameraPage( - videoUserTask: - widget.mediaUserTask, + videoUserTask: widget.mediaUserTask, ), ), ), style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .extension()! - .primary, + backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric( horizontal: 30, vertical: 12, diff --git a/lib/ui/tasks/display_picture_page.dart b/lib/ui/tasks/display_picture_page.dart index 29cff952..d8566af1 100644 --- a/lib/ui/tasks/display_picture_page.dart +++ b/lib/ui/tasks/display_picture_page.dart @@ -5,11 +5,7 @@ class DisplayPicturePage extends StatefulWidget { final bool isVideo; final VideoUserTask videoUserTask; - const DisplayPicturePage( - {super.key, - required this.file, - required this.videoUserTask, - this.isVideo = false}); + const DisplayPicturePage({super.key, required this.file, required this.videoUserTask, this.isVideo = false}); @override State createState() => DisplayPicturePageState(); @@ -52,8 +48,7 @@ class DisplayPicturePageState extends State { Row( children: [ Padding( - padding: - const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar( hasProfileIcon: false, ), @@ -81,8 +76,7 @@ class DisplayPicturePageState extends State { child: (widget.isVideo && _videoPlayerController != null) ? _videoPlayerController!.value.isInitialized ? AspectRatio( - aspectRatio: - _videoPlayerController!.value.aspectRatio, + aspectRatio: _videoPlayerController!.value.aspectRatio, child: VideoPlayer(_videoPlayerController!)) : const CircularProgressIndicator() : Image.file(File(videoFilePath)), @@ -96,8 +90,7 @@ class DisplayPicturePageState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text(locale.translate('pages.audio_task.done'), - style: fs22fw700), + child: Text(locale.translate('pages.audio_task.done'), style: fs22fw700), ), const SizedBox(height: 40), Padding( @@ -119,8 +112,7 @@ class DisplayPicturePageState extends State { IconButton( onPressed: () => Navigator.of(context).pop(), padding: const EdgeInsets.all(0), - icon: const Icon(Icons.replay, - size: 25, color: CACHET.GREY_5), + icon: const Icon(Icons.replay, size: 25, color: CACHET.GREY_5), ), const SizedBox(width: 20), CircleAvatar( @@ -134,8 +126,7 @@ class DisplayPicturePageState extends State { Navigator.of(context).pop(); }, padding: const EdgeInsets.all(0), - icon: const Icon(Icons.check_circle_outline, - color: Colors.white, size: 30), + icon: const Icon(Icons.check_circle_outline, color: Colors.white, size: 30), ), ), const SizedBox(width: 50), diff --git a/lib/ui/tasks/participant_data_page.dart b/lib/ui/tasks/participant_data_page.dart index 23c1320f..8ed6638f 100644 --- a/lib/ui/tasks/participant_data_page.dart +++ b/lib/ui/tasks/participant_data_page.dart @@ -1,14 +1,6 @@ part of carp_study_app; -enum ParticipantStep { - presentTypes, - address, - diagnosis, - fullName, - phoneNumber, - socialSecurityNumber, - review -} +enum ParticipantStep { presentTypes, address, diagnosis, fullName, phoneNumber, socialSecurityNumber, review } class ParticipantDataPage extends StatefulWidget { static const String route = '/participant_data'; @@ -81,8 +73,7 @@ class ParticipantDataPageState extends State { widget.model._lastNameFocusNode = FocusNode(); for (final key in _stepMap.keys) { - if (widget.model.expectedData.any( - (dataType) => dataType!.attribute!.inputDataType.contains(key))) { + if (widget.model.expectedData.any((dataType) => dataType!.attribute!.inputDataType.contains(key))) { _includedSteps.add(_stepMap[key]!); } } @@ -237,14 +228,13 @@ class ParticipantDataPageState extends State { widget.model._countryController.text.isNotEmpty; break; case ParticipantStep.diagnosis: - _nextEnabled = - widget.model._effectiveDateController.text.isNotEmpty && - widget.model._icd11CodeController.text.isNotEmpty && - widget.model._conclusionController.text.isNotEmpty; + _nextEnabled = widget.model._effectiveDateController.text.isNotEmpty && + widget.model._icd11CodeController.text.isNotEmpty && + widget.model._conclusionController.text.isNotEmpty; break; case ParticipantStep.fullName: - _nextEnabled = widget.model._firstNameController.text.isNotEmpty && - widget.model._lastNameController.text.isNotEmpty; + _nextEnabled = + widget.model._firstNameController.text.isNotEmpty && widget.model._lastNameController.text.isNotEmpty; break; case ParticipantStep.phoneNumber: _nextEnabled = widget.model._phoneNumberController.text.isNotEmpty; @@ -266,8 +256,7 @@ class ParticipantDataPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: - Theme.of(context).extension()!.backgroundGray!, + backgroundColor: Theme.of(context).extension()!.backgroundGray!, body: SafeArea( child: Container( padding: const EdgeInsets.all(16.0), @@ -276,8 +265,7 @@ class ParticipantDataPageState extends State { Row( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 10), + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), child: const CarpAppBar( hasProfileIcon: false, ), @@ -306,14 +294,12 @@ class ParticipantDataPageState extends State { child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: SizedBox( - child: _buildStepContent( - locale, widget.model.expectedData), + child: _buildStepContent(locale, widget.model.expectedData), ), ), ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _buildActionButtons(locale), @@ -333,20 +319,13 @@ class ParticipantDataPageState extends State { /// Builds the title of the dialog based on the current step. Widget _buildDialogTitle(RPLocalizations locale) { final stepTitleMap = { - ParticipantStep.presentTypes: - locale.translate("tasks.participant_data.present_data.title"), - ParticipantStep.address: - locale.translate("tasks.participant_data.address.title"), - ParticipantStep.diagnosis: - locale.translate("tasks.participant_data.diagnosis.title"), - ParticipantStep.fullName: - locale.translate("tasks.participant_data.full_name.title"), - ParticipantStep.phoneNumber: - locale.translate("tasks.participant_data.phone_number.title"), - ParticipantStep.socialSecurityNumber: - locale.translate("tasks.participant_data.ssn.title"), - ParticipantStep.review: - locale.translate("tasks.participant_data.review.title"), + ParticipantStep.presentTypes: locale.translate("tasks.participant_data.present_data.title"), + ParticipantStep.address: locale.translate("tasks.participant_data.address.title"), + ParticipantStep.diagnosis: locale.translate("tasks.participant_data.diagnosis.title"), + ParticipantStep.fullName: locale.translate("tasks.participant_data.full_name.title"), + ParticipantStep.phoneNumber: locale.translate("tasks.participant_data.phone_number.title"), + ParticipantStep.socialSecurityNumber: locale.translate("tasks.participant_data.ssn.title"), + ParticipantStep.review: locale.translate("tasks.participant_data.review.title"), }; return Padding( padding: const EdgeInsets.only(bottom: 16), @@ -371,16 +350,13 @@ class ParticipantDataPageState extends State { } /// Builds the content of the current step based on the [_includedSteps]. - Widget _buildStepContent( - RPLocalizations locale, Set expectedData) { + Widget _buildStepContent(RPLocalizations locale, Set expectedData) { List fields = []; switch (currentStep) { case ParticipantStep.presentTypes: fields.add(_buildPresentTypes( _includedSteps - .where((step) => - step != ParticipantStep.presentTypes && - step != ParticipantStep.review) + .where((step) => step != ParticipantStep.presentTypes && step != ParticipantStep.review) .map((step) => participantStepDescriptions[step]) .toList(), )); @@ -396,10 +372,8 @@ class ParticipantDataPageState extends State { break; case ParticipantStep.diagnosis: fields.addAll([ - _buildField(locale, widget.model.effectiveDateField, - isDatePicker: true), - _buildField(locale, widget.model.diagnosisDescriptionField, - isOptional: true), + _buildField(locale, widget.model.effectiveDateField, isDatePicker: true), + _buildField(locale, widget.model.diagnosisDescriptionField, isOptional: true), _buildField(locale, widget.model.icd11CodeField), _buildField(locale, widget.model.conclusionField, isThicc: true), ]); @@ -412,8 +386,7 @@ class ParticipantDataPageState extends State { ]); break; case ParticipantStep.phoneNumber: - fields.add(_buildField(locale, widget.model.phoneNumberField, - isPhoneNumber: true)); + fields.add(_buildField(locale, widget.model.phoneNumberField, isPhoneNumber: true)); break; case ParticipantStep.socialSecurityNumber: fields.add(_buildField(locale, widget.model.ssnField, isCPR: true)); @@ -463,14 +436,10 @@ class ParticipantDataPageState extends State { final String field = fields.elementAt(index).title; String input = ""; if (index < fields.length) { - if (fields.elementAt(index).controller == - widget.model._phoneNumberController) { - input = - "${widget.model._phoneNumberCodeController.text} ${fields.elementAt(index).controller.text}"; - } else if (fields.elementAt(index).controller == - widget.model._ssnController) { - input = - "${widget.model._ssnCountryController.text} ${fields.elementAt(index).controller.text}"; + if (fields.elementAt(index).controller == widget.model._phoneNumberController) { + input = "${widget.model._phoneNumberCodeController.text} ${fields.elementAt(index).controller.text}"; + } else if (fields.elementAt(index).controller == widget.model._ssnController) { + input = "${widget.model._ssnCountryController.text} ${fields.elementAt(index).controller.text}"; } else { input = fields.elementAt(index).controller.text; } @@ -520,8 +489,7 @@ class ParticipantDataPageState extends State { if (isPhoneNumber) { return InternationalPhoneNumberInput( onInputChanged: (phoneNumber) { - widget.model._phoneNumberCodeController.text = - phoneNumber.dialCode ?? ''; + widget.model._phoneNumberCodeController.text = phoneNumber.dialCode ?? ''; }, textFieldController: stepField.controller, selectorConfig: SelectorConfig( @@ -533,8 +501,7 @@ class ParticipantDataPageState extends State { autoValidateMode: AutovalidateMode.disabled, selectorTextStyle: TextStyle(color: Colors.black), formatInput: true, - keyboardType: - TextInputType.numberWithOptions(signed: true, decimal: true), + keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true), inputBorder: OutlineInputBorder(), ); } else if (isCPR) { @@ -549,8 +516,7 @@ class ParticipantDataPageState extends State { child: Container( decoration: BoxDecoration( border: Border.all( - color: - Theme.of(context).extension()!.grey600!, + color: Theme.of(context).extension()!.grey600!, width: 1.0, ), borderRadius: BorderRadius.circular(16.0), @@ -558,8 +524,7 @@ class ParticipantDataPageState extends State { child: CountryCodePicker( onChanged: (value) { stepField.controller.clear(); - widget.model._ssnCountryController.text = - value.code ?? ''; + widget.model._ssnCountryController.text = value.code ?? ''; stepField.controller.text = stepField.controller.text; }, initialSelection: 'DK', @@ -567,8 +532,7 @@ class ParticipantDataPageState extends State { showOnlyCountryWhenClosed: true, alignLeft: false, textStyle: fs16fw600.copyWith( - color: - Theme.of(context).extension()!.grey900!, + color: Theme.of(context).extension()!.grey900!, ), ), ), @@ -597,8 +561,7 @@ class ParticipantDataPageState extends State { textInputAction: TextInputAction.next, onFieldSubmitted: (_) { if (stepField.nextFocusNode != null) { - FocusScope.of(context) - .requestFocus(stepField.nextFocusNode); + FocusScope.of(context).requestFocus(stepField.nextFocusNode); } }, onTap: isDatePicker @@ -610,8 +573,7 @@ class ParticipantDataPageState extends State { lastDate: DateTime.now(), ); if (pickedDate != null) { - stepField.controller.text = - "${pickedDate.toLocal()}".split(' ')[0]; + stepField.controller.text = "${pickedDate.toLocal()}".split(' ')[0]; } } : null, @@ -624,8 +586,7 @@ class ParticipantDataPageState extends State { } } - InputDecoration _buildInputDecoration( - RPLocalizations locale, StepField stepField, bool isThicc) { + InputDecoration _buildInputDecoration(RPLocalizations locale, StepField stepField, bool isThicc) { return InputDecoration( labelText: locale.translate(stepField.title), floatingLabelBehavior: FloatingLabelBehavior.always, @@ -641,16 +602,15 @@ class ParticipantDataPageState extends State { borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.blue, width: 2), ), - contentPadding: - EdgeInsets.symmetric(horizontal: 16, vertical: isThicc ? 70 : 12)); + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: isThicc ? 70 : 12)); } /// Builds the action buttons at the bottom of the page. /// Includes "Cancel", "Previous", "Next", and "Submit" buttons. /// The "Next" button is enabled only if the required fields for the current step are filled. List _buildActionButtons(RPLocalizations locale) { - Widget buildTranslatedButton(String key, VoidCallback onPressed, - bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { + Widget buildTranslatedButton( + String key, VoidCallback onPressed, bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { return ElevatedButton( onPressed: enabled ? onPressed : null, child: Text( @@ -683,10 +643,8 @@ class ParticipantDataPageState extends State { }, _nextEnabled, ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).extension()!.primary, - padding: - const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), TextStyle( color: Colors.white, @@ -704,10 +662,8 @@ class ParticipantDataPageState extends State { }, currentStep == ParticipantStep.presentTypes ? true : _nextEnabled, ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).extension()!.primary, - padding: - const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), TextStyle( color: Colors.white, @@ -736,8 +692,7 @@ class ParticipantDataPageState extends State { DiagnosisInput.type: DiagnosisInput( effectiveDate: widget.model._effectiveDateController.text.isNotEmpty ? DateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") - .parse( - '${widget.model._effectiveDateController.text}T00:00:00Z') + .parse('${widget.model._effectiveDateController.text}T00:00:00Z') .toUtc() : null, diagnosis: widget.model._diagnosisDescriptionController.text, diff --git a/lib/ui/widgets/battery_icon.dart b/lib/ui/widgets/battery_icon.dart index 1a3dae64..bc402ee6 100644 --- a/lib/ui/widgets/battery_icon.dart +++ b/lib/ui/widgets/battery_icon.dart @@ -5,8 +5,7 @@ class BatteryPercentage extends StatelessWidget { super.key, required this.batteryLevel, this.scale = 1.0, - }) : assert(batteryLevel >= 0 && batteryLevel <= 100, - 'Battery level must be between 0 and 100'); + }) : assert(batteryLevel >= 0 && batteryLevel <= 100, 'Battery level must be between 0 and 100'); // Battery level from 0 to 100 final int batteryLevel; @@ -28,8 +27,7 @@ class BatteryPercentage extends StatelessWidget { height: height, child: Row(children: [ SizedBox( - width: - batteryLevel != 0 ? batteryLevel * (width * 0.9 / 100) : 0, + width: batteryLevel != 0 ? batteryLevel * (width * 0.9 / 100) : 0, height: height * 0.75, child: Container(color: Theme.of(context).primaryColor)), ]), diff --git a/lib/ui/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index b7598380..8b5b60b3 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -31,10 +31,7 @@ class CarpAppBar extends StatelessWidget { ), tooltip: 'Profile', onPressed: () { - Navigator.push( - context, - SlidePageRoute( - ProfilePage(ProfilePageViewModel()))); + Navigator.push(context, SlidePageRoute(ProfilePage(ProfilePageViewModel()))); }, ), ], diff --git a/lib/ui/widgets/charts_legend.dart b/lib/ui/widgets/charts_legend.dart index 12f0a434..99cc4135 100644 --- a/lib/ui/widgets/charts_legend.dart +++ b/lib/ui/widgets/charts_legend.dart @@ -8,12 +8,7 @@ class ChartsLegend extends StatelessWidget { final List colors; const ChartsLegend( - {super.key, - this.heroTag, - this.iconAssetName, - required this.title, - this.values = const [], - required this.colors}); + {super.key, this.heroTag, this.iconAssetName, required this.title, this.values = const [], required this.colors}); @override Widget build(BuildContext context) { @@ -37,8 +32,7 @@ class ChartsLegend extends StatelessWidget { (entry) => Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Icon(Icons.circle, - color: colors[entry.key], size: 12.0), + Icon(Icons.circle, color: colors[entry.key], size: 12.0), Text(' ${entry.value} ', style: fs12fw400), ], ), diff --git a/lib/ui/widgets/details_banner.dart b/lib/ui/widgets/details_banner.dart index cc070eb2..aa9926ff 100644 --- a/lib/ui/widgets/details_banner.dart +++ b/lib/ui/widgets/details_banner.dart @@ -17,8 +17,7 @@ class DetailsBanner extends StatelessWidget { if (imagePath != null && imagePath!.isNotEmpty) SizedBox( height: 300, - child: - bloc.appViewModel.studyPageViewModel.getMessageImage(imagePath), + child: bloc.appViewModel.studyPageViewModel.getMessageImage(imagePath), ), Padding( padding: const EdgeInsets.all(16), @@ -29,8 +28,7 @@ class DetailsBanner extends StatelessWidget { children: [ Text( locale.translate(title), - style: fs30fw800.copyWith( - fontSize: 30, color: Theme.of(context).primaryColor), + style: fs30fw800.copyWith(fontSize: 30, color: Theme.of(context).primaryColor), ), ], ), diff --git a/lib/ui/widgets/dialog_title.dart b/lib/ui/widgets/dialog_title.dart index 51ed72ac..3c36339b 100644 --- a/lib/ui/widgets/dialog_title.dart +++ b/lib/ui/widgets/dialog_title.dart @@ -5,8 +5,7 @@ class DialogTitle extends StatelessWidget { final String? deviceName; final String? titleEnd; - const DialogTitle( - {super.key, required this.title, this.deviceName, this.titleEnd}); + const DialogTitle({super.key, required this.title, this.deviceName, this.titleEnd}); @override Widget build(BuildContext context) { @@ -14,17 +13,14 @@ class DialogTitle extends StatelessWidget { return _buildDialogTitle(locale, title, context); } - Widget _buildDialogTitle( - RPLocalizations locale, String title, BuildContext context) { + Widget _buildDialogTitle(RPLocalizations locale, String title, BuildContext context) { return Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - onPressed: () => Navigator.of(context).canPop() - ? Navigator.of(context).pop() - : null, + onPressed: () => Navigator.of(context).canPop() ? Navigator.of(context).pop() : null, icon: const Icon(Icons.close), padding: const EdgeInsets.only(right: 8), ), @@ -42,12 +38,8 @@ class DialogTitle extends StatelessWidget { locale.translate( title, ) + - (deviceName != null - ? " ${locale.translate(deviceName!)} " - : "") + - (titleEnd != null - ? ' ${locale.translate(titleEnd!)}' - : ""), + (deviceName != null ? " ${locale.translate(deviceName!)} " : "") + + (titleEnd != null ? ' ${locale.translate(titleEnd!)}' : ""), style: fs18fw700.copyWith( color: Theme.of(context).primaryColor, ), diff --git a/lib/ui/widgets/horizontal_bar.dart b/lib/ui/widgets/horizontal_bar.dart index bc3b3aa6..58bc1709 100644 --- a/lib/ui/widgets/horizontal_bar.dart +++ b/lib/ui/widgets/horizontal_bar.dart @@ -23,10 +23,7 @@ class HorizontalBar extends StatelessWidget { List assetList() { List assetList = []; for (int i = 0; i < names.length; i++) { - assetList.add(MyAsset( - size: values.elementAt(i), - color: colors.elementAt(i), - name: names.elementAt(i))); + assetList.add(MyAsset(size: values.elementAt(i), color: colors.elementAt(i), name: names.elementAt(i))); } return assetList; } @@ -42,8 +39,7 @@ class HorizontalBar extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.all(Radius.circular(height / 2)), child: Container( - decoration: - BoxDecoration(color: Theme.of(context).colorScheme.tertiary), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.tertiary), width: width, height: height, child: const SizedBox.shrink(), @@ -135,9 +131,7 @@ class MyAssetsBar extends StatelessWidget { //single.size : assetsSum = x : width Widget _createSingle(MyAsset singleAsset) { return SizedBox( - width: singleAsset.size! != 0 - ? singleAsset.size! * (width / _getValuesSum()) - : 0, + width: singleAsset.size! != 0 ? singleAsset.size! * (width / _getValuesSum()) : 0, child: Container(color: singleAsset.color), ); } @@ -151,14 +145,12 @@ class MyAssetsBar extends StatelessWidget { .entries .map( (entry) => Padding( - padding: - const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Icon(Icons.circle, color: entry.value.color, size: 12.0), - Text(' ${entry.value.name!} ${entry.value.size}', - style: fs12fw400, textAlign: TextAlign.right), + Text(' ${entry.value.name!} ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.right), ], )), ) @@ -180,19 +172,15 @@ class MyAssetsBar extends StatelessWidget { .entries .map( (entry) => Padding( - padding: - const EdgeInsets.symmetric(vertical: 3, horizontal: 5), + padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 5), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Icon(Icons.circle, color: entry.value.color, size: 12.0), - Text(' ${entry.value.size}', - style: fs12fw400, textAlign: TextAlign.left), + Text(' ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.left), Expanded( child: Text(' ${entry.value.name!}', - style: fs12fw400, - textAlign: TextAlign.left, - overflow: TextOverflow.ellipsis)), + style: fs12fw400, textAlign: TextAlign.left, overflow: TextOverflow.ellipsis)), ], )), ) @@ -217,10 +205,7 @@ class MyAssetsBar extends StatelessWidget { decoration: BoxDecoration(color: background), width: width, height: height, - child: Row( - children: assets - .map((singleAsset) => _createSingle(singleAsset)) - .toList()), + child: Row(children: assets.map((singleAsset) => _createSingle(singleAsset)).toList()), ), ), _labelOrientation(), diff --git a/lib/ui/widgets/location_usage_dialog.dart b/lib/ui/widgets/location_usage_dialog.dart index 33f5599a..85b3280e 100644 --- a/lib/ui/widgets/location_usage_dialog.dart +++ b/lib/ui/widgets/location_usage_dialog.dart @@ -15,8 +15,7 @@ class LocationUsageDialog { width: MediaQuery.of(context).size.width * 0.15, height: MediaQuery.of(context).size.height * 0.15, ), - Text(locale.translate("dialog.location.permission"), - style: fs20fw700), + Text(locale.translate("dialog.location.permission"), style: fs20fw700), ], ), contentPadding: const EdgeInsets.all(15), @@ -38,15 +37,11 @@ class LocationUsageDialog { actions: [ ElevatedButton( onPressed: () { - Permission.locationWhenInUse - .request() - .then((value) => context.pop(true)); + Permission.locationWhenInUse.request().then((value) => context.pop(true)); }, style: ButtonStyle( - backgroundColor: - WidgetStateProperty.all(Theme.of(context).primaryColor), - foregroundColor: WidgetStateProperty.all( - Theme.of(context).colorScheme.onPrimary), + backgroundColor: WidgetStateProperty.all(Theme.of(context).primaryColor), + foregroundColor: WidgetStateProperty.all(Theme.of(context).colorScheme.onPrimary), ), child: Text( locale.translate("dialog.location.continue"), diff --git a/lib/view_models/cards/activity_data_model.dart b/lib/view_models/cards/activity_data_model.dart index 4b7519b6..9bd47b4c 100644 --- a/lib/view_models/cards/activity_data_model.dart +++ b/lib/view_models/cards/activity_data_model.dart @@ -1,37 +1,30 @@ part of carp_study_app; class ActivityCardViewModel extends SerializableViewModel { - Measurement _lastActivity = - Measurement.fromData(Activity(type: ActivityType.STILL, confidence: 100)); + Measurement _lastActivity = Measurement.fromData(Activity(type: ActivityType.STILL, confidence: 100)); @override WeeklyActivities createModel() => WeeklyActivities(); Map> get activities => model.activities; - List activitiesByType(ActivityType type) => - model.activitiesByType(type); + List activitiesByType(ActivityType type) => model.activitiesByType(type); /// Stream of activity measurements. - Stream? get activityEvents => controller?.measurements - .where((measurement) => measurement.data is Activity); + Stream? get activityEvents => + controller?.measurements.where((measurement) => measurement.data is Activity); - final DateTime _startOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); - final DateTime _endOfWeek = DateTime.now() - .subtract(Duration(days: DateTime.now().weekday - 1)) - .add(Duration(days: 6)); + final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); + final DateTime _endOfWeek = + DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)).add(Duration(days: 6)); String get startOfWeek => DateFormat('dd').format(_startOfWeek); String get endOfWeek => DateFormat('dd').format(_endOfWeek); - String get currentMonth => - DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); + String get currentMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); - String get nextMonth => DateFormat('MMM') - .format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); + String get nextMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); - String get currentYear => - DateFormat('yyyy').format(DateTime(DateTime.now().year)); + String get currentYear => DateFormat('yyyy').format(DateTime(DateTime.now().year)); @override void init(SmartphoneDeploymentController ctrl) { @@ -41,14 +34,11 @@ class ActivityCardViewModel extends SerializableViewModel { activityEvents?.listen((measurement) { var lastActivity = _lastActivity; - if ((measurement.data as Activity).type != - (lastActivity.data as Activity).type) { + 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); + DateTime start = DateTime.fromMicrosecondsSinceEpoch(lastActivity.sensorStartTime); + DateTime end = DateTime.fromMicrosecondsSinceEpoch(measurement.sensorStartTime); model.increaseActivityDuration( (lastActivity.data as Activity).type, start.weekday, @@ -73,10 +63,8 @@ class WeeklyActivities extends DataModel { Map> activities = {}; /// A list of activities of a specific [type]. - List activitiesByType(ActivityType type) => activities[type]! - .entries - .map((entry) => DailyActivity(entry.key, entry.value)) - .toList(); + List activitiesByType(ActivityType type) => + activities[type]!.entries.map((entry) => DailyActivity(entry.key, entry.value)).toList(); WeeklyActivities() { // initialize every week or if is the first time opening the app @@ -94,21 +82,19 @@ class WeeklyActivities extends DataModel { int weekday, int minutes, ) { - activities[activityType]![weekday] = - (activities[activityType]![weekday] ?? 0) + minutes; + activities[activityType]![weekday] = (activities[activityType]![weekday] ?? 0) + minutes; } @override - WeeklyActivities fromJson(Map json) => - _$WeeklyActivitiesFromJson(json); + WeeklyActivities fromJson(Map json) => _$WeeklyActivitiesFromJson(json); @override Map toJson() => _$WeeklyActivitiesToJson(this); @override String toString() { String str = ' TYPE\t| day | min.\n'; - activities.forEach((type, data) => data.forEach((day, minutes) => - str += '${type.toString().split(".").last}\t| $day | $minutes\n')); + activities.forEach((type, data) => + data.forEach((day, minutes) => str += '${type.toString().split(".").last}\t| $day | $minutes\n')); return str; } } diff --git a/lib/view_models/cards/heart_rate_data_model.dart b/lib/view_models/cards/heart_rate_data_model.dart index 75ef5be6..65c71ffe 100644 --- a/lib/view_models/cards/heart_rate_data_model.dart +++ b/lib/view_models/cards/heart_rate_data_model.dart @@ -11,8 +11,7 @@ class HeartRateCardViewModel extends SerializableViewModel { /// The current heart rate double? get currentHeartRate => model.currentHeartRate; - HeartRateMinMaxPrHour get dayMinMax => - HeartRateMinMaxPrHour(model.minHeartRate, model.maxHeartRate); + HeartRateMinMaxPrHour get dayMinMax => HeartRateMinMaxPrHour(model.minHeartRate, model.maxHeartRate); final StreamGroup _group = StreamGroup.broadcast(); @@ -21,9 +20,7 @@ class HeartRateCardViewModel extends SerializableViewModel { /// 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); + .map((measurement) => (measurement.data as PolarHR).samples.firstOrNull?.hr.toDouble() ?? 0); /// Stream of heart rate based on [MovesenseHR] measures. Stream? get movesenseHRStream => controller?.measurements @@ -124,14 +121,12 @@ class HourlyHeartRate extends DataModel { @override String toString() { String str = 'time | heart rate\n'; - hourlyHeartRate - .forEach((time, heartRate) => str += '$time | $heartRate\n'); + hourlyHeartRate.forEach((time, heartRate) => str += '$time | $heartRate\n'); return str; } @override - HourlyHeartRate fromJson(Map json) => - _$HourlyHeartRateFromJson(json); + HourlyHeartRate fromJson(Map json) => _$HourlyHeartRateFromJson(json); @override Map toJson() => _$HourlyHeartRateToJson(this); } @@ -146,17 +141,13 @@ class HeartRateMinMaxPrHour { @override String toString() => {'min': min, 'max': max}.toString(); - factory HeartRateMinMaxPrHour.fromJson(Map json) => - _$HeartRateMinMaxPrHourFromJson(json); + factory HeartRateMinMaxPrHour.fromJson(Map json) => _$HeartRateMinMaxPrHourFromJson(json); Map toJson() => _$HeartRateMinMaxPrHourToJson(this); @override bool operator ==(Object other) => identical(this, other) || - other is HeartRateMinMaxPrHour && - runtimeType == other.runtimeType && - min == other.min && - max == other.max; + other is HeartRateMinMaxPrHour && runtimeType == other.runtimeType && min == other.min && max == other.max; @override int get hashCode => min.hashCode ^ max.hashCode; diff --git a/lib/view_models/cards/measurements_data_model.dart b/lib/view_models/cards/measurements_data_model.dart index 5b95c201..542b8286 100644 --- a/lib/view_models/cards/measurements_data_model.dart +++ b/lib/view_models/cards/measurements_data_model.dart @@ -7,12 +7,11 @@ class MeasurementsCardViewModel extends ViewModel { Stream? get measureEvents => controller?.measurements; /// Stream of more quiet [DataPoint] measures. - Stream? get quietMeasureEvents => controller?.measurements - .where((measurement) => measurement.dataType.name != 'sensor'); + Stream? get quietMeasureEvents => + controller?.measurements.where((measurement) => measurement.dataType.name != 'sensor'); /// The total sampling size - int get samplingSize => - controller?.samplingSize == null ? 0 : controller!.samplingSize; + int get samplingSize => controller?.samplingSize == null ? 0 : controller!.samplingSize; // samplingTable.values.fold(0, (prev, element) => prev + element); /// A table with sampling size of each measure type @@ -28,14 +27,12 @@ class MeasurementsCardViewModel extends ViewModel { /// The list of measures List get measures { // sort them first - var mapEntries = _samplingTable.entries.toList() - ..sort((b, a) => a.value.compareTo(b.value)); + var mapEntries = _samplingTable.entries.toList()..sort((b, a) => a.value.compareTo(b.value)); Map sortedTasksTable = {}..addEntries(mapEntries); // and map to the [TaskCount] model - List tasksList = sortedTasksTable.entries - .map((entry) => MeasureCount(entry.key, entry.value)) - .toList(); + List tasksList = + sortedTasksTable.entries.map((entry) => MeasureCount(entry.key, entry.value)).toList(); return tasksList; } diff --git a/lib/view_models/cards/mobility_data_model.dart b/lib/view_models/cards/mobility_data_model.dart index e53a15c4..03dcd0c7 100644 --- a/lib/view_models/cards/mobility_data_model.dart +++ b/lib/view_models/cards/mobility_data_model.dart @@ -7,27 +7,22 @@ class MobilityCardViewModel extends SerializableViewModel { Map get weekData => model.weekMobility; /// Stream of mobility [DataPoint] measures. - Stream? get mobilityEvents => controller?.measurements - .where((measurement) => measurement.data is Mobility); + Stream? get mobilityEvents => + controller?.measurements.where((measurement) => measurement.data is Mobility); - final DateTime _startOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); - final DateTime _endOfWeek = DateTime.now() - .subtract(Duration(days: DateTime.now().weekday - 1)) - .add(Duration(days: 6)); + final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); + final DateTime _endOfWeek = + DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)).add(Duration(days: 6)); String get startOfWeek => DateFormat('dd').format(_startOfWeek); String get endOfWeek => DateFormat('dd').format(_endOfWeek); - String get currentMonth => - DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); + String get currentMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); - String get nextMonth => DateFormat('MMM') - .format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); + String get nextMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); - String get currentYear => - DateFormat('yyyy').format(DateTime(DateTime.now().year)); + String get currentYear => DateFormat('yyyy').format(DateTime(DateTime.now().year)); MobilityCardViewModel(); @override @@ -64,18 +59,12 @@ class WeeklyMobility extends DataModel { void setMobilityFeatures(Mobility data) { DateTime day = data.date ?? DateTime.now(); - weekMobility[day.weekday] = DailyMobility( - day.weekday, - data.numberOfPlaces ?? 0, - data.homeStay != null && data.homeStay! > 0 - ? (100 * (data.homeStay!)).toInt() - : 0, - data.distanceTraveled ?? 0); + weekMobility[day.weekday] = DailyMobility(day.weekday, data.numberOfPlaces ?? 0, + data.homeStay != null && data.homeStay! > 0 ? (100 * (data.homeStay!)).toInt() : 0, data.distanceTraveled ?? 0); } @override - WeeklyMobility fromJson(Map json) => - _$WeeklyMobilityFromJson(json); + WeeklyMobility fromJson(Map json) => _$WeeklyMobilityFromJson(json); @override Map toJson() => _$WeeklyMobilityToJson(this); } @@ -90,6 +79,5 @@ class DailyMobility extends DailyMeasure { DailyMobility(super.weekday, this.places, this.homeStay, this.distance); Map toJson() => _$DailyMobilityToJson(this); - static DailyMobility fromJson(Map json) => - _$DailyMobilityFromJson(json); + static DailyMobility fromJson(Map json) => _$DailyMobilityFromJson(json); } diff --git a/lib/view_models/cards/steps_data_model.dart b/lib/view_models/cards/steps_data_model.dart index 02ecf2b4..da57763d 100644 --- a/lib/view_models/cards/steps_data_model.dart +++ b/lib/view_models/cards/steps_data_model.dart @@ -12,28 +12,23 @@ class StepsCardViewModel extends SerializableViewModel { /// The list of steps. List get steps => model.steps; - final DateTime _startOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); - final DateTime _endOfWeek = DateTime.now() - .subtract(Duration(days: DateTime.now().weekday - 1)) - .add(Duration(days: 6)); + final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); + final DateTime _endOfWeek = + DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)).add(Duration(days: 6)); String get startOfWeek => DateFormat('dd').format(_startOfWeek); String get endOfWeek => DateFormat('dd').format(_endOfWeek); - String get currentMonth => - DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); + String get currentMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month)); - String get nextMonth => DateFormat('MMM') - .format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); + String get nextMonth => DateFormat('MMM').format(DateTime(_startOfWeek.year, _startOfWeek.month + 1, 1)); - String get currentYear => - DateFormat('yyyy').format(DateTime(DateTime.now().year)); + String get currentYear => DateFormat('yyyy').format(DateTime(DateTime.now().year)); /// Stream of pedometer (step) [DataPoint] measures. - Stream? get pedometerEvents => controller?.measurements - .where((dataPoint) => dataPoint.data is StepCount); + Stream? get pedometerEvents => + controller?.measurements.where((dataPoint) => dataPoint.data is StepCount); @override void init(SmartphoneDeploymentController ctrl) { @@ -43,8 +38,7 @@ class StepsCardViewModel extends SerializableViewModel { pedometerEvents?.listen((pedometerDataPoint) { StepCount? step = pedometerDataPoint.data as StepCount?; if (_lastStep != null) { - model.increaseStepCount( - DateTime.now().weekday, step!.steps - _lastStep!.steps); + model.increaseStepCount(DateTime.now().weekday, step!.steps - _lastStep!.steps); } _lastStep = step; @@ -71,12 +65,9 @@ class WeeklySteps extends DataModel { } /// The list of steps listed pr. weekday. - List get steps => weeklySteps.entries - .map((entry) => DailySteps(entry.key, entry.value)) - .toList(); + List get steps => weeklySteps.entries.map((entry) => DailySteps(entry.key, entry.value)).toList(); - void increaseStepCount(int weekday, int steps) => - weeklySteps[weekday] = (weeklySteps[weekday] ?? 0) + steps; + void increaseStepCount(int weekday, int steps) => weeklySteps[weekday] = (weeklySteps[weekday] ?? 0) + steps; @override String toString() { @@ -86,8 +77,7 @@ class WeeklySteps extends DataModel { } @override - WeeklySteps fromJson(Map json) => - _$WeeklyStepsFromJson(json); + WeeklySteps fromJson(Map json) => _$WeeklyStepsFromJson(json); @override Map toJson() => _$WeeklyStepsToJson(this); } diff --git a/lib/view_models/cards/study_progress_data_model.dart b/lib/view_models/cards/study_progress_data_model.dart index 215dbbdb..ee2fa8f9 100644 --- a/lib/view_models/cards/study_progress_data_model.dart +++ b/lib/view_models/cards/study_progress_data_model.dart @@ -20,9 +20,8 @@ class StudyProgressCardViewModel extends ViewModel { Map get progressTable => _progressTable; /// The list of measures - List get progress => _progressTable.entries - .map((entry) => StudyProgress(entry.key, entry.value)) - .toList(); + List get progress => + _progressTable.entries.map((entry) => StudyProgress(entry.key, entry.value)).toList(); StudyProgressCardViewModel() : super(); diff --git a/lib/view_models/cards/task_data_model.dart b/lib/view_models/cards/task_data_model.dart index 047ed85e..dcc0b374 100644 --- a/lib/view_models/cards/task_data_model.dart +++ b/lib/view_models/cards/task_data_model.dart @@ -19,8 +19,7 @@ class TaskCardViewModel extends ViewModel { AppTaskController() .userTaskQueue - .where( - (task) => task.state == UserTaskState.done && task.type == taskType) + .where((task) => task.state == UserTaskState.done && task.type == taskType) .forEach((task) { if (!tasksTable.containsKey(task.title)) tasksTable[task.title] = 0; tasksTable[task.title] = tasksTable[task.title]! + 1; @@ -31,21 +30,17 @@ class TaskCardViewModel extends ViewModel { /// The total number of tasks done of type [taskType]. int get tasksDone => AppTaskController() .userTaskQueue - .where( - (task) => task.state == UserTaskState.done && task.type == taskType) + .where((task) => task.state == UserTaskState.done && task.type == taskType) .length; /// The list of [TaskCount]s done. List get taskCount { // sort them first - var mapEntries = tasksTable.entries.toList() - ..sort((b, a) => a.value.compareTo(b.value)); + var mapEntries = tasksTable.entries.toList()..sort((b, a) => a.value.compareTo(b.value)); Map sortedTasksTable = {}..addEntries(mapEntries); // and map to the [TaskCount] model - List tasksList = sortedTasksTable.entries - .map((entry) => TaskCount(entry.key, entry.value)) - .toList(); + List tasksList = sortedTasksTable.entries.map((entry) => TaskCount(entry.key, entry.value)).toList(); return tasksList; } diff --git a/lib/view_models/data_visualization_page_model.dart b/lib/view_models/data_visualization_page_model.dart index 407a63bc..cfc1a9ff 100644 --- a/lib/view_models/data_visualization_page_model.dart +++ b/lib/view_models/data_visualization_page_model.dart @@ -3,21 +3,14 @@ part of carp_study_app; class DataVisualizationPageViewModel extends ViewModel { final ActivityCardViewModel _activityCardDataModel = ActivityCardViewModel(); final StepsCardViewModel _stepsCardDataModel = StepsCardViewModel(); - final MeasurementsCardViewModel _measuresCardDataModel = - MeasurementsCardViewModel(); + final MeasurementsCardViewModel _measuresCardDataModel = MeasurementsCardViewModel(); final MobilityCardViewModel _mobilityCardDataModel = MobilityCardViewModel(); - final TaskCardViewModel _surveysCardDataModel = - TaskCardViewModel(SurveyUserTask.SURVEY_TYPE); - final TaskCardViewModel _audioCardDataModel = - TaskCardViewModel(SurveyUserTask.AUDIO_TYPE); - final TaskCardViewModel _videoCardDataModel = - TaskCardViewModel(SurveyUserTask.VIDEO_TYPE); - final TaskCardViewModel _imageCardDataModel = - TaskCardViewModel(SurveyUserTask.IMAGE_TYPE); - final StudyProgressCardViewModel _studyProgressCardDataModel = - StudyProgressCardViewModel(); - final HeartRateCardViewModel _heartRateCardDataModel = - HeartRateCardViewModel(); + final TaskCardViewModel _surveysCardDataModel = TaskCardViewModel(SurveyUserTask.SURVEY_TYPE); + final TaskCardViewModel _audioCardDataModel = TaskCardViewModel(SurveyUserTask.AUDIO_TYPE); + final TaskCardViewModel _videoCardDataModel = TaskCardViewModel(SurveyUserTask.VIDEO_TYPE); + final TaskCardViewModel _imageCardDataModel = TaskCardViewModel(SurveyUserTask.IMAGE_TYPE); + final StudyProgressCardViewModel _studyProgressCardDataModel = StudyProgressCardViewModel(); + final HeartRateCardViewModel _heartRateCardDataModel = HeartRateCardViewModel(); ActivityCardViewModel get activityCardDataModel => _activityCardDataModel; StepsCardViewModel get stepsCardDataModel => _stepsCardDataModel; @@ -29,22 +22,17 @@ class DataVisualizationPageViewModel extends ViewModel { TaskCardViewModel get imageCardDataModel => _imageCardDataModel; HeartRateCardViewModel get heartRateCardDataModel => _heartRateCardDataModel; - StudyProgressCardViewModel get studyProgressCardDataModel => - _studyProgressCardDataModel; + StudyProgressCardViewModel get studyProgressCardDataModel => _studyProgressCardDataModel; /// 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. - int get daysInStudy => (bloc.studyStartTimestamp != null) - ? DateTime.now().difference(bloc.studyStartTimestamp!).inDays + 1 - : 0; + int get daysInStudy => + (bloc.studyStartTimestamp != null) ? DateTime.now().difference(bloc.studyStartTimestamp!).inDays + 1 : 0; /// The number of tasks completed so far. - int get taskCompleted => AppTaskController() - .userTaskQueue - .where((task) => task.state == UserTaskState.done) - .length; + int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; DataVisualizationPageViewModel(); diff --git a/lib/view_models/device_view_models.dart b/lib/view_models/device_view_models.dart index 4ef202f0..03bbce31 100644 --- a/lib/view_models/device_view_models.dart +++ b/lib/view_models/device_view_models.dart @@ -18,8 +18,7 @@ class DeviceViewModel extends ViewModel { String? get type => deviceManager.type; /// A printer-friendly name for this [type] of device. - String get typeName => - _deviceTypeName[type!] ?? 'pages.devices.type.unknown.name'; + String get typeName => _deviceTypeName[type!] ?? 'pages.devices.type.unknown.name'; /// The status of this device. DeviceStatus get status => deviceManager.status; @@ -43,16 +42,14 @@ class DeviceViewModel extends ViewModel { } /// A printer-friendly description of this device. - String get description => - '${_deviceTypeDescription[type!]} - ${status.name}\n$batteryLevel% battery remaining.'; + String get description => '${_deviceTypeDescription[type!]} - ${status.name}\n$batteryLevel% battery remaining.'; /// The battery level of this device. /// /// Only relevant if this device is a [HardwareDeviceManager]. /// Returns null if not a hardware device. - int? get batteryLevel => (deviceManager is HardwareDeviceManager) - ? (deviceManager as HardwareDeviceManager).batteryLevel - : null; + int? get batteryLevel => + (deviceManager is HardwareDeviceManager) ? (deviceManager as HardwareDeviceManager).batteryLevel : null; /// The stream of battery level events. /// @@ -77,13 +74,11 @@ class DeviceViewModel extends ViewModel { /// Instructions to the user on how to connect to this type of device. String? get connectionInstructions => _deviceConnectionInstructions[type!]; - String? get connectionInstructionsImage => - _deviceConnectionInstructionsImage[type!]; + String? get connectionInstructionsImage => _deviceConnectionInstructionsImage[type!]; PolarDeviceType get polarDeviceType { if (deviceManager is PolarDeviceManager) { - return (deviceManager as PolarDeviceManager).configuration?.deviceType ?? - PolarDeviceType.UNKNOWN; + return (deviceManager as PolarDeviceManager).configuration?.deviceType ?? PolarDeviceType.UNKNOWN; } else { return PolarDeviceType.UNKNOWN; } @@ -91,10 +86,7 @@ class DeviceViewModel extends ViewModel { MovesenseDeviceType get movesenseDeviceType { if (deviceManager is MovesenseDeviceManager) { - return (deviceManager as MovesenseDeviceManager) - .configuration - ?.deviceType ?? - MovesenseDeviceType.UNKNOWN; + return (deviceManager as MovesenseDeviceManager).configuration?.deviceType ?? MovesenseDeviceType.UNKNOWN; } else { return MovesenseDeviceType.UNKNOWN; } @@ -103,18 +95,15 @@ class DeviceViewModel extends ViewModel { /// Display information about this phone. Map get phoneInfo => { 'name': '${DeviceInfo().deviceID}', - 'model': - '${DeviceInfo().deviceModel} (${DeviceInfo().deviceManufacturer?.toUpperCase()})', + 'model': '${DeviceInfo().deviceModel} (${DeviceInfo().deviceManufacturer?.toUpperCase()})', 'version': 'SDK ${DeviceInfo().sdk}', }; /// Map a selected device to the device in the protocol and connect to it. void connectToDevice(BluetoothDevice selectedDevice) { if (deviceManager is BTLEDeviceManager) { - (deviceManager as BTLEDeviceManager).btleAddress = - selectedDevice.remoteId.str; - (deviceManager as BTLEDeviceManager).btleName = - selectedDevice.platformName; + (deviceManager as BTLEDeviceManager).btleAddress = selectedDevice.remoteId.str; + (deviceManager as BTLEDeviceManager).btleName = selectedDevice.platformName; } Sensing().controller?.saveDeployment(); @@ -134,8 +123,7 @@ class DeviceViewModel extends ViewModel { Sensing().controller?.saveDeployment(); } catch (error) { - warning( - "$runtimeType - Error disconnecting to device '${deviceManager.id}' - $error."); + warning("$runtimeType - Error disconnecting to device '${deviceManager.id}' - $error."); } } } @@ -197,28 +185,22 @@ const Map _deviceTypeIcon = { const Map _deviceStatusIcon = { DeviceStatus.initialized: "pages.devices.status.action.connect", - DeviceStatus.connecting: Icon(Icons.bluetooth_searching_rounded, - color: CACHET.DARK_BLUE, size: 30), - DeviceStatus.connected: - Icon(Icons.bluetooth_rounded, color: CACHET.GREEN_1, size: 30), + DeviceStatus.connecting: Icon(Icons.bluetooth_searching_rounded, color: CACHET.DARK_BLUE, size: 30), + DeviceStatus.connected: Icon(Icons.bluetooth_rounded, color: CACHET.GREEN_1, size: 30), DeviceStatus.disconnected: "pages.devices.status.action.connect", DeviceStatus.paired: "pages.devices.status.action.connect", DeviceStatus.error: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), - DeviceStatus.unknown: - Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), + DeviceStatus.unknown: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), }; const Map _serviceStatusIcon = { DeviceStatus.initialized: "pages.devices.status.action.connect", - DeviceStatus.connecting: - Icon(Icons.sensors_off_rounded, color: CACHET.GREEN_1, size: 30), - DeviceStatus.connected: - Icon(Icons.sensors_rounded, color: CACHET.GREEN_1, size: 30), + DeviceStatus.connecting: Icon(Icons.sensors_off_rounded, color: CACHET.GREEN_1, size: 30), + DeviceStatus.connected: Icon(Icons.sensors_rounded, color: CACHET.GREEN_1, size: 30), DeviceStatus.disconnected: "pages.devices.status.action.connect", DeviceStatus.paired: "pages.devices.status.action.connect", DeviceStatus.error: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), - DeviceStatus.unknown: - Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), + DeviceStatus.unknown: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), }; const Map _deviceStatusText = { diff --git a/lib/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index f243ed4c..7fdc2325 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -1,10 +1,8 @@ part of carp_study_app; class InvitationsViewModel extends ViewModel { - List get invitations => - bloc.backend.invitations; + List get invitations => bloc.backend.invitations; ActiveParticipationInvitation getInvitation(String invitationId) => - invitations.firstWhere((invitation) => - invitation.participation.participantId == invitationId); + invitations.firstWhere((invitation) => invitation.participation.participantId == invitationId); } diff --git a/lib/view_models/participant_data_page_model.dart b/lib/view_models/participant_data_page_model.dart index d3d4aab1..b3a33c5b 100644 --- a/lib/view_models/participant_data_page_model.dart +++ b/lib/view_models/participant_data_page_model.dart @@ -1,8 +1,7 @@ part of carp_study_app; class ParticipantDataPageViewModel extends ViewModel { - Set get expectedData => - bloc.expectedParticipantData; + Set get expectedData => bloc.expectedParticipantData; late TextEditingController _address1Controller; late TextEditingController _address2Controller; diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_page_model.dart index a78f5e6f..0229b7f4 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -10,19 +10,15 @@ class ProfilePageViewModel extends ViewModel { String get studyId => bloc.deployment?.studyId ?? ''; String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; - String get studyDeploymentTitle => - bloc.deployment?.studyDescription?.title ?? ''; + String get studyDeploymentTitle => bloc.deployment?.studyDescription?.title ?? ''; String get participantId => bloc.deployment?.participantId ?? ''; String get participantRole => bloc.deployment?.participantRoleName ?? ''; String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; - String get responsibleEmail => - bloc.deployment?.studyDescription?.responsible?.email ?? 'study@carp.dk'; + String get responsibleEmail => bloc.deployment?.studyDescription?.responsible?.email ?? 'study@carp.dk'; String get privacyPolicyUrl => - bloc.deployment?.studyDescription?.privacyPolicyUrl ?? - 'https://carp.dk/privacy-policy-app/'; - String get studyDescriptionUrl => - bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; + bloc.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; + String get studyDescriptionUrl => bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; String get deviceID => DeviceInfo().deviceID ?? ''; String get currentServer => bloc.backend.uri.toString(); diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index 94ad2aab..4e57b56d 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -4,35 +4,28 @@ part of carp_study_app; /// news articles to be shown as part of the study. class StudyPageViewModel extends ViewModel { String get title => bloc.deployment?.studyDescription?.title ?? 'Unnamed'; - String get description => - bloc.deployment?.studyDescription?.description ?? ''; + String get description => bloc.deployment?.studyDescription?.description ?? ''; String get purpose => bloc.deployment?.studyDescription?.purpose ?? ''; Image get image => Image.asset('assets/images/exercise.png'); String? get userID => bloc.deployment?.participantId; String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; - String get responsibleName => - bloc.deployment?.studyDescription?.responsible?.name ?? ''; - String get responsibleEmail => - bloc.deployment?.studyDescription?.responsible?.email ?? ''; - String get studyDescriptionUrl => - bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; + String get responsibleName => bloc.deployment?.studyDescription?.responsible?.name ?? ''; + String get responsibleEmail => bloc.deployment?.studyDescription?.responsible?.email ?? ''; + String get studyDescriptionUrl => bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; String get privacyPolicyUrl => - bloc.deployment?.studyDescription?.privacyPolicyUrl ?? - 'https://carp.dk/privacy-policy-app/'; + bloc.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; String get piTitle => bloc.deployment?.responsible?.title ?? ''; String get piName => bloc.deployment?.responsible?.name ?? ''; String get piAddress => bloc.deployment?.responsible?.address ?? ''; String get piEmail => bloc.deployment?.responsible?.email ?? ''; String get piAffiliation => - bloc.deployment?.responsible?.affiliation ?? - 'Department of Health Technology, Technical University of Denmark'; + bloc.deployment?.responsible?.affiliation ?? 'Department of Health Technology, Technical University of Denmark'; String get participantRole => bloc.deployment?.participantRoleName ?? ''; String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; - Future get studyDeploymentStatus => - bloc.studyDeploymentStatus; + Future get studyDeploymentStatus => bloc.studyDeploymentStatus; /// The stream of messages (count) Stream get messageStream => bloc.messageStream; diff --git a/lib/view_models/tasklist_page_model.dart b/lib/view_models/tasklist_page_model.dart index 258d5727..54986e7d 100644 --- a/lib/view_models/tasklist_page_model.dart +++ b/lib/view_models/tasklist_page_model.dart @@ -39,14 +39,9 @@ class TaskListPageViewModel extends ViewModel { /// [StudyDeploymentStatus]. /// Returns 0 if the study deployment status is not available. int get daysInStudy => (Sensing().studyDeploymentStatus != null) - ? DateTime.now() - .difference(Sensing().studyDeploymentStatus!.createdOn) - .inDays + ? DateTime.now().difference(Sensing().studyDeploymentStatus!.createdOn).inDays : 0; /// The number of tasks completed so far. - int get taskCompleted => AppTaskController() - .userTaskQueue - .where((task) => task.state == UserTaskState.done) - .length; + int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; } diff --git a/lib/view_models/user_tasks.dart b/lib/view_models/user_tasks.dart index 2b66f65c..6ce14b68 100644 --- a/lib/view_models/user_tasks.dart +++ b/lib/view_models/user_tasks.dart @@ -36,9 +36,7 @@ class AudioUserTask extends UserTask { int ongoingRecordingDuration = 60; AudioUserTask(AppTaskExecutor executor) : super(executor) { - recordingDuration = (executor.task.minutesToComplete != null) - ? executor.task.minutesToComplete! * 60 - : 60; + recordingDuration = (executor.task.minutesToComplete != null) ? executor.task.minutesToComplete! * 60 : 60; } @override @@ -137,15 +135,11 @@ class VideoUserTask extends UserTask { // create the media measurement ... media = switch (_mediaType) { MediaType.image => ImageMedia( - filename: _file!.path, - startRecordingTime: _startRecordingTime!, - endRecordingTime: _endRecordingTime) + filename: _file!.path, startRecordingTime: _startRecordingTime!, endRecordingTime: _endRecordingTime) ..filename = _file!.path.split("/").last ..path = _file!.path, MediaType.video => VideoMedia( - filename: _file!.path, - startRecordingTime: _startRecordingTime!, - endRecordingTime: _endRecordingTime) + filename: _file!.path, startRecordingTime: _startRecordingTime!, endRecordingTime: _endRecordingTime) ..filename = _file!.path.split("/").last ..path = _file!.path, _ => null, diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 9585bb84..d0b29f69 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -76,8 +76,7 @@ abstract class SerializableViewModel extends ViewModel { }); // save the data model on a regular basis. - _persistenceTimer = - Timer.periodic(const Duration(minutes: 3), (_) => save()); + _persistenceTimer = Timer.periodic(const Duration(minutes: 3), (_) => save()); /// Check if we are running in a test environment. /// If so, do not listen to app lifecycle events. @@ -171,9 +170,7 @@ class DailyMeasure { /// Get the localized name of the [weekday]. @override - String toString() => DateFormat('EEEE') - .format(DateTime(2021, 2, 7).add(Duration(days: weekday))) - .substring(0, 3); + String toString() => DateFormat('EEEE').format(DateTime(2021, 2, 7).add(Duration(days: weekday))).substring(0, 3); } /// A measure for a specific hour of the day. [hour] and [minute] is the time of the day in 24 hour format. @@ -190,33 +187,25 @@ class HourlyMeasure { /// The view model for the entire app. class CarpStudyAppViewModel extends ViewModel { - final DataVisualizationPageViewModel _dataVisualizationPageViewModel = - DataVisualizationPageViewModel(); + final DataVisualizationPageViewModel _dataVisualizationPageViewModel = DataVisualizationPageViewModel(); final StudyPageViewModel _studyPageViewModel = StudyPageViewModel(); final TaskListPageViewModel _taskListPageViewModel = TaskListPageViewModel(); final ProfilePageViewModel _profilePageViewModel = ProfilePageViewModel(); - final DeviceListPageViewModel _devicesPageViewModel = - DeviceListPageViewModel(); + final DeviceListPageViewModel _devicesPageViewModel = DeviceListPageViewModel(); final InvitationsViewModel _invitationsListViewModel = InvitationsViewModel(); - final InformedConsentViewModel _informedConsentViewModel = - InformedConsentViewModel(); - final ParticipantDataPageViewModel _participantDataPageViewModel = - ParticipantDataPageViewModel(); + final InformedConsentViewModel _informedConsentViewModel = InformedConsentViewModel(); + final ParticipantDataPageViewModel _participantDataPageViewModel = ParticipantDataPageViewModel(); CarpStudyAppViewModel() : super(); - DataVisualizationPageViewModel get dataVisualizationPageViewModel => - _dataVisualizationPageViewModel; + DataVisualizationPageViewModel get dataVisualizationPageViewModel => _dataVisualizationPageViewModel; StudyPageViewModel get studyPageViewModel => _studyPageViewModel; TaskListPageViewModel get taskListPageViewModel => _taskListPageViewModel; ProfilePageViewModel get profilePageViewModel => _profilePageViewModel; DeviceListPageViewModel get devicesPageViewModel => _devicesPageViewModel; - InvitationsViewModel get invitationsListViewModel => - _invitationsListViewModel; - InformedConsentViewModel get informedConsentViewModel => - _informedConsentViewModel; - ParticipantDataPageViewModel get participantDataPageViewModel => - _participantDataPageViewModel; + InvitationsViewModel get invitationsListViewModel => _invitationsListViewModel; + InformedConsentViewModel get informedConsentViewModel => _informedConsentViewModel; + ParticipantDataPageViewModel get participantDataPageViewModel => _participantDataPageViewModel; @override void init(SmartphoneDeploymentController ctrl) { diff --git a/test/cams_app_test.dart b/test/cams_app_test.dart index 120ac75a..aa89766e 100644 --- a/test/cams_app_test.dart +++ b/test/cams_app_test.dart @@ -42,11 +42,9 @@ void main() { group("Local Study Protocol Manager", () { // skipping this test since it is throwing strange "asUnmodifiableView" errors....? test('JSON File -> StudyProtocol', skip: true, () async { - final plainJson = - File('test/json/study_protocol.json').readAsStringSync(); + final plainJson = File('test/json/study_protocol.json').readAsStringSync(); - SmartphoneStudyProtocol.fromJson( - json.decode(plainJson) as Map); + SmartphoneStudyProtocol.fromJson(json.decode(plainJson) as Map); }); }); } diff --git a/test/heart_rate_data_model_test.dart b/test/heart_rate_data_model_test.dart index a723ca6f..a3df3ed2 100644 --- a/test/heart_rate_data_model_test.dart +++ b/test/heart_rate_data_model_test.dart @@ -25,18 +25,15 @@ void main() { }); group('init', () { group('should listen to heart rate events', () { - final mockSmartphoneDeploymentController = - MockSmartphoneDeploymentController(); + final mockSmartphoneDeploymentController = MockSmartphoneDeploymentController(); final mockPolarHRSample = MockPolarHRSample(); final mockPolarHRDatum = MockPolarHR(); final mockMeasurement = MockMeasurement(); final viewModel = HeartRateCardViewModel(); - final heartRateStreamController = - StreamController.broadcast(); + final heartRateStreamController = StreamController.broadcast(); setUp(() { - when(mockSmartphoneDeploymentController.measurements) - .thenAnswer((_) => heartRateStreamController.stream); + when(mockSmartphoneDeploymentController.measurements).thenAnswer((_) => heartRateStreamController.stream); viewModel.init(mockSmartphoneDeploymentController); }); @@ -55,10 +52,8 @@ void main() { await Future.delayed(const Duration(milliseconds: 100)); expect(viewModel.currentHeartRate, equals(80.0)); expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(80, 80))); - expect( - viewModel.hourlyHeartRate, - equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 80)) - .hourlyHeartRate)); + expect(viewModel.hourlyHeartRate, + equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 80)).hourlyHeartRate)); }); test('with multiple events', () async { // Add a heart rate data point to the stream @@ -80,9 +75,7 @@ void main() { expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(60, 90))); expect( viewModel.hourlyHeartRate, - equals((HourlyHeartRate() - .addHeartRate(DateTime.now().hour, 60) - .addHeartRate(DateTime.now().hour, 90)) + equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 60).addHeartRate(DateTime.now().hour, 90)) .hourlyHeartRate)); }); test('with events with data that is 0', () async { @@ -95,10 +88,8 @@ void main() { await Future.delayed(const Duration(milliseconds: 100)); expect(viewModel.currentHeartRate, equals(null)); - expect( - viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(null, null))); - expect(viewModel.hourlyHeartRate, - equals((HourlyHeartRate()).hourlyHeartRate)); + expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(null, null))); + expect(viewModel.hourlyHeartRate, equals((HourlyHeartRate()).hourlyHeartRate)); // expect(viewModel.skinContact, equals(false)); }); test('with contactStatus being true', () async { @@ -129,8 +120,7 @@ void main() { hr.hourlyHeartRate[13] = HeartRateMinMaxPrHour(75, 85); hr.maxHeartRate = 85; hr.minHeartRate = 70; - hr.lastUpdated = - DateTime.now().subtract(const Duration(days: 1)); // yesterday + hr.lastUpdated = DateTime.now().subtract(const Duration(days: 1)); // yesterday // call resetDataAtMidnight hr.resetDataAtMidnight(); diff --git a/test/heart_rate_data_model_test.mocks.dart b/test/heart_rate_data_model_test.mocks.dart index 6d4076ff..41e4e452 100644 --- a/test/heart_rate_data_model_test.mocks.dart +++ b/test/heart_rate_data_model_test.mocks.dart @@ -28,8 +28,7 @@ import 'package:permission_handler/permission_handler.dart' as _i5; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeDeviceController_0 extends _i1.SmartFake - implements _i2.DeviceController { +class _FakeDeviceController_0 extends _i1.SmartFake implements _i2.DeviceController { _FakeDeviceController_0( Object parent, Invocation parentInvocation, @@ -39,8 +38,7 @@ class _FakeDeviceController_0 extends _i1.SmartFake ); } -class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake - implements _i2.SmartphoneDeploymentExecutor { +class _FakeSmartphoneDeploymentExecutor_1 extends _i1.SmartFake implements _i2.SmartphoneDeploymentExecutor { _FakeSmartphoneDeploymentExecutor_1( Object parent, Invocation parentInvocation, @@ -60,8 +58,7 @@ class _FakeData_2 extends _i1.SmartFake implements _i3.Data { ); } -class _FakeDeploymentService_3 extends _i1.SmartFake - implements _i3.DeploymentService { +class _FakeDeploymentService_3 extends _i1.SmartFake implements _i3.DeploymentService { _FakeDeploymentService_3( Object parent, Invocation parentInvocation, @@ -71,8 +68,7 @@ class _FakeDeploymentService_3 extends _i1.SmartFake ); } -class _FakeHeartRateMinMaxPrHour_4 extends _i1.SmartFake - implements _i4.HeartRateMinMaxPrHour { +class _FakeHeartRateMinMaxPrHour_4 extends _i1.SmartFake implements _i4.HeartRateMinMaxPrHour { _FakeHeartRateMinMaxPrHour_4( Object parent, Invocation parentInvocation, @@ -82,8 +78,7 @@ class _FakeHeartRateMinMaxPrHour_4 extends _i1.SmartFake ); } -class _FakeHourlyHeartRate_5 extends _i1.SmartFake - implements _i4.HourlyHeartRate { +class _FakeHourlyHeartRate_5 extends _i1.SmartFake implements _i4.HourlyHeartRate { _FakeHourlyHeartRate_5( Object parent, Invocation parentInvocation, @@ -126,8 +121,7 @@ class _FakeDataModel_8 extends _i1.SmartFake implements _i4.DataModel { /// A class which mocks [SmartphoneDeploymentController]. /// /// See the documentation for Mockito's code generation for more information. -class MockSmartphoneDeploymentController extends _i1.Mock - implements _i2.SmartphoneDeploymentController { +class MockSmartphoneDeploymentController extends _i1.Mock implements _i2.SmartphoneDeploymentController { @override _i2.DeviceController get deviceRegistry => (super.noSuchMethod( Invocation.getter(#deviceRegistry), @@ -142,8 +136,7 @@ class MockSmartphoneDeploymentController extends _i1.Mock ) as _i2.DeviceController); @override - Map<_i5.Permission, _i5.PermissionStatus> get permissions => - (super.noSuchMethod( + Map<_i5.Permission, _i5.PermissionStatus> get permissions => (super.noSuchMethod( Invocation.getter(#permissions), returnValue: <_i5.Permission, _i5.PermissionStatus>{}, returnValueForMissingStub: <_i5.Permission, _i5.PermissionStatus>{}, @@ -251,17 +244,14 @@ class MockSmartphoneDeploymentController extends _i1.Mock ) as bool); @override - List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> - get remainingDevicesToRegister => (super.noSuchMethod( - Invocation.getter(#remainingDevicesToRegister), - returnValue: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], - returnValueForMissingStub: <_i3 - .DeviceConfiguration<_i3.DeviceRegistration>>[], - ) as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); + List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> get remainingDevicesToRegister => (super.noSuchMethod( + Invocation.getter(#remainingDevicesToRegister), + returnValue: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], + returnValueForMissingStub: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], + ) as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); @override - set deployment(_i3.PrimaryDeviceDeployment? _deployment) => - super.noSuchMethod( + set deployment(_i3.PrimaryDeviceDeployment? _deployment) => super.noSuchMethod( Invocation.setter( #deployment, _deployment, @@ -270,8 +260,7 @@ class MockSmartphoneDeploymentController extends _i1.Mock ); @override - set deviceRegistry(_i3.DeviceDataCollectorFactory? _deviceRegistry) => - super.noSuchMethod( + set deviceRegistry(_i3.DeviceDataCollectorFactory? _deviceRegistry) => super.noSuchMethod( Invocation.setter( #deviceRegistry, _deviceRegistry, @@ -280,8 +269,7 @@ class MockSmartphoneDeploymentController extends _i1.Mock ); @override - set deploymentService(_i3.DeploymentService? _deploymentService) => - super.noSuchMethod( + set deploymentService(_i3.DeploymentService? _deploymentService) => super.noSuchMethod( Invocation.setter( #deploymentService, _deploymentService, @@ -290,8 +278,7 @@ class MockSmartphoneDeploymentController extends _i1.Mock ); @override - set deploymentStatus(_i3.StudyDeploymentStatus? _deploymentStatus) => - super.noSuchMethod( + set deploymentStatus(_i3.StudyDeploymentStatus? _deploymentStatus) => super.noSuchMethod( Invocation.setter( #deploymentStatus, _deploymentStatus, @@ -309,8 +296,7 @@ class MockSmartphoneDeploymentController extends _i1.Mock ); @override - _i7.Stream<_i3.Measurement> measurementsByType(String? type) => - (super.noSuchMethod( + _i7.Stream<_i3.Measurement> measurementsByType(String? type) => (super.noSuchMethod( Invocation.method( #measurementsByType, [type], @@ -357,9 +343,7 @@ class MockSmartphoneDeploymentController extends _i1.Mock ); @override - void initializeDevice( - _i3.DeviceConfiguration<_i3.DeviceRegistration>? configuration) => - super.noSuchMethod( + void initializeDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? configuration) => super.noSuchMethod( Invocation.method( #initializeDevice, [configuration], @@ -387,17 +371,14 @@ class MockSmartphoneDeploymentController extends _i1.Mock ) as _i7.Future); @override - _i7.Future<_i3.StudyStatus> tryDeployment({bool? useCached = true}) => - (super.noSuchMethod( + _i7.Future<_i3.StudyStatus> tryDeployment({bool? useCached = true}) => (super.noSuchMethod( Invocation.method( #tryDeployment, [], {#useCached: useCached}, ), - returnValue: _i7.Future<_i3.StudyStatus>.value( - _i3.StudyStatus.DeploymentNotStarted), - returnValueForMissingStub: _i7.Future<_i3.StudyStatus>.value( - _i3.StudyStatus.DeploymentNotStarted), + returnValue: _i7.Future<_i3.StudyStatus>.value(_i3.StudyStatus.DeploymentNotStarted), + returnValueForMissingStub: _i7.Future<_i3.StudyStatus>.value(_i3.StudyStatus.DeploymentNotStarted), ) as _i7.Future<_i3.StudyStatus>); @override @@ -487,20 +468,17 @@ class MockSmartphoneDeploymentController extends _i1.Mock ) as _i7.Future); @override - _i7.Future<_i3.StudyDeploymentStatus?> getStudyDeploymentStatus() => - (super.noSuchMethod( + _i7.Future<_i3.StudyDeploymentStatus?> getStudyDeploymentStatus() => (super.noSuchMethod( Invocation.method( #getStudyDeploymentStatus, [], ), returnValue: _i7.Future<_i3.StudyDeploymentStatus?>.value(), - returnValueForMissingStub: - _i7.Future<_i3.StudyDeploymentStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i3.StudyDeploymentStatus?>.value(), ) as _i7.Future<_i3.StudyDeploymentStatus?>); @override - _i7.Future tryRegisterConnectedDevice( - _i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => + _i7.Future tryRegisterConnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => (super.noSuchMethod( Invocation.method( #tryRegisterConnectedDevice, @@ -511,8 +489,7 @@ class MockSmartphoneDeploymentController extends _i1.Mock ) as _i7.Future); @override - _i7.Future tryRegisterRemainingDevicesToRegister() => - (super.noSuchMethod( + _i7.Future tryRegisterRemainingDevicesToRegister() => (super.noSuchMethod( Invocation.method( #tryRegisterRemainingDevicesToRegister, [], @@ -525,11 +502,9 @@ class MockSmartphoneDeploymentController extends _i1.Mock /// A class which mocks [HeartRateCardViewModel]. /// /// See the documentation for Mockito's code generation for more information. -class MockHeartRateCardViewModel extends _i1.Mock - implements _i4.HeartRateCardViewModel { +class MockHeartRateCardViewModel extends _i1.Mock implements _i4.HeartRateCardViewModel { @override - Map get hourlyHeartRate => - (super.noSuchMethod( + Map get hourlyHeartRate => (super.noSuchMethod( Invocation.getter(#hourlyHeartRate), returnValue: {}, returnValueForMissingStub: {}, @@ -568,8 +543,7 @@ class MockHeartRateCardViewModel extends _i1.Mock this, Invocation.getter(#filename), )), - returnValueForMissingStub: - _i7.Future.value(_i6.dummyValue( + returnValueForMissingStub: _i7.Future.value(_i6.dummyValue( this, Invocation.getter(#filename), )), @@ -694,8 +668,7 @@ class MockHeartRateCardViewModel extends _i1.Mock /// See the documentation for Mockito's code generation for more information. class MockHourlyHeartRate extends _i1.Mock implements _i4.HourlyHeartRate { @override - Map get hourlyHeartRate => - (super.noSuchMethod( + Map get hourlyHeartRate => (super.noSuchMethod( Invocation.getter(#hourlyHeartRate), returnValue: {}, returnValueForMissingStub: {}, @@ -715,8 +688,7 @@ class MockHourlyHeartRate extends _i1.Mock implements _i4.HourlyHeartRate { ) as DateTime); @override - set hourlyHeartRate(Map? _hourlyHeartRate) => - super.noSuchMethod( + set hourlyHeartRate(Map? _hourlyHeartRate) => super.noSuchMethod( Invocation.setter( #hourlyHeartRate, _hourlyHeartRate, @@ -818,8 +790,7 @@ class MockHourlyHeartRate extends _i1.Mock implements _i4.HourlyHeartRate { ) as _i4.HourlyHeartRate); @override - _i4.HourlyHeartRate fromJson(Map? json) => - (super.noSuchMethod( + _i4.HourlyHeartRate fromJson(Map? json) => (super.noSuchMethod( Invocation.method( #fromJson, [json], From 3746a2e408f94a4f485808c0a33553a43d4b994f Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Thu, 11 Jun 2026 12:14:44 +0200 Subject: [PATCH 47/94] feat: migrate to CAMS 2.x (API level 2.0) Bump the whole CARP stack from the 1.x to the 2.x line (carp_core 2.1.2, carp_mobile_sensing 2.1.1, carp_webservices 4.0, carp_backend 2.0, all sampling packages 2.0/4.0) and adapt the app to the new APIs: - SmartphoneDeploymentController -> SmartphoneStudyController - getStudyRuntime(id) -> getStudyController(study); deployment now driven from the client manager (tryDeployment moved off the controller, and the controller is configured automatically - no controller.configure()) - start()/stop() -> resume()/pause(); ExecutorState.started -> .Resumed - two-arg removeStudy(id, role); configure() deviceController -> dataCollectorFactory - task-type constants moved to AppTask.*; SensorSamplingPackage.STEP_COUNT -> CarpDataTypes.STEP_COUNT; input-type constants -> InputType.* - DeviceManager.type -> deviceType, .id -> displayName; DeviceStatus .initialized -> .configured (error state removed); BTLE* -> BLE*; Polar/Movesense device-type access via the managers - participant id/role read from SmartphoneStudy; primary device resolved from deviceStatusList; consent manager methods -> *ConsentDocument - raise Dart SDK floor to 3.8 (required by carp 2.x codegen); bump connectivity_plus 7 and permission_handler 12 for transitive constraints On iOS, disable Swift Package Manager (flutter config in pubspec) so all native deps resolve through CocoaPods: the polar 7.x plugin otherwise pulls SwiftProtobuf via SPM while Movesense pulls it via CocoaPods, producing 6372 duplicate-symbol link errors. PolarBleSdk 6.14.0 now shares a single SwiftProtobuf pod with Movesense. Refs #571 Closes #601 --- ios/Podfile.lock | 167 +- .../xcshareddata/swiftpm/Package.resolved | 9 - .../xcshareddata/swiftpm/Package.resolved | 9 - lib/blocs/app_bloc.dart | 68 +- lib/blocs/sensing.dart | 67 +- lib/carp_study_app.dart | 61 +- lib/data/carp_backend.dart | 51 +- lib/data/local_participation_service.dart | 10 +- lib/data/local_resource_manager.dart | 43 +- lib/data/local_settings.dart | 46 +- lib/data/participant.dart | 17 +- lib/main.dart | 3 +- lib/main.g.dart | 103 +- lib/ui/cards/activity_card.dart | 104 +- lib/ui/cards/anonymous_card.dart | 13 +- lib/ui/cards/distance_card.dart | 104 +- lib/ui/cards/heart_rate_card.dart | 139 +- lib/ui/cards/media_card.dart | 13 +- lib/ui/cards/mobility_card.dart | 115 +- lib/ui/cards/scoreboard_card.dart | 85 +- lib/ui/cards/steps_card.dart | 101 +- lib/ui/cards/study_progress_card.dart | 19 +- lib/ui/cards/survey_card.dart | 82 +- lib/ui/carp_study_style.dart | 143 +- lib/ui/helpers.dart | 8 +- lib/ui/pages/data_visualization_page.dart | 34 +- lib/ui/pages/device_list_page.dart | 301 ++-- .../devices_page.authorization_dialog.dart | 29 +- ...evices_page.bluetooth_connection_page.dart | 104 +- .../devices_page.disconnection_dialog.dart | 25 +- .../devices_page.enable_bluetooth_dialog.dart | 24 +- .../devices_page.health_service_connect.dart | 26 +- lib/ui/pages/devices_page.list_title.dart | 22 +- lib/ui/pages/enable_connection_dialog.dart | 57 +- lib/ui/pages/error_page.dart | 14 +- lib/ui/pages/home_page.dart | 25 +- ...me_page.install_health_connect_dialog.dart | 14 +- lib/ui/pages/informed_consent_page.dart | 29 +- lib/ui/pages/invitation_list_page.dart | 36 +- lib/ui/pages/invitation_page.dart | 36 +- lib/ui/pages/login_page.dart | 163 +- lib/ui/pages/message_details_page.dart | 66 +- lib/ui/pages/process_message_page.dart | 130 +- lib/ui/pages/profile_page.dart | 256 ++- lib/ui/pages/qr_scanner.dart | 45 +- lib/ui/pages/study_details_page.dart | 137 +- lib/ui/pages/study_page.dart | 219 ++- lib/ui/pages/task_list_page.dart | 303 +--- lib/ui/tasks/audio_page.dart | 68 +- lib/ui/tasks/audio_task_page.dart | 136 +- lib/ui/tasks/camera_page.dart | 48 +- lib/ui/tasks/camera_task_page.dart | 184 +-- lib/ui/tasks/display_picture_page.dart | 30 +- lib/ui/tasks/participant_data_page.dart | 250 ++- lib/ui/widgets/battery_icon.dart | 67 +- lib/ui/widgets/carp_app_bar.dart | 14 +- lib/ui/widgets/charts_legend.dart | 10 +- lib/ui/widgets/details_banner.dart | 9 +- lib/ui/widgets/dialog_title.dart | 8 +- lib/ui/widgets/horizontal_bar.dart | 45 +- lib/ui/widgets/location_usage_dialog.dart | 19 +- lib/ui/widgets/studies_material.dart | 23 +- .../cards/activity_data_model.dart | 19 +- .../cards/heart_rate_data_model.dart | 24 +- .../cards/measurements_data_model.dart | 12 +- .../cards/mobility_data_model.dart | 15 +- lib/view_models/cards/steps_data_model.dart | 7 +- .../cards/study_progress_data_model.dart | 2 +- lib/view_models/cards/task_data_model.dart | 12 +- .../data_visualization_page_model.dart | 10 +- lib/view_models/device_view_models.dart | 89 +- .../informed_consent_page_model.dart | 4 +- lib/view_models/profile_page_model.dart | 8 +- lib/view_models/study_page_model.dart | 18 +- lib/view_models/user_tasks.dart | 52 +- lib/view_models/view_model.dart | 10 +- pubspec.lock | 144 +- pubspec.yaml | 41 +- test/exports.dart | 2 +- test/heart_rate_data_model_test.dart | 25 +- test/heart_rate_data_model_test.mocks.dart | 1421 ++++++----------- 81 files changed, 2658 insertions(+), 3843 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e9b69ddd..2089e041 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,20 +1,56 @@ PODS: - - audio_streamer (0.0.1): + - AppAuth (2.0.0): + - AppAuth/Core (= 2.0.0) + - AppAuth/ExternalUserAgent (= 2.0.0) + - AppAuth/Core (2.0.0) + - AppAuth/ExternalUserAgent (2.0.0): + - AppAuth/Core + - "appcheck (1.5.4+1)": + - Flutter + - audio_session (0.0.1): + - Flutter + - audio_streamer (4.3.0): + - Flutter + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - battery_plus (1.0.0): + - Flutter + - camera_avfoundation (0.0.1): + - Flutter + - connectivity_plus (0.0.1): - Flutter - dchs_flutter_beacon (0.6.5): - Flutter + - device_info_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_activity_recognition (0.0.1): - Flutter + - flutter_appauth (0.0.1): + - AppAuth (= 2.0.0) + - Flutter + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS + - flutter_local_notifications (0.0.1): + - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_sound (9.30.0): - Flutter - flutter_sound_core (= 9.30.0) - flutter_sound_core (9.30.0) + - flutter_timezone (0.0.1): + - Flutter - flutter_web_auth_2 (3.0.0): - Flutter - - health (12.2.1): + - health (13.3.1): + - Flutter + - just_audio (0.0.1): + - Flutter + - FlutterMacOS + - light (4.1.1): - Flutter - location (0.0.1): - Flutter @@ -23,45 +59,89 @@ PODS: - Movesense - SwiftProtobuf - Movesense (3.33.1) + - network_info_plus (0.0.1): + - Flutter - oidc_ios (0.0.1): - Flutter - open_settings_plus (0.0.1): - Flutter + - package_info_plus (0.4.5): + - Flutter + - pedometer (4.2.0): + - Flutter - permission_handler_apple (9.3.0): - Flutter - polar (0.0.1): - Flutter - - PolarBleSdk (~> 6.1.0) - - PolarBleSdk (6.1.0): + - PolarBleSdk (= 6.14.0) + - PolarBleSdk (6.14.0): - RxSwift (~> 6.8.0) - SwiftProtobuf (~> 1.0) - Zip (~> 2.1.2) + - qr_code_scanner_plus (0.2.6): + - Flutter - RxSwift (6.8.0) - - screen_state (1.0.0): + - screen_state (5.0.0): - Flutter + - sensors_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS - SwiftProtobuf (1.33.3) + - url_launcher_ios (0.0.1): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS - Zip (2.1.2) DEPENDENCIES: + - appcheck (from `.symlinks/plugins/appcheck/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) - audio_streamer (from `.symlinks/plugins/audio_streamer/ios`) + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - battery_plus (from `.symlinks/plugins/battery_plus/ios`) + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - dchs_flutter_beacon (from `.symlinks/plugins/dchs_flutter_beacon/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_activity_recognition (from `.symlinks/plugins/flutter_activity_recognition/ios`) + - flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - health (from `.symlinks/plugins/health/ios`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - light (from `.symlinks/plugins/light/ios`) - location (from `.symlinks/plugins/location/ios`) - mdsflutter (from `.symlinks/plugins/mdsflutter/ios`) - Movesense (from `https://bitbucket.org/movesense/movesense-mobile-lib/`) + - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - oidc_ios (from `.symlinks/plugins/oidc_ios/ios`) - open_settings_plus (from `.symlinks/plugins/open_settings_plus/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - pedometer (from `.symlinks/plugins/pedometer/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - polar (from `.symlinks/plugins/polar/ios`) + - qr_code_scanner_plus (from `.symlinks/plugins/qr_code_scanner_plus/ios`) - screen_state (from `.symlinks/plugins/screen_state/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) SPEC REPOS: trunk: + - AppAuth - flutter_sound_core - PolarBleSdk - RxSwift @@ -69,38 +149,82 @@ SPEC REPOS: - Zip EXTERNAL SOURCES: + appcheck: + :path: ".symlinks/plugins/appcheck/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" audio_streamer: :path: ".symlinks/plugins/audio_streamer/ios" + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" + battery_plus: + :path: ".symlinks/plugins/battery_plus/ios" + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" dchs_flutter_beacon: :path: ".symlinks/plugins/dchs_flutter_beacon/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_activity_recognition: :path: ".symlinks/plugins/flutter_activity_recognition/ios" + flutter_appauth: + :path: ".symlinks/plugins/flutter_appauth/ios" + flutter_blue_plus_darwin: + :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_sound: :path: ".symlinks/plugins/flutter_sound/ios" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" flutter_web_auth_2: :path: ".symlinks/plugins/flutter_web_auth_2/ios" health: :path: ".symlinks/plugins/health/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" + light: + :path: ".symlinks/plugins/light/ios" location: :path: ".symlinks/plugins/location/ios" mdsflutter: :path: ".symlinks/plugins/mdsflutter/ios" Movesense: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ + network_info_plus: + :path: ".symlinks/plugins/network_info_plus/ios" oidc_ios: :path: ".symlinks/plugins/oidc_ios/ios" open_settings_plus: :path: ".symlinks/plugins/open_settings_plus/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + pedometer: + :path: ".symlinks/plugins/pedometer/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" polar: :path: ".symlinks/plugins/polar/ios" + qr_code_scanner_plus: + :path: ".symlinks/plugins/qr_code_scanner_plus/ios" screen_state: :path: ".symlinks/plugins/screen_state/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" CHECKOUT OPTIONS: Movesense: @@ -108,26 +232,49 @@ CHECKOUT OPTIONS: :git: https://bitbucket.org/movesense/movesense-mobile-lib/ SPEC CHECKSUMS: - audio_streamer: 2e472b9f81cec5e381c4cf7667afa3055dcb45a4 + AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 + appcheck: 3c94d0ffc94bd639938cac7427d5b13df2795404 + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + audio_streamer: 85e5b27e443525fcfe375052132327539c89d19a + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977 + camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd dchs_flutter_beacon: 88d72cc467de508d621e454ea66c6231d0897d4c + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_activity_recognition: 52dfad4c1e9cec99f0841b3ed0efcc9d424b3deb + flutter_appauth: d4abcf54856e5d8ba82ed7646ffc83245d4aa448 + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_sound: d95194f6476c9ad211d22b3a414d852c12c7ca44 flutter_sound_core: 7f2626d249d3a57bfa6da892ef7e22d234482c1a + flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 - health: fe206e65f13a9b88623605fbd8af8f029e23dc35 + health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + light: 6490592116da81837a414ef49387bbea7a5ed375 location: 155caecf9da4f280ab5fe4a55f94ceccfab838f8 mdsflutter: bd3c0b3335d50947d1a192cf70f930e0fb04a766 Movesense: dc64047c1feb856b7f7ac6ea2c67685350d99dd5 + network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc oidc_ios: 16966cad509ce6850ca4ca1216c5138bef2a8726 open_settings_plus: d19f91e8a04649358a51c19b484ce2e637149d70 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + pedometer: 6806036696085739f36b4e4e5374135e86220a59 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - polar: 0374f6063ade7e2dd85d626f87cbe2393d3eda7c - PolarBleSdk: bfb57cf8350f0c53d441b8632ba6df363ce7c79f + polar: e6c15a5007d85ab02ce12973c152545bcba861cd + PolarBleSdk: 1d56bb7a3ca0a6df04cb4f652fdccb26064df011 + qr_code_scanner_plus: 1fb59fd4576edb53ec7560c3746f454dee54300c RxSwift: 4e28be97cbcfeee614af26d83415febbf2bf6f45 - screen_state: 52d6e997d31bddba6417c60d9cdd22effd0320a7 + screen_state: 05272c0afc6270e43f08589598234334698b53ae + sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 PODFILE CHECKSUM: 45842edf150243fea66883d4dfad1e5ddb428147 diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f1c67b8..a71b474b 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "appauth-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/openid/AppAuth-iOS", - "state" : { - "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", - "version" : "2.0.0" - } - }, { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f1c67b8..a71b474b 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "appauth-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/openid/AppAuth-iOS", - "state" : { - "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", - "version" : "2.0.0" - } - }, { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index ea5e9f02..55b7a8f8 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -91,9 +91,11 @@ class StudyAppBLoC extends ChangeNotifier { const deb = String.fromEnvironment('debug-level', defaultValue: 'info'); debugLevel = DebugLevel.values.where((element) => element.name == deb).first; - info('$runtimeType created. ' - 'DeploymentMode: ${deploymentMode.name}, ' - 'DebugLevel: ${debugLevel.name}'); + info( + '$runtimeType created. ' + 'DeploymentMode: ${deploymentMode.name}, ' + 'DebugLevel: ${debugLevel.name}', + ); } LocalizationManager get localizationManager => @@ -215,10 +217,12 @@ class StudyAppBLoC extends ChangeNotifier { if (deploymentMode != DeploymentMode.local) return; if (hasStudyBeenDeployed) { - info('Running in local deployment mode. Note that the local protocol has ' - 'already been deployed and the cached version will be loaded and used. ' - 'If you want to reload a modified protocol, delete the app with the ' - 'cached protocol from the phone before running it.'); + info( + 'Running in local deployment mode. Note that the local protocol has ' + 'already been deployed and the cached version will be loaded and used. ' + 'If you want to reload a modified protocol, delete the app with the ' + 'cached protocol from the phone before running it.', + ); } else { debug('$runtimeType - deploying local protocol'); @@ -229,17 +233,17 @@ class StudyAppBLoC extends ChangeNotifier { // Deploy this protocol using the on-phone deployment service. final status = await SmartphoneDeploymentService().createStudyDeployment(protocol!); + // The primary device (the smartphone) is found in the device status list. + final primaryDeviceRoleName = status.deviceStatusList + .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) + .device + .roleName; + // Save the participant and study on the phone for use across app restart. - var participant = Participant( - studyDeploymentId: status.studyDeploymentId, - deviceRoleName: status.primaryDeviceStatus?.device.roleName, - ); + var participant = Participant(studyDeploymentId: status.studyDeploymentId, deviceRoleName: primaryDeviceRoleName); LocalSettings().participant = participant; - bloc.study = SmartphoneStudy( - studyDeploymentId: status.studyDeploymentId, - deviceRoleName: status.primaryDeviceStatus!.device.roleName, - ); + bloc.study = SmartphoneStudy(studyDeploymentId: status.studyDeploymentId, deviceRoleName: primaryDeviceRoleName); } } @@ -247,10 +251,7 @@ class StudyAppBLoC extends ChangeNotifier { /// /// If a [context] is provided, the translation for this study is re-loaded /// and applied in the app. - void setStudyInvitation( - ActiveParticipationInvitation invitation, [ - BuildContext? context, - ]) { + void setStudyInvitation(ActiveParticipationInvitation invitation, [BuildContext? context]) { // create and save the participant info based on this invitation var participant = Participant.fromParticipationInvitation(invitation); LocalSettings().participant = participant; @@ -308,23 +309,15 @@ class StudyAppBLoC extends ChangeNotifier { (deployment == null) ? [] : await participationService.getParticipantDataList([deployment!.studyDeploymentId]); /// Set the participant data for this study. - void setParticipantData( - String studyDeploymentId, - Map data, [ - String? inputByParticipantRole, - ]) => - participationService.setParticipantData( - studyDeploymentId, - data, - inputByParticipantRole, - ); + void setParticipantData(String studyDeploymentId, Map data, [String? inputByParticipantRole]) => + participationService.setParticipantData(studyDeploymentId, data, inputByParticipantRole); /// Does this app use location permissions? bool get usingLocationPermissions => true; /// Get the informed consent for this study. Future getInformedConsent({bool refresh = false}) => - informedConsentManager.getInformedConsent(refresh: refresh); + informedConsentManager.getConsentDocument(refresh: refresh); /// Has the informed consent been accepted by the user? /// @@ -386,10 +379,13 @@ class StudyAppBLoC extends ChangeNotifier { /// Does this [deployment] have any measures? bool hasMeasures() => (deployment == null) ? false - : (deployment!.measures.any((measure) => (measure.type != SurveyUserTask.VIDEO_TYPE && - measure.type != SurveyUserTask.IMAGE_TYPE && - measure.type != SurveyUserTask.AUDIO_TYPE && - measure.type != SurveyUserTask.SURVEY_TYPE))); + : (deployment!.measures.any( + (measure) => + (measure.type != AppTask.VIDEO_TYPE && + measure.type != AppTask.IMAGE_TYPE && + measure.type != AppTask.AUDIO_TYPE && + measure.type != AppTask.SURVEY_TYPE), + )); /// Does this [deployment] have the measure of type [type]? bool hasMeasure(String type) { @@ -425,11 +421,11 @@ class StudyAppBLoC extends ChangeNotifier { /// Start sensing. Future start() async { assert(Sensing().controller != null, 'No Study Controller - the study has not been deployed.'); - if (!Sensing().isRunning) Sensing().controller?.start(); + if (!Sensing().isRunning) Sensing().controller?.resume(); } /// Stop sensing. - void stop() => Sensing().controller?.stop(); + void stop() => Sensing().controller?.pause(); /// Dispose the entire sensing. @override diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 78a25151..38b3def8 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -21,8 +21,8 @@ part of carp_study_app; class Sensing { static final Sensing _instance = Sensing._(); StudyDeploymentStatus? _status; - SmartphoneDeploymentController? _controller; - Study? _study; + SmartphoneStudyController? _controller; + SmartphoneStudy? _study; /// The deployment service used in this app. DeploymentService get deploymentService => @@ -30,7 +30,7 @@ class Sensing { /// The study running on this phone. /// Only available after [addStudy] is called. - Study? get study => _study; + SmartphoneStudy? get study => _study; /// The deployment running on this phone. /// Only available after [addStudy] is called. @@ -46,10 +46,10 @@ class Sensing { String? get studyDeploymentId => _study?.studyDeploymentId; /// The study runtime for this deployment. - SmartphoneDeploymentController? get controller => _controller; + SmartphoneStudyController? get controller => _controller; - /// Is sensing running, i.e. has the study executor been started? - bool get isRunning => (controller != null) && controller!.executor.state == ExecutorState.started; + /// Is sensing running, i.e. has the study executor been resumed? + bool get isRunning => (controller != null) && controller!.executor.state == ExecutorState.Resumed; /// The list of running - i.e. used - probes in this study. List get runningProbes => (_controller != null) ? _controller!.executor.probes : []; @@ -60,12 +60,9 @@ class Sensing { /// current deployment. Hence, this method returns the list of device managers /// used in the current deployment. List get deploymentDevices => deployment != null - ? SmartPhoneClientManager() - .deviceController - .devices - .values - .where((manager) => deployment!.devices.any((element) => element.type == manager.type)) - .toList() + ? SmartPhoneClientManager().deviceController.devices.values + .where((manager) => deployment!.devices.any((element) => element.type == manager.deviceType)) + .toList() : []; /// The smartphone (primary device) manager. @@ -111,7 +108,7 @@ class Sensing { // Create and configure a client manager for this phone await SmartPhoneClientManager().configure( deploymentService: deploymentService, - deviceController: DeviceController(), + dataCollectorFactory: DeviceController(), // Need to ask for permissions all at once on Android. askForPermissions: Platform.isAndroid ? true : false, @@ -120,37 +117,35 @@ class Sensing { info('$runtimeType initialized'); } - /// Add and deploy the study, and configure the study runtime (sampling). + /// Add the study to the client manager and deploy it. Future addStudy() async { - assert(SmartPhoneClientManager().isConfigured, - 'The client manager is not yet configured. Call SmartPhoneClientManager().configure() before adding a study.'); + assert( + SmartPhoneClientManager().isConfigured, + 'The client manager is not yet configured. Call SmartPhoneClientManager().configure() before adding a study.', + ); assert(bloc.study != null, 'No study is provided. Cannot start deployment w/o a study.'); _study = await SmartPhoneClientManager().addStudy(bloc.study!); - _controller = SmartPhoneClientManager().getStudyRuntime(study!.studyDeploymentId); - return await tryDeployment(); } - ///7 Try to deploy the study. + /// Try to deploy the study. /// /// Note that if the study has already been deployed on this phone it has /// been cached locally and the local version will be used pr. default. - /// If not deployed before (i.e., cached) the study deployment will be - /// fetched from the deployment service. + /// If not deployed before the study deployment will be fetched from the + /// deployment service. Future tryDeployment() async { - assert(controller != null, 'No study or controller is provided. Cannot start deployment w/o a study.'); + assert(study != null, 'No study is provided. Cannot deploy w/o a study. Call addStudy() first.'); - StudyStatus status = await controller!.tryDeployment(useCached: true); + StudyStatus status = await SmartPhoneClientManager().tryDeployment(study!.studyDeploymentId, study!.deviceRoleName); - translateStudyProtocol(); + _controller = SmartPhoneClientManager().getStudyController(_study!); - await controller?.configure(); + translateStudyProtocol(); - // Mirror each measurement to the console only when CAMS debug logging is on - // — matches the gating in carp_mobile_sensing's debug()/info() helpers so a - // release build (or any non-debug level) doesn't get spammed. + // Mirror each measurement to the console only in debug to avoid spamming logs. if (Settings().debugLevel.index >= DebugLevel.debug.index) { controller?.measurements.listen((measurement) => debugPrint(toJsonString(measurement))); } @@ -161,17 +156,7 @@ class Sensing { Future removeStudy() async { if (study == null) return; - // SmartPhoneClientManager.removeStudy calls getStudyRuntime which does an - // unsafe `as SmartphoneDeploymentController` cast — it throws when the - // study was never added to the client manager (e.g. the user cancelled - // consent before configureStudy ran). Swallow that case; we just want - // the study gone, and it never existed here. - try { - await SmartPhoneClientManager().removeStudy(study!.studyDeploymentId); - } on TypeError { - info('$runtimeType - study $study was never registered with the ' - 'client manager, skipping removeStudy.'); - } + await SmartPhoneClientManager().removeStudy(study!.studyDeploymentId, study!.deviceRoleName); } /// Get the last known status for the current study deployment. @@ -192,8 +177,8 @@ class Sensing { // Fast out is no localization if (bloc.localization == null) return; - // Fast out, if not configured or no protocol - if (controller?.status != StudyStatus.Deployed || controller?.deployment == null) { + // Fast out, if not configured or no protocol. + if (study?.status != StudyStatus.Deployed || controller?.deployment == null) { return; } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index eeaddadf..76cd55f9 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -33,8 +33,10 @@ class CarpStudyAppState extends State { errorBuilder: (context, state) => const ErrorPage(), redirect: (context, state) async { final loc = state.matchedLocation; - debugPrint('[redirect] loc=$loc auth=${bloc.backend.isAuthenticated} ' - 'studyDeployed=${bloc.hasStudyBeenDeployed}'); + debugPrint( + '[redirect] loc=$loc auth=${bloc.backend.isAuthenticated} ' + 'studyDeployed=${bloc.hasStudyBeenDeployed}', + ); // 1) Not authenticated → login page. if (bloc.deploymentMode != DeploymentMode.local && !bloc.backend.isAuthenticated) { @@ -64,18 +66,12 @@ class CarpStudyAppState extends State { 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: (_, _) => StudyPage.route), GoRoute( path: TaskListPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: TaskListPage( - model: bloc.appViewModel.taskListPageViewModel, - ), + child: TaskListPage(model: bloc.appViewModel.taskListPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), ), @@ -83,9 +79,7 @@ class CarpStudyAppState extends State { path: StudyPage.route, parentNavigatorKey: _shellNavigatorKey, pageBuilder: (context, state) => CustomTransitionPage( - child: StudyPage( - model: bloc.appViewModel.studyPageViewModel, - ), + child: StudyPage(model: bloc.appViewModel.studyPageViewModel), transitionsBuilder: bottomNavigationBarAnimation, ), routes: [ @@ -95,9 +89,7 @@ class CarpStudyAppState extends State { GoRoute( path: 'consent', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => InformedConsentPage( - model: bloc.appViewModel.informedConsentViewModel, - ), + builder: (context, state) => InformedConsentPage(model: bloc.appViewModel.informedConsentViewModel), ), ], ), @@ -112,10 +104,8 @@ class CarpStudyAppState extends State { GoRoute( path: DeviceListPage.route, parentNavigatorKey: _shellNavigatorKey, - pageBuilder: (context, state) => const CustomTransitionPage( - child: DeviceListPage(), - transitionsBuilder: bottomNavigationBarAnimation, - ), + pageBuilder: (context, state) => + const CustomTransitionPage(child: DeviceListPage(), transitionsBuilder: bottomNavigationBarAnimation), ), GoRoute( path: ProfilePage.route, @@ -130,9 +120,7 @@ class CarpStudyAppState extends State { GoRoute( path: StudyDetailsPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => StudyDetailsPage( - model: bloc.appViewModel.studyPageViewModel, - ), + builder: (context, state) => StudyDetailsPage(model: bloc.appViewModel.studyPageViewModel), ), GoRoute( path: ParticipantDataPage.route, @@ -178,10 +166,7 @@ class CarpStudyAppState extends State { /// Research Package translations, incl. both local language assets plus /// translations of informed consent and surveys downloaded from CARP final RPLocalizationsDelegate rpLocalizationsDelegate = RPLocalizationsDelegate( - loaders: [ - const AssetLocalizationLoader(), - bloc.localizationLoader, - ], + loaders: [const AssetLocalizationLoader(), bloc.localizationLoader], ); @override @@ -190,17 +175,11 @@ class CarpStudyAppState extends State { // Apply system overlay style after frame so Theme.of(context) is ready WidgetsBinding.instance.addPostFrameCallback((_) { - SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - )); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(statusBarColor: Colors.transparent)); }); return MaterialApp.router( scaffoldMessengerKey: bloc.scaffoldKey, - supportedLocales: const [ - Locale('en'), - Locale('da'), - Locale('es'), - ], + supportedLocales: const [Locale('en'), Locale('da'), Locale('es')], localizationsDelegates: [ // Research Package translations rpLocalizationsDelegate, @@ -222,11 +201,7 @@ class CarpStudyAppState extends State { }, locale: bloc.localization?.locale, theme: carpTheme.copyWith( - extensions: [ - carpTheme.extension()!.copyWith( - primary: studyAppColors?.primary, - ), - ], + extensions: [carpTheme.extension()!.copyWith(primary: studyAppColors?.primary)], ), darkTheme: carpDarkTheme, debugShowCheckedModeBanner: true, @@ -240,8 +215,4 @@ FadeTransition bottomNavigationBarAnimation( Animation animation, Animation secondaryAnimation, Widget child, -) => - FadeTransition( - opacity: animation, - child: child, - ); +) => FadeTransition(opacity: animation, child: child); diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index 8d016c3c..0371a9d0 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -28,24 +28,13 @@ class CarpBackend { } /// The URI of the CAWS server - depending on deployment mode. - Uri get uri => Uri( - scheme: 'https', - host: uris[bloc.deploymentMode], - ); + Uri get uri => Uri(scheme: 'https', host: uris[bloc.deploymentMode]); /// The URI of the CAWS authentication service. /// /// Of the form: /// https://dev.carp.dk/auth/realms/Carp/ - Uri get authUri => Uri( - scheme: 'https', - host: uris[bloc.deploymentMode], - pathSegments: [ - 'auth', - 'realms', - 'Carp', - ], - ); + Uri get authUri => Uri(scheme: 'https', host: uris[bloc.deploymentMode], pathSegments: ['auth', 'realms', 'Carp']); /// The CAWS app configuration. late final CarpApp _app = CarpApp(name: "CAWS @ DTU", uri: uri); @@ -54,17 +43,13 @@ class CarpBackend { /// The authentication configuration CarpAuthProperties get authProperties => CarpAuthProperties( - authURL: uri, - clientId: 'studies-app', - redirectURI: Uri.parse('carp-studies-auth://auth'), - anonymousRedirectURI: Uri.parse('carp-studies:/anonymous'), - // For authentication at CAWS the path is '/auth/realms/Carp' - discoveryURL: uri.replace(pathSegments: [ - 'auth', - 'realms', - 'Carp', - ]), - ); + authURL: uri, + clientId: 'studies-app', + redirectURI: Uri.parse('carp-studies-auth://auth'), + anonymousRedirectURI: Uri.parse('carp-studies:/anonymous'), + // For authentication at CAWS the path is '/auth/realms/Carp' + discoveryURL: uri.replace(pathSegments: ['auth', 'realms', 'Carp']), + ); /// Initialize this backend. Must be called before used. Future initialize() async { @@ -168,7 +153,8 @@ class CarpBackend { // (i.e. the invitation is for a smartphone). // This is done to avoid showing invitations for other devices (e.g. [WebBrowser]). invitations.removeWhere( - (invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) ?? false); + (invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) ?? false, + ); return invitations; } @@ -187,9 +173,7 @@ class CarpBackend { /// /// Looks for the first instance of a [RPConsentSignatureResult] in [consent] /// and uploads this. - Future uploadInformedConsent( - RPTaskResult consent, - ) async { + Future uploadInformedConsent(RPTaskResult consent) async { if (user == null) { warning('$runtimeType - No user authenticated.'); return null; @@ -201,9 +185,8 @@ class CarpBackend { late RPConsentSignatureResult signedConsent; try { - signedConsent = consent.results.values.firstWhere( - (result) => result is RPConsentSignatureResult, - ) as RPConsentSignatureResult; + signedConsent = + consent.results.values.firstWhere((result) => result is RPConsentSignatureResult) as RPConsentSignatureResult; } catch (_) { warning('$runtimeType - No signed informed consent found to be uploaded.'); return null; @@ -222,8 +205,10 @@ class CarpBackend { try { await CarpParticipationService().participation().setInformedConsent(uploadedConsent); - info('$runtimeType - Informed consent document uploaded successfully for ' - 'deployment id: ${bloc.study?.studyDeploymentId}'); + info( + '$runtimeType - Informed consent document uploaded successfully for ' + 'deployment id: ${bloc.study?.studyDeploymentId}', + ); } on Exception { warning('$runtimeType - Informed consent upload failed for username: $username'); } diff --git a/lib/data/local_participation_service.dart b/lib/data/local_participation_service.dart index 67c543d2..1171e802 100644 --- a/lib/data/local_participation_service.dart +++ b/lib/data/local_participation_service.dart @@ -11,23 +11,19 @@ class LocalParticipationService implements ParticipationService { factory LocalParticipationService() => _instance; @override - Future> getActiveParticipationInvitations([String? accountId]) async => []; + Future> getActiveParticipationInvitations({String? accountId}) async => []; @override Future getParticipantData(String studyDeploymentId) async => ParticipantData(studyDeploymentId: studyDeploymentId); @override - Future> getParticipantDataList( - List studyDeploymentIds, - ) async => - []; + Future> getParticipantDataList(List studyDeploymentIds) async => []; @override Future setParticipantData( String studyDeploymentId, Map data, [ String? inputByParticipantRole, - ]) async => - ParticipantData(studyDeploymentId: studyDeploymentId); + ]) async => ParticipantData(studyDeploymentId: studyDeploymentId); } diff --git a/lib/data/local_resource_manager.dart b/lib/data/local_resource_manager.dart index 4afe1432..0d80bf02 100644 --- a/lib/data/local_resource_manager.dart +++ b/lib/data/local_resource_manager.dart @@ -42,28 +42,30 @@ class LocalResourceManager RPOrderedTask? get informedConsent => _informedConsent; @override - Future getInformedConsent({bool refresh = false}) async { + Future getConsentDocument({bool refresh = false}) async { if (_informedConsent == null) { try { var jsonString = await rootBundle.loadString('$basePath/resources/consent.json'); Map jsonMap = json.decode(jsonString) as Map; _informedConsent = RPOrderedTask.fromJson(jsonMap); } catch (error) { - warning("$runtimeType - Could not load a local informed consent. " - "It should be added as an asset resource in 'carp/resources/consent.json'. $error"); + warning( + "$runtimeType - Could not load a local informed consent. " + "It should be added as an asset resource in 'carp/resources/consent.json'. $error", + ); } } return _informedConsent; } @override - Future setInformedConsent(RPOrderedTask informedConsent) async { + Future setConsentDocument(RPOrderedTask informedConsent) async { _informedConsent = informedConsent; return true; } @override - Future deleteInformedConsent() async { + Future deleteConsentDocument() async { _informedConsent = null; return true; } @@ -71,10 +73,7 @@ class LocalResourceManager // LOCALIZATION @override - Future> getLocalizations( - Locale locale, { - bool refresh = false, - }) async { + Future> getLocalizations(Locale locale, {bool refresh = false}) async { if (_translations == null) { var path = '$basePath/lang/${locale.languageCode}.json'; var jsonString = await rootBundle.loadString(path); @@ -86,10 +85,7 @@ class LocalResourceManager } @override - Future setLocalizations( - Locale locale, - Map localizations, - ) { + Future setLocalizations(Locale locale, Map localizations) { throw UnimplementedError(); } @@ -104,11 +100,7 @@ class LocalResourceManager // MESSAGES @override - Future> getMessages({ - DateTime? start, - DateTime? end, - int? count = 20, - }) async { + Future> getMessages({DateTime? start, DateTime? end, int? count = 20}) async { if (_messages.isEmpty) { final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle); final files = assetManifest.listAssets().where((string) => string.startsWith("$basePath/messages/")).toList(); @@ -152,17 +144,20 @@ class LocalResourceManager if (!(_protocol!.dataEndPoint!.type == DataEndPointTypes.FILE || _protocol!.dataEndPoint!.type == DataEndPointTypes.SQLITE)) { warning( - "$runtimeType - Local protocol is trying to use a non-local data endpoint of type: '${_protocol!.dataEndPoint!.type}'. " - "This will not work. Replacing this data endpoint to use a local SQLite backend instead. " - "You can also change this in the local protocol stored in the 'carp/resources/protocol.json' file."); + "$runtimeType - Local protocol is trying to use a non-local data endpoint of type: '${_protocol!.dataEndPoint!.type}'. " + "This will not work. Replacing this data endpoint to use a local SQLite backend instead. " + "You can also change this in the local protocol stored in the 'carp/resources/protocol.json' file.", + ); _protocol!.dataEndPoint = SQLiteDataEndPoint(); } } } catch (error) { - warning("$runtimeType - Could not load a local study protocol. " - "It should be added as an asset resource in 'carp/resources/protocol.json'.\n" - "Error: $error"); + warning( + "$runtimeType - Could not load a local study protocol. " + "It should be added as an asset resource in 'carp/resources/protocol.json'.\n" + "Error: $error", + ); } } return _protocol; diff --git a/lib/data/local_settings.dart b/lib/data/local_settings.dart index 461d8034..fe97f94b 100644 --- a/lib/data/local_settings.dart +++ b/lib/data/local_settings.dart @@ -80,30 +80,26 @@ class LocalSettings { SmartphoneStudy? get study { if (_study != null) return _study; var jsonString = Settings().preferences?.getString(studyKey); - return _study = - (jsonString == null) ? null : _$SmartphoneStudyFromJson(json.decode(jsonString) as Map); + return _study = (jsonString == null) + ? null + : _$SmartphoneStudyFromJson(json.decode(jsonString) as Map); } set study(SmartphoneStudy? study) { assert( - study != null, - 'Cannot set the study to null in Settings. ' - "Use the 'eraseStudyDeployment()' method to erase study deployment information."); + study != null, + 'Cannot set the study to null in Settings. ' + "Use the 'eraseStudyDeployment()' method to erase study deployment information.", + ); _study = study; - Settings().preferences?.setString( - studyKey, - json.encode(_$SmartphoneStudyToJson(study!)), - ); + Settings().preferences?.setString(studyKey, json.encode(_$SmartphoneStudyToJson(study!))); } bool get hasSeenBluetoothConnectionInstructions => Settings().preferences?.getBool('hasSeenBluetoothConnectionInstructions') ?? false; set hasSeenBluetoothConnectionInstructions(bool seen) { - Settings().preferences?.setBool( - 'hasSeenBluetoothConnectionInstructions', - seen, - ); + Settings().preferences?.setBool('hasSeenBluetoothConnectionInstructions', seen); } bool get isAnonymous => Settings().preferences!.getBool('isAnonymous') ?? false; @@ -138,17 +134,17 @@ class LocalSettings { // Need to create our own JSON serializers here, since SmartphoneStudy is not made serializable Map _$SmartphoneStudyToJson(SmartphoneStudy study) => { - 'studyId': study.studyId, - 'studyDeploymentId': study.studyDeploymentId, - 'deviceRoleName': study.deviceRoleName, - 'participantId': study.participantId, - 'participantRoleName': study.participantRoleName, - }; + 'studyId': study.studyId, + 'studyDeploymentId': study.studyDeploymentId, + 'deviceRoleName': study.deviceRoleName, + 'participantId': study.participantId, + 'participantRoleName': study.participantRoleName, +}; SmartphoneStudy _$SmartphoneStudyFromJson(Map json) => SmartphoneStudy( - studyId: json['studyId'] as String?, - studyDeploymentId: json['studyDeploymentId'] as String, - deviceRoleName: json['deviceRoleName'] as String, - participantId: json['participantId'] as String?, - participantRoleName: json['participantRoleName'] as String?, - ); + studyId: json['studyId'] as String?, + studyDeploymentId: json['studyDeploymentId'] as String, + deviceRoleName: json['deviceRoleName'] as String, + participantId: json['participantId'] as String?, + participantRoleName: json['participantRoleName'] as String?, +); diff --git a/lib/data/participant.dart b/lib/data/participant.dart index e8f88857..63a0eec2 100644 --- a/lib/data/participant.dart +++ b/lib/data/participant.dart @@ -22,15 +22,14 @@ class Participant { this.hasInformedConsentBeenAccepted = false, }); - Participant.fromParticipationInvitation( - ActiveParticipationInvitation invitation, - ) : this( - studyId: invitation.studyId, - studyDeploymentId: invitation.studyDeploymentId, - deviceRoleName: invitation.assignedDevices?.first.device.roleName, - participantId: invitation.participation.participantId, - participantRoleName: invitation.participation.assignedRoles.roleNames?.first, - ); + Participant.fromParticipationInvitation(ActiveParticipationInvitation invitation) + : this( + studyId: invitation.studyId, + studyDeploymentId: invitation.studyDeploymentId, + deviceRoleName: invitation.assignedDevices?.first.device.roleName, + participantId: invitation.participation.participantId, + participantRoleName: invitation.participation.assignedRoles.roleNames?.first, + ); factory Participant.fromJson(Map json) => _$ParticipantFromJson(json); Map toJson() => _$ParticipantToJson(this); diff --git a/lib/main.dart b/lib/main.dart index 94c7a691..a09457f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,7 +39,8 @@ import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart' as qr; // the CARP packages import 'package:carp_serializable/carp_serializable.dart'; -import 'package:carp_core/carp_core.dart'; +// Both carp_core and carp_mobile_sensing export `Smartphone`; hide carp_core's. +import 'package:carp_core/carp_core.dart' hide Smartphone; import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; import 'package:carp_audio_package/media.dart'; //import 'package:carp_connectivity_package/connectivity.dart'; diff --git a/lib/main.g.dart b/lib/main.g.dart index b3cd016d..95750247 100644 --- a/lib/main.g.dart +++ b/lib/main.g.dart @@ -7,36 +7,37 @@ part of 'main.dart'; // ************************************************************************** Participant _$ParticipantFromJson(Map json) => Participant( - studyId: json['studyId'] as String?, - studyDeploymentId: json['studyDeploymentId'] as String?, - deviceRoleName: json['deviceRoleName'] as String?, - participantId: json['participantId'] as String?, - participantRoleName: json['participantRoleName'] as String?, - hasInformedConsentBeenAccepted: json['hasInformedConsentBeenAccepted'] as bool? ?? false, - ); + studyId: json['studyId'] as String?, + studyDeploymentId: json['studyDeploymentId'] as String?, + deviceRoleName: json['deviceRoleName'] as String?, + participantId: json['participantId'] as String?, + participantRoleName: json['participantRoleName'] as String?, + hasInformedConsentBeenAccepted: json['hasInformedConsentBeenAccepted'] as bool? ?? false, +); Map _$ParticipantToJson(Participant instance) => { - if (instance.studyId case final value?) 'studyId': value, - if (instance.studyDeploymentId case final value?) 'studyDeploymentId': value, - if (instance.deviceRoleName case final value?) 'deviceRoleName': value, - if (instance.participantId case final value?) 'participantId': value, - if (instance.participantRoleName case final value?) 'participantRoleName': value, - '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()), - )), - ); + '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())), + ), + ); Map _$WeeklyActivitiesToJson(WeeklyActivities instance) => { - 'activities': instance.activities - .map((k, e) => MapEntry(_$ActivityTypeEnumMap[k]!, e.map((k, e) => MapEntry(k.toString(), e)))), - }; + 'activities': instance.activities.map( + (k, e) => MapEntry(_$ActivityTypeEnumMap[k]!, e.map((k, e) => MapEntry(k.toString(), e))), + ), +}; const _$ActivityTypeEnumMap = { ActivityType.IN_VEHICLE: 'IN_VEHICLE', @@ -53,22 +54,22 @@ WeeklyMobility _$WeeklyMobilityFromJson(Map json) => WeeklyMobi ); Map _$WeeklyMobilityToJson(WeeklyMobility instance) => { - 'weekMobility': instance.weekMobility.map((k, e) => MapEntry(k.toString(), e)), - }; + '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(), - ); + (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, - }; + 'weekday': instance.weekday, + 'places': instance.places, + 'homeStay': instance.homeStay, + 'distance': instance.distance, +}; WeeklySteps _$WeeklyStepsFromJson(Map json) => WeeklySteps() ..weeklySteps = (json['weeklySteps'] as Map).map( @@ -76,8 +77,8 @@ WeeklySteps _$WeeklyStepsFromJson(Map json) => WeeklySteps() ); Map _$WeeklyStepsToJson(WeeklySteps instance) => { - 'weeklySteps': instance.weeklySteps.map((k, e) => MapEntry(k.toString(), e)), - }; + 'weeklySteps': instance.weeklySteps.map((k, e) => MapEntry(k.toString(), e)), +}; HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => HourlyHeartRate() ..hourlyHeartRate = (json['hourlyHeartRate'] as Map).map( @@ -88,18 +89,16 @@ HourlyHeartRate _$HourlyHeartRateFromJson(Map json) => HourlyHe ..minHeartRate = (json['minHeartRate'] as num?)?.toDouble(); Map _$HourlyHeartRateToJson(HourlyHeartRate instance) => { - 'hourlyHeartRate': instance.hourlyHeartRate.map((k, e) => MapEntry(k.toString(), e)), - 'lastUpdated': instance.lastUpdated.toIso8601String(), - if (instance.maxHeartRate case final value?) 'maxHeartRate': value, - if (instance.minHeartRate case final value?) 'minHeartRate': value, - }; + '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(), - ); +HeartRateMinMaxPrHour _$HeartRateMinMaxPrHourFromJson(Map json) => + HeartRateMinMaxPrHour((json['min'] as num?)?.toDouble(), (json['max'] as num?)?.toDouble()); Map _$HeartRateMinMaxPrHourToJson(HeartRateMinMaxPrHour instance) => { - if (instance.min case final value?) 'min': value, - if (instance.max case final value?) 'max': value, - }; + 'min': ?instance.min, + 'max': ?instance.max, +}; diff --git a/lib/ui/cards/activity_card.dart b/lib/ui/cards/activity_card.dart index 8847b104..1c48af92 100644 --- a/lib/ui/cards/activity_card.dart +++ b/lib/ui/cards/activity_card.dart @@ -19,8 +19,11 @@ class ActivityCardState extends State { final betweenSpace = 2.4; - List> activitiesList = - List.generate(7, (_) => List.generate(4, (index) => index, growable: false), growable: false); + List> activitiesList = List.generate( + 7, + (_) => List.generate(4, (index) => index, growable: false), + growable: false, + ); @override void initState() { @@ -66,17 +69,13 @@ class ActivityCardState extends State { children: [ Text( '${_walk! + _run! + _cycle!}', - style: fs28fw700.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + 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)}', - style: fs12fw700.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ), ], @@ -85,9 +84,7 @@ class ActivityCardState extends State { 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, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -109,12 +106,7 @@ class ActivityCardState extends State { Expanded( child: Row( children: [ - Text( - '$_walk', - style: fs22fw700.copyWith( - color: widget.colors[0], - ), - ), + Text('$_walk', style: fs22fw700.copyWith(color: widget.colors[0])), Padding( padding: const EdgeInsets.all(4.0), child: Text( @@ -130,12 +122,7 @@ class ActivityCardState extends State { children: [ Padding( padding: const EdgeInsets.only(left: 8.0), - child: Text( - '$_run', - style: fs12fw700.copyWith( - color: widget.colors[1], - ), - ), + child: Text('$_run', style: fs12fw700.copyWith(color: widget.colors[1])), ), Padding( padding: const EdgeInsets.all(4.0), @@ -146,24 +133,17 @@ class ActivityCardState extends State { ), ], ), - ) + ), ], ), Row( children: [ - Text( - '$_cycle', - style: fs22fw700.copyWith( - color: widget.colors[2], - ), - ), + 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, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey800), ), ), ], @@ -182,19 +162,11 @@ class ActivityCardState extends State { alignment: BarChartAlignment.spaceAround, titlesData: FlTitlesData( bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, - ), + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), leftTitles: const AxisTitles(), rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, - ), + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), topTitles: const AxisTitles(), ), @@ -214,29 +186,15 @@ class ActivityCardState extends State { drawVerticalLine: false, drawHorizontalLine: true, getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); }, ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), - ), - ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), ); } - BarChartGroupData generateGroupData( - int x, - num walking, - num running, - num cycling, - ) { + BarChartGroupData generateGroupData(int x, num walking, num running, num cycling) { double roundness = 2; bool isTouched = touchedIndex == x; maxValue = max(maxValue, walking + running + cycling); @@ -251,17 +209,19 @@ class ActivityCardState extends State { groupVertically: true, barRods: [ BarChartRodData( - fromY: 0, - toY: walking + 0, - color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), - width: 32, - borderRadius: BorderRadius.all(Radius.circular(roundness))), + fromY: 0, + toY: walking + 0, + color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), + 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), - width: 32, - borderRadius: BorderRadius.all(Radius.circular(roundness))), + fromY: walking + betweenSpace, + toY: walking + betweenSpace + running, + color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), + width: 32, + borderRadius: BorderRadius.all(Radius.circular(roundness)), + ), BarChartRodData( fromY: walking + betweenSpace + running + betweenSpace, toY: walking + betweenSpace + running + betweenSpace + cycling, @@ -284,9 +244,7 @@ class ActivityCardState extends State { space: 6, child: Text( value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: fs14ls1.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } diff --git a/lib/ui/cards/anonymous_card.dart b/lib/ui/cards/anonymous_card.dart index d776831c..1d393778 100644 --- a/lib/ui/cards/anonymous_card.dart +++ b/lib/ui/cards/anonymous_card.dart @@ -11,13 +11,9 @@ class AnonymousCard extends StatelessWidget { color: Theme.of(context).extension()!.grey50, elevation: 0, margin: const EdgeInsets.all(16.0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16.0)), child: Row( children: [ Expanded( @@ -32,10 +28,7 @@ class AnonymousCard extends StatelessWidget { child: CircleAvatar( radius: 18, backgroundColor: CACHET.ANONYMOUS, - child: Icon( - Icons.info_outline, - color: Colors.white, - ), + child: Icon(Icons.info_outline, color: Colors.white), ), ), Padding( diff --git a/lib/ui/cards/distance_card.dart b/lib/ui/cards/distance_card.dart index e5466e81..143a0101 100644 --- a/lib/ui/cards/distance_card.dart +++ b/lib/ui/cards/distance_card.dart @@ -38,19 +38,12 @@ class _DistanceCardState extends State { children: [ Row( children: [ - Text( - _distance, - style: fs28fw700.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), - ), + Text(_distance, style: fs28fw700.copyWith(color: Theme.of(context).extension()!.grey900!)), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( 'km ${_getDayName(touchedIndex)}', - style: fs12fw700.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ), ], @@ -59,9 +52,7 @@ class _DistanceCardState extends State { 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, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -69,10 +60,7 @@ class _DistanceCardState extends State { SizedBox( height: 160, width: MediaQuery.of(context).size.width * 0.9, - child: StreamBuilder( - stream: widget.model.mobilityEvents, - builder: (context, snapshot) => barCharts, - ), + child: StreamBuilder(stream: widget.model.mobilityEvents, builder: (context, snapshot) => barCharts), ), ], ), @@ -81,56 +69,41 @@ class _DistanceCardState extends State { } BarChart get barCharts { - return BarChart(BarChartData( - alignment: BarChartAlignment.spaceAround, - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), - ), - leftTitles: const AxisTitles(), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, + leftTitles: const AxisTitles(), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), + topTitles: const AxisTitles(), ), - topTitles: const AxisTitles(), - ), - barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; - }); - }, - ), - groupsSpace: 4, - barGroups: barChartsGroups, - maxY: (maxValue) * 1.2, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); - }, - ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), + barTouchData: BarTouchData( + enabled: false, + touchCallback: (p0, p1) { + setState(() { + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; + }); + }, + ), + groupsSpace: 4, + barGroups: barChartsGroups, + maxY: (maxValue) * 1.2, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) { + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); + }, ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), - )); + ); } List get barChartsGroups { @@ -151,10 +124,7 @@ class _DistanceCardState extends State { toY: step.toDouble(), color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), width: 32, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), ], ); @@ -166,9 +136,7 @@ class _DistanceCardState extends State { space: 6, child: Text( value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: fs14ls1.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } diff --git a/lib/ui/cards/heart_rate_card.dart b/lib/ui/cards/heart_rate_card.dart index 5a6879e1..0830352c 100644 --- a/lib/ui/cards/heart_rate_card.dart +++ b/lib/ui/cards/heart_rate_card.dart @@ -22,13 +22,8 @@ class HeartRateCardWidgetState extends State with SingleTic duration: const Duration(seconds: 1), lowerBound: 0.9, upperBound: 1, - )..repeat( - reverse: true, - ); - animation = CurvedAnimation( - parent: animationController, - curve: Curves.easeOut, - ); + )..repeat(reverse: true); + animation = CurvedAnimation(parent: animationController, curve: Curves.easeOut); } @override @@ -51,14 +46,8 @@ class HeartRateCardWidgetState extends State with SingleTic return Column( children: [ getDailyRange, - SizedBox( - height: 240, - child: barCharts, - ), - SizedBox( - height: 50, - child: currentHeartRateWidget, - ) + SizedBox(height: 240, child: barCharts), + SizedBox(height: 50, child: currentHeartRateWidget), ], ); }, @@ -80,19 +69,13 @@ class HeartRateCardWidgetState extends State with SingleTic children: [ Container( margin: const EdgeInsets.only(left: 8, right: 4, bottom: 4), - child: Text( - min == null || max == null ? '-' : '${(min.toInt())} - ${(max.toInt())}', - style: fs28fw700, - ), + child: Text(min == null || max == null ? '-' : '${(min.toInt())} - ${(max.toInt())}', style: fs28fw700), ), 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, - ), + style: fs10fw700.copyWith(fontSize: 12, color: Theme.of(context).extension()!.grey600), ), ), ], @@ -114,10 +97,7 @@ class HeartRateCardWidgetState extends State with SingleTic Container( margin: const EdgeInsets.only(left: 8, bottom: 8, right: 4), child: currentHeartRate != null - ? Text( - currentHeartRate.toStringAsFixed(0), - style: fs28fw700, - ) + ? Text(currentHeartRate.toStringAsFixed(0), style: fs28fw700) : Text('-', style: fs28fw700), ), Padding( @@ -129,18 +109,12 @@ class HeartRateCardWidgetState extends State with SingleTic RepaintBoundary( child: ScaleTransition( scale: Tween(begin: 1, end: 1).animate(animationController), - child: Icon( - Icons.favorite, - color: CACHET.HEART_RATE_RED, - size: 10, - ), + 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, - ), + style: fs10fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ], ), @@ -177,10 +151,7 @@ class HeartRateCardWidgetState extends State with SingleTic ), TextSpan( text: "\n${rod.fromY.toInt()}-${rod.toY.toInt()}", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 30, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 30), ), TextSpan( text: "${locale.translate('cards.heartrate.bpm')}\n", @@ -207,51 +178,31 @@ class HeartRateCardWidgetState extends State with SingleTic titlesData: FlTitlesData( show: true, bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 20, - getTitlesWidget: bottomTitles, - ), + sideTitles: SideTitles(showTitles: true, interval: 20, getTitlesWidget: bottomTitles), ), rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 48, - getTitlesWidget: rightTitles, - ), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), + sideTitles: SideTitles(showTitles: true, reservedSize: 48, getTitlesWidget: rightTitles), ), + 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; - }), - borderData: FlBorderData( show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), - ), + 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; + }, ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), groupsSpace: 4, barGroups: getHeartRateBars(), minY: 0, @@ -261,11 +212,7 @@ class HeartRateCardWidgetState extends State with SingleTic } Widget bottomTitles(double value, TitleMeta meta) { - final style = TextStyle( - color: Colors.grey.withValues(alpha: 0.6), - fontSize: 14, - fontWeight: FontWeight.bold, - ); + final style = TextStyle(color: Colors.grey.withValues(alpha: 0.6), fontSize: 14, fontWeight: FontWeight.bold); String text; if (value == 0) { text = '00'; @@ -282,10 +229,7 @@ class HeartRateCardWidgetState extends State with SingleTic return SideTitleWidget( meta: meta, space: 0, - child: Text( - text, - style: style, - ), + child: Text(text, style: style), ); } @@ -295,26 +239,21 @@ class HeartRateCardWidgetState extends State with SingleTic space: 6, child: Text( value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: fs14ls1.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), maxLines: 1, ), ); } 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, - ), - ], - )) + .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(); } diff --git a/lib/ui/cards/media_card.dart b/lib/ui/cards/media_card.dart index a8c715e8..be777f8f 100644 --- a/lib/ui/cards/media_card.dart +++ b/lib/ui/cards/media_card.dart @@ -47,16 +47,19 @@ class MediaCardWidgetState extends State { '${entry.value.tasksDone} ${locale.translate('cards.${entry.value.taskType}.title')}', style: fs16fw400ls1.copyWith(fontSize: 14), ), - LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { - return HorizontalBar( + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return HorizontalBar( parentWidth: constraints.maxWidth, names: entry.value.taskCount .map((task) => locale.translate(task.title)) .toList(), values: entry.value.taskCount.map((task) => task.size).toList(), colors: CACHET.COLOR_LIST, - height: 18); - }), + height: 18, + ); + }, + ), ], ), ) @@ -68,7 +71,7 @@ class MediaCardWidgetState extends State { ], ), ], - ) + ), ], ), ), diff --git a/lib/ui/cards/mobility_card.dart b/lib/ui/cards/mobility_card.dart index b2362860..9daff951 100644 --- a/lib/ui/cards/mobility_card.dart +++ b/lib/ui/cards/mobility_card.dart @@ -35,19 +35,12 @@ class _MobilityCardState extends State { children: [ Row( children: [ - Text( - '$_homestay%', - style: fs28fw700.copyWith( - color: widget.colors[0], - ), - ), + Text('$_homestay%', style: fs28fw700.copyWith(color: widget.colors[0])), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( "${locale.translate('cards.mobility.homestay')} ${_getDayName(touchedIndex)}", - style: fs12fw700.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey900!), ), ), ], @@ -56,9 +49,7 @@ class _MobilityCardState extends State { 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, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -77,12 +68,7 @@ class _MobilityCardState extends State { children: [ Row( children: [ - Text( - '$_places', - style: fs22fw700.copyWith( - color: widget.colors[0], - ), - ), + Text('$_places', style: fs22fw700.copyWith(color: widget.colors[0])), Padding( padding: const EdgeInsets.all(4.0), child: Text( @@ -101,56 +87,41 @@ class _MobilityCardState extends State { } BarChart get barCharts { - return BarChart(BarChartData( - alignment: BarChartAlignment.spaceAround, - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), - ), - leftTitles: const AxisTitles(), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, + leftTitles: const AxisTitles(), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), + topTitles: const AxisTitles(), ), - topTitles: const AxisTitles(), - ), - barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; - }); - }, - ), - groupsSpace: 4, - barGroups: barChartsGroups, - maxY: 100, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); - }, - ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), + barTouchData: BarTouchData( + enabled: false, + touchCallback: (p0, p1) { + setState(() { + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; + }); + }, ), + groupsSpace: 4, + barGroups: barChartsGroups, + maxY: 100, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) { + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); + }, + ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), - )); + ); } List get barChartsGroups { @@ -173,19 +144,13 @@ class _MobilityCardState extends State { toY: places.toDouble(), color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), width: 16, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), BarChartRodData( toY: homestay.toDouble(), color: widget.colors[0].withValues(alpha: isTouched ? 0.8 : 1), width: 16, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), ], ); @@ -197,9 +162,7 @@ class _MobilityCardState extends State { space: 6, child: Text( value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: fs14ls1.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } @@ -210,9 +173,7 @@ class _MobilityCardState extends State { space: 6, child: Text( value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: fs14ls1.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } diff --git a/lib/ui/cards/scoreboard_card.dart b/lib/ui/cards/scoreboard_card.dart index 09486500..974d8897 100644 --- a/lib/ui/cards/scoreboard_card.dart +++ b/lib/ui/cards/scoreboard_card.dart @@ -14,12 +14,7 @@ class ScoreboardCardState extends State { return SliverPersistentHeader( pinned: false, - delegate: ScoreboardPersistentHeaderDelegate( - model: widget.model, - locale: locale, - minExtent: 40, - maxExtent: 110, - ), + delegate: ScoreboardPersistentHeaderDelegate(model: widget.model, locale: locale, minExtent: 40, maxExtent: 110), ); } } @@ -51,38 +46,52 @@ class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate 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)), + 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)), + 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)), - ) + 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)), + 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)), + 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)), + child: Text( + locale.translate('cards.scoreboard.tasks-short'), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.primary), + ), ), - ) + ), ]; return Container( @@ -100,14 +109,8 @@ class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate children: [ Expanded( child: shrinkOffset < offsetForShrink - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenDays, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenDays, - ), + ? 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( @@ -123,15 +126,9 @@ class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate ), Expanded( child: shrinkOffset < offsetForShrink - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenTasks, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: childrenTasks, - ), - ) + ? Column(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks) + : Row(mainAxisAlignment: MainAxisAlignment.center, children: childrenTasks), + ), ], ); }, @@ -158,10 +155,8 @@ class ScoreboardPersistentHeaderDelegate extends SliverPersistentHeaderDelegate } @override - FloatingHeaderSnapConfiguration get snapConfiguration => FloatingHeaderSnapConfiguration( - curve: Curves.linear, - duration: const Duration(milliseconds: 100), - ); + FloatingHeaderSnapConfiguration get snapConfiguration => + FloatingHeaderSnapConfiguration(curve: Curves.linear, duration: const Duration(milliseconds: 100)); @override OverScrollHeaderStretchConfiguration get stretchConfiguration => OverScrollHeaderStretchConfiguration(); diff --git a/lib/ui/cards/steps_card.dart b/lib/ui/cards/steps_card.dart index 681096dc..577a0561 100644 --- a/lib/ui/cards/steps_card.dart +++ b/lib/ui/cards/steps_card.dart @@ -36,17 +36,13 @@ class StepsCardWidgetState extends State { children: [ Text( _step > 0 ? '$_step' : '0', - style: fs28fw700.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + 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)}', - style: fs12fw700.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), ), ], @@ -55,9 +51,7 @@ class StepsCardWidgetState extends State { 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, - ), + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey600), ), Spacer(), ], @@ -79,56 +73,41 @@ class StepsCardWidgetState extends State { } BarChart get barCharts { - return BarChart(BarChartData( - alignment: BarChartAlignment.spaceAround, - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: bottomTitles, - reservedSize: 20, + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: bottomTitles, reservedSize: 20), ), - ), - leftTitles: const AxisTitles(), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: rightTitles, - reservedSize: 48, + leftTitles: const AxisTitles(), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: true, getTitlesWidget: rightTitles, reservedSize: 48), ), + topTitles: const AxisTitles(), ), - topTitles: const AxisTitles(), - ), - barTouchData: BarTouchData( - enabled: false, - touchCallback: (p0, p1) { - setState(() { - touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; - }); - }, - ), - groupsSpace: 4, - barGroups: barChartsGroups, - maxY: (maxValue) * 1.2, - gridData: FlGridData( - show: true, - drawVerticalLine: false, - drawHorizontalLine: true, - getDrawingHorizontalLine: (value) { - return FlLine( - color: Colors.grey.withValues(alpha: 0.3), - strokeWidth: 1, - ); - }, - ), - borderData: FlBorderData( - show: true, - border: Border.all( - width: 1, - color: Colors.grey.withValues(alpha: 0.2), + barTouchData: BarTouchData( + enabled: false, + touchCallback: (p0, p1) { + setState(() { + touchedIndex = (p1?.spot?.touchedBarGroupIndex ?? DateTime.now().weekday - 1) + 1; + }); + }, ), + groupsSpace: 4, + barGroups: barChartsGroups, + maxY: (maxValue) * 1.2, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) { + return FlLine(color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1); + }, + ), + borderData: FlBorderData(show: true, border: Border.all(width: 1, color: Colors.grey.withValues(alpha: 0.2))), ), - )); + ); } List get barChartsGroups { @@ -149,10 +128,7 @@ class StepsCardWidgetState extends State { toY: step.toDouble(), color: widget.colors[1].withValues(alpha: isTouched ? 0.8 : 1), width: 32, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(4), - ), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), ), ], ); @@ -164,9 +140,7 @@ class StepsCardWidgetState extends State { space: 6, child: Text( value.toInt() % meta.appliedInterval == 0 ? value.toInt().toString() : '', - style: fs14ls1.copyWith( - color: Theme.of(context).extension()!.grey600, - ), + style: fs14ls1.copyWith(color: Theme.of(context).extension()!.grey600), ), ); } @@ -175,10 +149,7 @@ class StepsCardWidgetState extends State { const style = TextStyle(fontSize: 10); return SideTitleWidget( meta: meta, - child: Text( - _getDayName(value.toInt()), - style: style, - ), + child: Text(_getDayName(value.toInt()), style: style), ); } diff --git a/lib/ui/cards/study_progress_card.dart b/lib/ui/cards/study_progress_card.dart index 268b0dea..b1cc044e 100644 --- a/lib/ui/cards/study_progress_card.dart +++ b/lib/ui/cards/study_progress_card.dart @@ -4,8 +4,11 @@ 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]}); + const StudyProgressCardWidget( + this.model, { + super.key, + this.colors = const [CACHET.BLUE_1, CACHET.RED_1, CACHET.GREY_6], + }); @override StudyProgressCardWidgetState createState() => StudyProgressCardWidgetState(); @@ -30,9 +33,7 @@ class StudyProgressCardWidgetState extends State { padding: const EdgeInsets.only(left: 8), child: Row( mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text(locale.translate('cards.study_progress.title'), style: fs16fw400ls1), - ], + children: [Text(locale.translate('cards.study_progress.title'), style: fs16fw400ls1)], ), ), SizedBox( @@ -154,13 +155,7 @@ class TaskProgressPainter extends CustomPainter { ); // draw a full circle as a background with a faint color - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - 0, - 2 * pi, - false, - paintBackground, - ); + canvas.drawArc(Rect.fromCircle(center: center, radius: radius), 0, 2 * pi, false, paintBackground); } } diff --git a/lib/ui/cards/survey_card.dart b/lib/ui/cards/survey_card.dart index 844985de..f2f4314a 100644 --- a/lib/ui/cards/survey_card.dart +++ b/lib/ui/cards/survey_card.dart @@ -34,13 +34,14 @@ class _SurveyCardState extends State { 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( + 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( @@ -55,38 +56,27 @@ class _SurveyCardState extends State { '${entry.value} ${locale.translate(entry.key).truncateTo(12)}', style: fs12fw400, ); - return Row( - children: [ - dot, - const SizedBox(width: 8), - text, - ], - ); - }).toList()), - ), - ), - // The pie chart - Expanded( - flex: 3, - child: Stack( - alignment: Alignment.center, - children: [ - PieChart( - PieChartData( - sections: pieChartSections, - startDegreeOffset: 270, - ), + return Row(children: [dot, const SizedBox(width: 8), text]); + }).toList(), ), - Text( - '$totalSurveys', - style: fs24fw700.copyWith( - color: Theme.of(context).extension()!.grey800, + ), + ), + // 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), ), - ) - ], + ], + ), ), - ), - ]), + ], + ), ), ], ), @@ -95,16 +85,14 @@ class _SurveyCardState extends State { } 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(); + 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(); } } diff --git a/lib/ui/carp_study_style.dart b/lib/ui/carp_study_style.dart index 632c5b71..6e2d162e 100644 --- a/lib/ui/carp_study_style.dart +++ b/lib/ui/carp_study_style.dart @@ -44,23 +44,24 @@ class StudyAppColors extends ThemeExtension { final Color? grey950; @override - StudyAppColors copyWith( - {Color? primary, - Color? warningColor, - Color? backgroundGray, - Color? tabBarBackground, - Color? white, - Color? grey50, - Color? grey100, - Color? grey200, - Color? grey300, - Color? grey400, - Color? grey500, - Color? grey600, - Color? grey700, - Color? grey800, - Color? grey900, - Color? grey950}) { + StudyAppColors copyWith({ + Color? primary, + Color? warningColor, + Color? backgroundGray, + Color? tabBarBackground, + Color? white, + Color? grey50, + Color? grey100, + Color? grey200, + Color? grey300, + Color? grey400, + Color? grey500, + Color? grey600, + Color? grey700, + Color? grey800, + Color? grey900, + Color? grey950, + }) { return StudyAppColors( primary: primary ?? this.primary, warningColor: warningColor ?? this.warningColor, @@ -126,51 +127,39 @@ ThemeData carpStudyTheme = ThemeData.light().copyWith( grey800: const Color(0xff2C2C2E), grey900: const Color(0xff1C1C1E), grey950: const Color(0xff0E0E0E), - ) + ), ], primaryColor: const Color(0xff006398), colorScheme: const ColorScheme.light().copyWith( - secondary: const Color(0xFFFAFAFA), - primary: const Color(0xFF206FA2), - tertiary: const ui.Color.fromARGB(255, 230, 230, 230)), + secondary: const Color(0xFFFAFAFA), + primary: const Color(0xFF206FA2), + tertiary: const ui.Color.fromARGB(255, 230, 230, 230), + ), //accentColor: Color(0xFFFAFAFA), //Color(0xffcce8fa), hoverColor: const Color(0xFFF1F9FF), scaffoldBackgroundColor: const Color(0xFFFFFFFF), - textTheme: ThemeData.light() - .textTheme + textTheme: ThemeData.light().textTheme .copyWith( - bodySmall: ThemeData.light().textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 14.0, - ), - bodyLarge: ThemeData.light().textTheme.bodyLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 18.0, - ), - bodyMedium: ThemeData.light().textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w400, - fontSize: 16.0, - ), - titleMedium: ThemeData.light() - .textTheme - .titleMedium! - .copyWith(fontWeight: FontWeight.w600, fontSize: 20.0, color: const Color(0xFF206FA2)), - titleLarge: ThemeData.light().textTheme.titleLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 20.0, - ), + bodySmall: ThemeData.light().textTheme.bodySmall!.copyWith(fontWeight: FontWeight.w500, fontSize: 14.0), + bodyLarge: ThemeData.light().textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 18.0), + bodyMedium: ThemeData.light().textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w400, fontSize: 16.0), + titleMedium: ThemeData.light().textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20.0, + color: const Color(0xFF206FA2), + ), + titleLarge: ThemeData.light().textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 20.0), headlineMedium: ThemeData.light().textTheme.headlineMedium!.copyWith( - fontWeight: FontWeight.w700, - fontSize: 30.0, - ), - labelLarge: ThemeData.light() - .textTheme - .labelLarge! - .copyWith(fontWeight: FontWeight.w500, fontSize: 16.0, color: Colors.white), + fontWeight: FontWeight.w700, + fontSize: 30.0, + ), + labelLarge: ThemeData.light().textTheme.labelLarge!.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16.0, + color: Colors.white, + ), ) - .apply( - fontFamily: 'OpenSans', - ), + .apply(fontFamily: 'OpenSans'), pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.android: CupertinoPageTransitionsBuilder(), @@ -198,7 +187,7 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( grey800: const Color(0xffF2F2F7), grey900: const Color(0xffF2F2F7), grey950: const Color(0xff0E0E0E), - ) + ), ], primaryColor: const Color(0xff0379ff), colorScheme: const ColorScheme.dark().copyWith( @@ -208,38 +197,26 @@ ThemeData carpStudyDarkTheme = ThemeData.dark().copyWith( ), // accentColor: Color(0xff4C4C4C), disabledColor: const Color(0xffcce8fa), - textTheme: ThemeData.dark() - .textTheme + textTheme: ThemeData.dark().textTheme .copyWith( - bodySmall: ThemeData.dark().textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 14.0, - ), - bodyLarge: ThemeData.dark().textTheme.bodyLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 18.0, - ), - bodyMedium: ThemeData.dark().textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w400, - fontSize: 16.0, - ), + bodySmall: ThemeData.dark().textTheme.bodySmall!.copyWith(fontWeight: FontWeight.w500, fontSize: 14.0), + bodyLarge: ThemeData.dark().textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 18.0), + bodyMedium: ThemeData.dark().textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w400, fontSize: 16.0), titleMedium: ThemeData.dark().textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.w600, - fontSize: 20.0, - color: const Color(0xff81C7F3), - ), - titleLarge: ThemeData.dark().textTheme.titleLarge!.copyWith( - fontWeight: FontWeight.w500, - fontSize: 20.0, - ), + fontWeight: FontWeight.w600, + fontSize: 20.0, + color: const Color(0xff81C7F3), + ), + titleLarge: ThemeData.dark().textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w500, fontSize: 20.0), headlineMedium: ThemeData.dark().textTheme.headlineMedium!.copyWith( - fontWeight: FontWeight.w700, - fontSize: 30.0, - ), - labelLarge: ThemeData.dark() - .textTheme - .labelLarge! - .copyWith(fontWeight: FontWeight.w500, fontSize: 16.0, color: Colors.grey.shade800), + fontWeight: FontWeight.w700, + fontSize: 30.0, + ), + labelLarge: ThemeData.dark().textTheme.labelLarge!.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16.0, + color: Colors.grey.shade800, + ), ) .apply( fontFamily: 'OpenSans', diff --git a/lib/ui/helpers.dart b/lib/ui/helpers.dart index b570c382..d60de25e 100644 --- a/lib/ui/helpers.dart +++ b/lib/ui/helpers.dart @@ -7,8 +7,8 @@ part of carp_study_app; extension MessageTypeUI on MessageType { /// The icon representing this message type. IconData get icon => switch (this) { - MessageType.announcement => Icons.campaign, - MessageType.news => Icons.newspaper, - MessageType.article => Icons.article, - }; + MessageType.announcement => Icons.campaign, + MessageType.news => Icons.newspaper, + MessageType.article => Icons.article, + }; } diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart index 73192d16..ae440c96 100644 --- a/lib/ui/pages/data_visualization_page.dart +++ b/lib/ui/pages/data_visualization_page.dart @@ -15,9 +15,9 @@ class _DataVisualizationPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: SafeArea( - child: Column( + backgroundColor: Theme.of(context).extension()!.backgroundGray, + body: SafeArea( + child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -35,11 +35,13 @@ class _DataVisualizationPageState extends State { 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, - )), + Text( + locale.translate('pages.data_viz.title'), + style: fs24fw700.copyWith( + color: Theme.of(context).extension()!.grey900, + fontWeight: FontWeight.bold, + ), + ), ], ), ), @@ -54,18 +56,20 @@ class _DataVisualizationPageState extends State { 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, - )), + 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. @@ -103,7 +107,7 @@ class _DataVisualizationPageState extends State { widgets.add(MediaCardWidget(mediaModelsList)); } - if (bloc.hasMeasure(SensorSamplingPackage.STEP_COUNT)) { + if (bloc.hasMeasure(CarpDataTypes.STEP_COUNT)) { widgets.add(StepsCardWidget(widget.model.stepsCardDataModel)); } if (bloc.hasMeasure(ContextSamplingPackage.ACTIVITY)) { diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index e28894ac..661d5a12 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -16,16 +16,20 @@ class DeviceListPageState extends State { StreamSubscription? bluetoothStateStream; BluetoothAdapterState? bluetoothAdapterState; - final List _smartphoneDevice = - bloc.deploymentDevices.where((element) => element.deviceManager is SmartphoneDeviceManager).toList(); + final List _smartphoneDevice = bloc.deploymentDevices + .where((element) => element.deviceManager is SmartphoneDeviceManager) + .toList(); final List _hardwareDevices = bloc.deploymentDevices - .where((element) => - element.deviceManager is HardwareDeviceManager && element.deviceManager is! SmartphoneDeviceManager) + .where( + (element) => + element.deviceManager is HardwareDeviceManager && element.deviceManager is! SmartphoneDeviceManager, + ) .toList(); - final List _onlineServices = - bloc.deploymentDevices.where((element) => element.deviceManager is OnlineServiceManager).toList(); + final List _onlineServices = bloc.deploymentDevices + .where((element) => element.deviceManager is ServiceManager) + .toList(); @override void initState() { @@ -88,10 +92,10 @@ class DeviceListPageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(locale.translate("pages.devices.message"), - style: fs16fw600.copyWith( - color: Theme.of(context).extension()!.grey600, - )), + Text( + locale.translate("pages.devices.message"), + style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey600), + ), const SizedBox(height: 15), ], ), @@ -116,97 +120,97 @@ class DeviceListPageState extends State { /// The list of smartphones - which is a list with only one smartphone. List _smartphoneDeviceList(RPLocalizations locale) => [ - DevicesPageListTitle(locale: locale, type: DevicesPageTypes.phone), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: _smartphoneDevice.length, - (BuildContext context, int index) => ListenableBuilder( - listenable: _smartphoneDevice[index], - builder: (BuildContext context, Widget? widget) => Center( - child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, - child: _cardListBuilder( - leading: _smartphoneDevice[index].icon!, - title: ( - "${_smartphoneDevice[index].phoneInfo["model"]!} " - "- ${_smartphoneDevice[index].phoneInfo["version"]!}", - _smartphoneDevice[index].batteryLevel ?? 0 - ), - subtitle: _smartphoneDevice[index].phoneInfo['name']!, - ), + DevicesPageListTitle(locale: locale, type: DevicesPageTypes.phone), + SliverList( + delegate: SliverChildBuilderDelegate( + childCount: _smartphoneDevice.length, + (BuildContext context, int index) => ListenableBuilder( + listenable: _smartphoneDevice[index], + builder: (BuildContext context, Widget? widget) => Center( + child: StudiesMaterial( + backgroundColor: Theme.of(context).extension()!.grey50!, + child: _cardListBuilder( + leading: _smartphoneDevice[index].icon!, + title: ( + "${_smartphoneDevice[index].phoneInfo["model"]!} " + "- ${_smartphoneDevice[index].phoneInfo["version"]!}", + _smartphoneDevice[index].batteryLevel ?? 0, ), + subtitle: _smartphoneDevice[index].phoneInfo['name']!, ), ), ), ), - ]; + ), + ), + ]; /// The list of connected hardware devices (like a Polar sensor) List _hardwareDevicesList(RPLocalizations locale) => [ - DevicesPageListTitle(locale: locale, type: DevicesPageTypes.devices), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: _hardwareDevices.length, - (BuildContext context, int index) { - DeviceViewModel device = _hardwareDevices[index]; - return _devicesPageCardStream( - device.statusEvents, - DeviceStatus.unknown, - () => _cardListBuilder( - enableFeedback: true, - leading: device.icon!, - title: (locale.translate(device.typeName), device.batteryLevel ?? 0), - subtitle: device.name, - onTap: () async => await _hardwareDeviceClicked(device), - trailing: device.getDeviceStatusIcon is Icon - ? device.getDeviceStatusIcon as Icon - : Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: CACHET.DEPLOYMENT_DEPLOYING, borderRadius: BorderRadius.circular(100)), - child: Text(locale.translate(device.getDeviceStatusIcon as String), - style: fs20fw700.copyWith(color: Colors.white)), - ), - ), - ); - }, + DevicesPageListTitle(locale: locale, type: DevicesPageTypes.devices), + SliverList( + delegate: SliverChildBuilderDelegate(childCount: _hardwareDevices.length, (BuildContext context, int index) { + DeviceViewModel device = _hardwareDevices[index]; + return _devicesPageCardStream( + device.statusEvents, + DeviceStatus.unknown, + () => _cardListBuilder( + enableFeedback: true, + leading: device.icon!, + title: (locale.translate(device.typeName), device.batteryLevel ?? 0), + subtitle: device.name, + onTap: () async => await _hardwareDeviceClicked(device), + trailing: device.getDeviceStatusIcon is Icon + ? device.getDeviceStatusIcon as Icon + : Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: CACHET.DEPLOYMENT_DEPLOYING, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + locale.translate(device.getDeviceStatusIcon as String), + style: fs20fw700.copyWith(color: Colors.white), + ), + ), ), - ), - ]; + ); + }), + ), + ]; /// The list of online services (like a Location service) List _onlineServicesList(RPLocalizations locale) => [ - DevicesPageListTitle(locale: locale, type: DevicesPageTypes.services), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: _onlineServices.length, - (BuildContext context, int index) { - DeviceViewModel service = _onlineServices[index]; - return _devicesPageCardStream( - service.statusEvents, - DeviceStatus.unknown, - () => _cardListBuilder( - leading: service.icon!, - title: (locale.translate(service.typeName), null), - subtitle: null, - onTap: () async => await _onlineServiceClicked(service), - trailing: service.getServiceStatusIcon is String - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: CACHET.DEPLOYMENT_DEPLOYING, borderRadius: BorderRadius.circular(100)), - child: Text( - locale.translate(service.getServiceStatusIcon as String), - style: fs20fw700.copyWith(color: Colors.white), - ), - ) - : service.getServiceStatusIcon as Icon, - ), - ); - }, + DevicesPageListTitle(locale: locale, type: DevicesPageTypes.services), + SliverList( + delegate: SliverChildBuilderDelegate(childCount: _onlineServices.length, (BuildContext context, int index) { + DeviceViewModel service = _onlineServices[index]; + return _devicesPageCardStream( + service.statusEvents, + DeviceStatus.unknown, + () => _cardListBuilder( + leading: service.icon!, + title: (locale.translate(service.typeName), null), + subtitle: null, + onTap: () async => await _onlineServiceClicked(service), + trailing: service.getServiceStatusIcon is String + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: CACHET.DEPLOYMENT_DEPLOYING, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + locale.translate(service.getServiceStatusIcon as String), + style: fs20fw700.copyWith(color: Colors.white), + ), + ) + : service.getServiceStatusIcon as Icon, ), - ), - ]; + ); + }), + ), + ]; Widget _cardListBuilder({ bool enableFeedback = false, @@ -215,76 +219,61 @@ class DeviceListPageState extends State { String? subtitle, void Function()? onTap, Widget? trailing, - }) => - ListTile( - contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - enableFeedback: enableFeedback, - leading: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [leading!], - ), - title: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + }) => ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + enableFeedback: enableFeedback, + leading: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [leading!], + ), + title: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text(title!.$1, style: fs16fw700.copyWith(color: Theme.of(context).extension()!.grey900)), + SizedBox(width: 6), + if (title.$2 != null && title.$2! > 0) BatteryPercentage(batteryLevel: title.$2 ?? 0), + ], + ), + ), + subtitle: subtitle != null && subtitle.isNotEmpty + ? Column( mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title!.$1, - style: fs16fw700.copyWith( - color: Theme.of(context).extension()!.grey900, + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + subtitle, + style: fs12fw700.copyWith(color: Theme.of(context).extension()!.grey700), ), ), - SizedBox(width: 6), - if (title.$2 != null && title.$2! > 0) BatteryPercentage(batteryLevel: title.$2 ?? 0), ], - ), - ), - subtitle: subtitle != null && subtitle.isNotEmpty - ? Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - subtitle, - style: fs12fw700.copyWith( - color: Theme.of(context).extension()!.grey700, - ), - ), - ), - ], - ) - : null, - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (trailing != null) trailing, - ], - ), - onTap: onTap, - ); + ) + : null, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [?trailing], + ), + onTap: onTap, + ); - Widget _devicesPageCardStream( - Stream stream, - T? initialData, - Widget Function() childBuilder, - ) => - Center( - child: StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, - child: StreamBuilder( - stream: stream, - initialData: initialData, - builder: (context, AsyncSnapshot snapshot) => childBuilder(), - ), - ), - ); + Widget _devicesPageCardStream(Stream stream, T? initialData, Widget Function() childBuilder) => Center( + child: StudiesMaterial( + backgroundColor: Theme.of(context).extension()!.grey50!, + child: StreamBuilder( + stream: stream, + initialData: initialData, + builder: (context, AsyncSnapshot snapshot) => childBuilder(), + ), + ), + ); Future _onlineServiceClicked(DeviceViewModel service) async { if (service.status == DeviceStatus.connected || service.status == DeviceStatus.connecting) { @@ -293,10 +282,7 @@ class DeviceListPageState extends State { if (!(await service.deviceManager.hasPermissions())) { if (service.type == HealthService.DEVICE_TYPE) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => HealthServiceConnectPage()), - ); + Navigator.push(context, MaterialPageRoute(builder: (context) => HealthServiceConnectPage())); } else { await service.deviceManager.requestPermissions(); } @@ -320,7 +306,8 @@ class DeviceListPageState extends State { ); } else if (bluetoothAdapterState == BluetoothAdapterState.on) { if (device.status == DeviceStatus.connected || device.status == DeviceStatus.connecting) { - bool disconnect = await showDialog( + bool disconnect = + await showDialog( context: context, barrierDismissible: true, builder: (context) => DisconnectionDialog(device: device), diff --git a/lib/ui/pages/devices_page.authorization_dialog.dart b/lib/ui/pages/devices_page.authorization_dialog.dart index 5c5781fb..9a945369 100644 --- a/lib/ui/pages/devices_page.authorization_dialog.dart +++ b/lib/ui/pages/devices_page.authorization_dialog.dart @@ -8,16 +8,15 @@ class AuthorizationDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - scrollable: true, - titlePadding: const EdgeInsets.symmetric(vertical: 4), - insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.devices.connection.bluetooth_authorization.title", - ), - content: SizedBox( - height: MediaQuery.of(context).size.height * 0.45, - child: authorizationInstructions(context, device), - )); + scrollable: true, + titlePadding: const EdgeInsets.symmetric(vertical: 4), + insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), + title: const DialogTitle(title: "pages.devices.connection.bluetooth_authorization.title"), + content: SizedBox( + height: MediaQuery.of(context).size.height * 0.45, + child: authorizationInstructions(context, device), + ), + ); } Widget authorizationInstructions(BuildContext context, DeviceViewModel device) { @@ -34,10 +33,12 @@ class AuthorizationDialog extends StatelessWidget { textAlign: TextAlign.justify, ), Image( - image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_bar.png'), - width: MediaQuery.of(context).size.height * 0.2, - height: MediaQuery.of(context).size.height * 0.2), + image: AssetImage( + 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_bar.png', + ), + width: MediaQuery.of(context).size.height * 0.2, + height: MediaQuery.of(context).size.height * 0.2, + ), ], ), ), diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index b74efd71..81d978e5 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -7,7 +7,7 @@ class BluetoothConnectionPage extends StatefulWidget { final DeviceViewModel device; const BluetoothConnectionPage(CurrentStep currentStep, {super.key, required this.device}) - : _currentStep = currentStep; + : _currentStep = currentStep; final CurrentStep _currentStep; @@ -64,9 +64,7 @@ class _BluetoothConnectionPageState extends State { Expanded( child: Padding( padding: const EdgeInsets.all(8.0), - child: SizedBox( - child: _buildStepContent(locale), - ), + child: SizedBox(child: _buildStepContent(locale)), ), ), Padding( @@ -86,9 +84,7 @@ class _BluetoothConnectionPageState extends State { if (isConnecting) Container( color: Colors.black26, - child: const Center( - child: CircularProgressIndicator(), - ), + child: const Center(child: CircularProgressIndicator()), ), ], ), @@ -113,9 +109,7 @@ class _BluetoothConnectionPageState extends State { Flexible( child: Text( stepTitleMap[currentStep] ?? '', - style: fs22fw700.copyWith( - color: Theme.of(context).primaryColor, - ), + style: fs22fw700.copyWith(color: Theme.of(context).primaryColor), textAlign: TextAlign.center, ), ), @@ -137,22 +131,30 @@ class _BluetoothConnectionPageState extends State { List _buildActionButtons(RPLocalizations locale) { Widget buildTranslatedButton( - String key, VoidCallback onPressed, bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { + String key, + VoidCallback onPressed, + bool enabled, + ButtonStyle? buttonStyle, + TextStyle? buttonTextStyle, + ) { return ElevatedButton( onPressed: enabled ? onPressed : null, - child: Text( - locale.translate(key).toUpperCase(), - style: buttonTextStyle, - ), + child: Text(locale.translate(key).toUpperCase(), style: buttonTextStyle), style: buttonStyle, ); } final stepButtonConfigs = { CurrentStep.scan: [ - buildTranslatedButton("cancel", () { - context.pop(true); - }, true, null, null), + buildTranslatedButton( + "cancel", + () { + context.pop(true); + }, + true, + null, + null, + ), buildTranslatedButton( "next", _connectDevice(), @@ -161,15 +163,19 @@ class _BluetoothConnectionPageState extends State { backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ), ], CurrentStep.instructions: [ - buildTranslatedButton("settings", () { - Platform.isAndroid ? OpenSettingsPlusAndroid().bluetooth() : OpenSettingsPlusIOS().bluetooth(); - }, true, null, null), + buildTranslatedButton( + "settings", + () { + Platform.isAndroid ? OpenSettingsPlusAndroid().bluetooth() : OpenSettingsPlusIOS().bluetooth(); + }, + true, + null, + null, + ), buildTranslatedButton( "ok", () { @@ -180,15 +186,19 @@ class _BluetoothConnectionPageState extends State { backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ), ], CurrentStep.done: [ - buildTranslatedButton("back", () { - setState(() => currentStep = CurrentStep.scan); - }, true, null, null), + buildTranslatedButton( + "back", + () { + setState(() => currentStep = CurrentStep.scan); + }, + true, + null, + null, + ), buildTranslatedButton( "done", () { @@ -200,9 +210,7 @@ class _BluetoothConnectionPageState extends State { backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ), ], }; @@ -270,10 +278,7 @@ class _BluetoothConnectionPageState extends State { }; } - Widget stepContent( - CurrentStep currentStep, - DeviceViewModel device, - ) { + Widget stepContent(CurrentStep currentStep, DeviceViewModel device) { if (currentStep == CurrentStep.scan) { return scanWidget(device, context); } else if (currentStep == CurrentStep.instructions) { @@ -320,9 +325,7 @@ class _BluetoothConnectionPageState extends State { selected: bluetoothDevice.key == selected, title: Text( bluetoothDevice.value.device.platformName, - style: fs22fw700.copyWith( - fontSize: 20, - ), + style: fs22fw700.copyWith(fontSize: 20), ), selectedTileColor: Theme.of(context).primaryColor.withValues(alpha: 0.2), ), @@ -346,9 +349,7 @@ class _BluetoothConnectionPageState extends State { child: Text.rich( TextSpan( children: [ - TextSpan( - text: locale.translate("pages.devices.connection.step.start.1"), - ), + TextSpan(text: locale.translate("pages.devices.connection.step.start.1")), TextSpan( text: locale.translate("pages.devices.connection.instructions"), style: TextStyle( @@ -361,17 +362,13 @@ class _BluetoothConnectionPageState extends State { setState(() => currentStep = CurrentStep.instructions); }, ), - TextSpan( - text: locale.translate( - "pages.devices.connection.step.start.2", - ), - ), + TextSpan(text: locale.translate("pages.devices.connection.step.start.2")), ], ), style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.center, ), - ) + ), ], ), ); @@ -389,7 +386,7 @@ class _BluetoothConnectionPageState extends State { break; case PolarDeviceManager _ - when device.type == PolarDevice.DEVICE_TYPE && device.polarDeviceType == PolarDeviceType.SENSE: + when device.type == PolarDevice.DEVICE_TYPE && device.polarDeviceType == PolarDeviceType.Verity: assetImage = AssetImage('assets/instructions/polar_sense_instructions.png'); break; @@ -447,9 +444,10 @@ class _BluetoothConnectionPageState extends State { child: Column( children: [ Image( - image: const AssetImage('assets/icons/connection_done.png'), - width: MediaQuery.of(context).size.height * 0.2, - height: MediaQuery.of(context).size.height * 0.2), + image: const AssetImage('assets/icons/connection_done.png'), + width: MediaQuery.of(context).size.height * 0.2, + height: MediaQuery.of(context).size.height * 0.2, + ), Padding( padding: const EdgeInsets.only(top: 32), child: Text( diff --git a/lib/ui/pages/devices_page.disconnection_dialog.dart b/lib/ui/pages/devices_page.disconnection_dialog.dart index 90ab9c7d..a27e9321 100644 --- a/lib/ui/pages/devices_page.disconnection_dialog.dart +++ b/lib/ui/pages/devices_page.disconnection_dialog.dart @@ -8,15 +8,12 @@ class DisconnectionDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - scrollable: true, - titlePadding: const EdgeInsets.symmetric(vertical: 4), - insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.devices.connection.disconnect_bluetooth.title", - ), - content: SizedBox( - child: disconnectBluetooth(context, device), - )); + scrollable: true, + titlePadding: const EdgeInsets.symmetric(vertical: 4), + insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), + title: const DialogTitle(title: "pages.devices.connection.disconnect_bluetooth.title"), + content: SizedBox(child: disconnectBluetooth(context, device)), + ); } Widget disconnectBluetooth(BuildContext context, DeviceViewModel device) { @@ -33,9 +30,7 @@ class DisconnectionDialog extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton( - child: Text( - locale.translate("cancel"), - ), + child: Text(locale.translate("cancel")), onPressed: () { if (context.canPop()) context.pop(false); }, @@ -44,12 +39,10 @@ class DisconnectionDialog extends StatelessWidget { onPressed: () { if (context.canPop()) context.pop(true); }, - child: Text( - locale.translate("pages.devices.connection.disconnect_bluetooth.disconnect"), - ), + child: Text(locale.translate("pages.devices.connection.disconnect_bluetooth.disconnect")), ), ], - ) + ), ], ); } diff --git a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart index 93390b82..b31d02c3 100644 --- a/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart +++ b/lib/ui/pages/devices_page.enable_bluetooth_dialog.dart @@ -8,14 +8,15 @@ class EnableBluetoothDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - scrollable: true, - titlePadding: const EdgeInsets.symmetric(vertical: 4), - insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle(title: "pages.devices.connection.enable_bluetooth.title"), - content: SizedBox( - height: MediaQuery.of(context).size.height * 0.45, - child: enableBluetoothInstructions(context, device), - )); + scrollable: true, + titlePadding: const EdgeInsets.symmetric(vertical: 4), + insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), + title: const DialogTitle(title: "pages.devices.connection.enable_bluetooth.title"), + content: SizedBox( + height: MediaQuery.of(context).size.height * 0.45, + child: enableBluetoothInstructions(context, device), + ), + ); } Widget enableBluetoothInstructions(BuildContext context, DeviceViewModel device) { @@ -31,9 +32,7 @@ class EnableBluetoothDialog extends StatelessWidget { style: fs16fw400, textAlign: TextAlign.justify, ), - Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - ), + Padding(padding: EdgeInsets.symmetric(vertical: 16.0)), Text( locale.translate("pages.devices.connection.enable_bluetooth.message2"), style: fs16fw400, @@ -44,7 +43,8 @@ class EnableBluetoothDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_connections_bar.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/bluetooth_enable_connections_bar.png', + ), ), ), Text( diff --git a/lib/ui/pages/devices_page.health_service_connect.dart b/lib/ui/pages/devices_page.health_service_connect.dart index 214f486c..03ac809c 100644 --- a/lib/ui/pages/devices_page.health_service_connect.dart +++ b/lib/ui/pages/devices_page.health_service_connect.dart @@ -8,7 +8,7 @@ class HealthServiceConnectPage extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; DeviceViewModel healthServive = bloc.deploymentDevices - .where((element) => element.deviceManager is OnlineServiceManager && element.type == HealthService.DEVICE_TYPE) + .where((element) => element.deviceManager is ServiceManager && element.type == HealthService.DEVICE_TYPE) .first; return Scaffold( @@ -17,10 +17,7 @@ class HealthServiceConnectPage extends StatelessWidget { child: Container( child: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), - child: const CarpAppBar(), - ), + Padding(padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 18), child: const CarpAppBar()), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), @@ -44,9 +41,7 @@ class HealthServiceConnectPage extends StatelessWidget { children: [ TextSpan( text: "${locale.translate("pages.devices.type.health.instructions.page2.part1")} ", - style: fs22fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - ), + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), TextSpan( text: @@ -57,9 +52,7 @@ class HealthServiceConnectPage extends StatelessWidget { ), TextSpan( text: "${locale.translate("pages.devices.type.health.instructions.page2.part2")} ", - style: fs22fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - ), + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), TextSpan( text: "${locale.translate("pages.devices.type.health.instructions.page2.allow")} ", @@ -71,9 +64,7 @@ class HealthServiceConnectPage extends StatelessWidget { text: Platform.isAndroid ? locale.translate("pages.devices.type.health.instructions.page2.part3.android") : locale.translate("pages.devices.type.health.instructions.page2.part3.ios"), - style: fs22fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - ), + style: fs22fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), ], ), @@ -100,12 +91,7 @@ class HealthServiceConnectPage extends StatelessWidget { }, ), ElevatedButton( - child: const Text( - "Next", - style: TextStyle( - color: Colors.white, - ), - ), + child: const Text("Next", style: TextStyle(color: Colors.white)), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), diff --git a/lib/ui/pages/devices_page.list_title.dart b/lib/ui/pages/devices_page.list_title.dart index 97d3fd73..1afaf6ba 100644 --- a/lib/ui/pages/devices_page.list_title.dart +++ b/lib/ui/pages/devices_page.list_title.dart @@ -1,17 +1,9 @@ part of carp_study_app; -enum DevicesPageTypes { - phone, - services, - devices, -} +enum DevicesPageTypes { phone, services, devices } class DevicesPageListTitle extends StatelessWidget { - const DevicesPageListTitle({ - super.key, - required this.locale, - required this.type, - }); + const DevicesPageListTitle({super.key, required this.locale, required this.type}); final RPLocalizations locale; final DevicesPageTypes type; @@ -21,9 +13,13 @@ class DevicesPageListTitle extends StatelessWidget { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), - child: Text(locale.translate("pages.devices.${type.name}.title").toUpperCase(), - style: fs16fw400ls1.copyWith( - color: Theme.of(context).extension()!.grey900, fontWeight: FontWeight.bold)), + child: Text( + locale.translate("pages.devices.${type.name}.title").toUpperCase(), + style: fs16fw400ls1.copyWith( + color: Theme.of(context).extension()!.grey900, + fontWeight: FontWeight.bold, + ), + ), ), ); } diff --git a/lib/ui/pages/enable_connection_dialog.dart b/lib/ui/pages/enable_connection_dialog.dart index e8d7f656..ef7bcaad 100644 --- a/lib/ui/pages/enable_connection_dialog.dart +++ b/lib/ui/pages/enable_connection_dialog.dart @@ -38,17 +38,19 @@ class EnableInternetConnectionDialog extends StatelessWidget { textAlign: TextAlign.justify, ), Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: Text( - locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), - style: fs16fw400, - textAlign: TextAlign.justify, - )), + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Text( + locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), + style: fs16fw400, + textAlign: TextAlign.justify, + ), + ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_android.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_android.png', + ), ), ), Padding( @@ -63,7 +65,8 @@ class EnableInternetConnectionDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_android.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_android.png', + ), ), ), ], @@ -109,35 +112,41 @@ class EnableInternetConnectionDialog extends StatelessWidget { textAlign: TextAlign.justify, ), Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: Text( - locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), - style: fs16fw400, - textAlign: TextAlign.justify, - )), + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Text( + locale.translate("pages.login.internet_connection.enable_internet_connections.wifi_message"), + style: fs16fw400, + textAlign: TextAlign.justify, + ), + ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_ios.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_wifi_ios.png', + ), ), ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), - child: Column(children: [ - Text( - locale - .translate("pages.login.internet_connection.enable_internet_connections.mobile_data_message"), - style: fs16fw400, - textAlign: TextAlign.justify, - ), - ]), + child: Column( + children: [ + Text( + locale.translate( + "pages.login.internet_connection.enable_internet_connections.mobile_data_message", + ), + style: fs16fw400, + textAlign: TextAlign.justify, + ), + ], + ), ), Padding( padding: EdgeInsets.symmetric(vertical: 16.0), child: Image( image: AssetImage( - 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_ios.png'), + 'assets/instructions/${Localizations.localeOf(context).languageCode}/enable_mobile_data_ios.png', + ), ), ), ], diff --git a/lib/ui/pages/error_page.dart b/lib/ui/pages/error_page.dart index 9cd9db47..cbe99566 100644 --- a/lib/ui/pages/error_page.dart +++ b/lib/ui/pages/error_page.dart @@ -6,22 +6,14 @@ class ErrorPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Error'), - ), + appBar: AppBar(title: const Text('Error')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - "Error", - style: TextStyle(fontSize: 18.0), - ), + const Text("Error", style: TextStyle(fontSize: 18.0)), const SizedBox(height: 16.0), - ElevatedButton( - onPressed: () => context.go(CarpStudyAppState.homeRoute), - child: const Text('Go back'), - ), + ElevatedButton(onPressed: () => context.go(CarpStudyAppState.homeRoute), child: const Text('Go back')), ], ), ), diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 3b4f7a7a..6b112e7a 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -99,9 +99,7 @@ class HomePageState extends State { return Scaffold( backgroundColor: Theme.of(context).extension()!.backgroundGray, - body: SafeArea( - child: widget.child, - ), + body: SafeArea(child: widget.child), bottomNavigationBar: BottomNavigationBar( backgroundColor: Theme.of(context).extension()!.white, type: BottomNavigationBarType.fixed, @@ -109,22 +107,25 @@ class HomePageState extends State { //unselectedItemColor: Theme.of(context).primaryColor.withOpacity(0.8), items: [ BottomNavigationBarItem( - icon: const Icon(Icons.announcement), - label: locale.translate('app_home.nav_bar_item.about'), - activeIcon: const Icon(Icons.announcement)), + 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)), + 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)), + 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), diff --git a/lib/ui/pages/home_page.install_health_connect_dialog.dart b/lib/ui/pages/home_page.install_health_connect_dialog.dart index 7f6e6ea7..d36b8df1 100644 --- a/lib/ui/pages/home_page.install_health_connect_dialog.dart +++ b/lib/ui/pages/home_page.install_health_connect_dialog.dart @@ -9,19 +9,14 @@ class InstallHealthConnectDialog extends StatelessWidget { return AlertDialog( titlePadding: const EdgeInsets.symmetric(vertical: 4), insetPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), - title: const DialogTitle( - title: "pages.about.install_health_connect.title", - ), + title: const DialogTitle(title: "pages.about.install_health_connect.title"), content: Text( locale.translate('pages.about.install_health_connect.description'), style: fs16fw400, textAlign: TextAlign.justify, ), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(locale.translate('cancel')), - ), + TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(locale.translate('cancel'))), TextButton( child: Text(locale.translate('install')), onPressed: () async { @@ -34,8 +29,9 @@ class InstallHealthConnectDialog extends StatelessWidget { } void _redirectToHealthConnectPlayStore() async { - final Uri url = - Uri.parse('https://play.google.com/store/apps/details?id=${LocalSettings.healthConnectPackageName}'); + final Uri url = Uri.parse( + 'https://play.google.com/store/apps/details?id=${LocalSettings.healthConnectPackageName}', + ); var canLaunch = await canLaunchUrl(url); if (canLaunch) { await launchUrl(url); diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index 52556e07..a0f69107 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -42,26 +42,21 @@ class InformedConsentState extends State { 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 bloc.informedConsentHasBeenAccepted(); - if (mounted) context.go(CarpStudyAppState.homeRoute); - } - return document; - }, - ), + 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 bloc.informedConsentHasBeenAccepted(); + if (mounted) context.go(CarpStudyAppState.homeRoute); + } + return document; + }), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasData) { - return RPUITask( - task: snapshot.data!, - onSubmit: resultCallback, - ); + return RPUITask(task: snapshot.data!, onSubmit: resultCallback); } } diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index bfe9c761..9838b1f8 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -40,21 +40,12 @@ class _InvitationListPageState extends State { if (snapshot.hasData) { child = SliverFixedExtentList( itemExtent: 150, - delegate: SliverChildBuilderDelegate( - (context, index) { - return Container( - child: InvitationMaterial( - invitation: snapshot.data![index], - ), - ); - }, - childCount: snapshot.data!.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + return Container(child: InvitationMaterial(invitation: snapshot.data![index])); + }, childCount: snapshot.data!.length), ); } else { - child = const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator()), - ); + child = const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator())); } return CustomScrollView( @@ -88,10 +79,7 @@ class _InvitationListPageState extends State { child: Text( locale.translate('invitation.invitations'), textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22.0), ), ), ], @@ -107,10 +95,7 @@ class _InvitationListPageState extends State { child: Text( locale.translate('invitation.subtitle'), textAlign: TextAlign.left, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14.0), ), ), ), @@ -128,19 +113,14 @@ class _InvitationListPageState extends State { class InvitationMaterial extends StatelessWidget { final ActiveParticipationInvitation invitation; - const InvitationMaterial({ - super.key, - required this.invitation, - }); + const InvitationMaterial({super.key, required this.invitation}); @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return StudiesMaterial( backgroundColor: Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), child: InkWell( onTap: () { context.push('${InvitationDetailsPage.route}/${invitation.participation.participantId}'); diff --git a/lib/ui/pages/invitation_page.dart b/lib/ui/pages/invitation_page.dart index bda254e7..7757e138 100644 --- a/lib/ui/pages/invitation_page.dart +++ b/lib/ui/pages/invitation_page.dart @@ -5,11 +5,7 @@ class InvitationDetailsPage extends StatelessWidget { final String invitationId; final InvitationsViewModel model; - const InvitationDetailsPage({ - super.key, - required this.invitationId, - required this.model, - }); + const InvitationDetailsPage({super.key, required this.invitationId, required this.model}); @override Widget build(BuildContext context) { @@ -46,10 +42,7 @@ class InvitationDetailsPage extends StatelessWidget { child: Text( locale.translate('invitation.invited_to_study'), textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22.0), ), ), ], @@ -59,9 +52,7 @@ class InvitationDetailsPage extends StatelessWidget { padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( backgroundColor: Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -69,10 +60,7 @@ class InvitationDetailsPage extends StatelessWidget { children: [ Text( locale.translate('invitation.roles_in_the_study.title'), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20.0, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20.0), ), Padding( padding: const EdgeInsets.only(top: 8), @@ -91,16 +79,9 @@ class InvitationDetailsPage extends StatelessWidget { padding: const EdgeInsets.only(top: 16.0), child: StudiesMaterial( backgroundColor: Theme.of(context).extension()!.white!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), child: Padding( - padding: const EdgeInsets.only( - right: 24.0, - left: 24.0, - top: 16.0, - bottom: 16.0, - ), + padding: const EdgeInsets.only(right: 24.0, left: 24.0, top: 16.0, bottom: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -151,10 +132,7 @@ class InvitationDetailsPage extends StatelessWidget { Container( margin: const EdgeInsets.only(left: 16, right: 16, bottom: 16), height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), + decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), child: TextButton( onPressed: () { bloc.setStudyInvitation(invitation, context); diff --git a/lib/ui/pages/login_page.dart b/lib/ui/pages/login_page.dart index fcc72f02..1de073cb 100644 --- a/lib/ui/pages/login_page.dart +++ b/lib/ui/pages/login_page.dart @@ -20,96 +20,85 @@ class _LoginPageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - body: SafeArea( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.symmetric(vertical: 32, horizontal: 56), - child: Image.asset( - 'assets/carp_logo.png', - fit: BoxFit.contain, + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 32, horizontal: 56), + child: Image.asset('assets/carp_logo.png', fit: BoxFit.contain), + ), ), - ), - ), - Container( - margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - width: MediaQuery.of(context).size.width, - height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), - child: TextButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => QRViewExample(), - ); - }, - child: Text( - locale.translate("scan"), - style: const TextStyle(color: Color(0xffffffff), fontSize: 22), - textAlign: TextAlign.center, + Container( + margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), + width: MediaQuery.of(context).size.width, + height: 56, + decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), + child: TextButton( + onPressed: () { + showDialog(context: context, builder: (context) => QRViewExample()); + }, + child: Text( + locale.translate("scan"), + style: const TextStyle(color: Color(0xffffffff), fontSize: 22), + textAlign: TextAlign.center, + ), + ), ), - ), - ), - Container( - margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - width: MediaQuery.of(context).size.width, - height: 56, - decoration: BoxDecoration( - color: const Color(0xff006398), - borderRadius: BorderRadius.circular(100), - ), - child: TextButton( - onPressed: () async { - bool isConnected = await bloc.checkConnectivity(); - if (isConnected) { - await bloc.backend.initialize(); - await bloc.backend.authenticate(); - if (context.mounted) context.go(CarpStudyAppState.homeRoute); - } else { - showDialog( - context: context, - builder: (context) => PopScope( - onPopInvokedWithResult: (didPop, result) async { - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (didPop && result == true) { - Navigator.of(context).pop(); - } - }); - }, - child: EnableInternetConnectionDialog(), - ), - ); - } - }, - child: Text( - locale.translate("pages.login.login"), - style: const TextStyle(color: Color(0xffffffff), fontSize: 22), - textAlign: TextAlign.center, + Container( + margin: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), + width: MediaQuery.of(context).size.width, + height: 56, + decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), + child: TextButton( + onPressed: () async { + bool isConnected = await bloc.checkConnectivity(); + if (isConnected) { + await bloc.backend.initialize(); + await bloc.backend.authenticate(); + if (context.mounted) context.go(CarpStudyAppState.homeRoute); + } else { + showDialog( + context: context, + builder: (context) => PopScope( + onPopInvokedWithResult: (didPop, result) async { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (didPop && result == true) { + Navigator.of(context).pop(); + } + }); + }, + child: EnableInternetConnectionDialog(), + ), + ); + } + }, + child: Text( + locale.translate("pages.login.login"), + style: const TextStyle(color: Color(0xffffffff), fontSize: 22), + textAlign: TextAlign.center, + ), + ), ), - ), + if (bloc.backend.isAuthenticated) + TextButton( + onPressed: () { + showDialog(context: context, builder: (context) => const LogoutMessage()).then((value) async { + if (value == true) { + await bloc.backend.signOut(); + setState(() {}); + } + }); + }, + child: Text(locale.translate('pages.login.logout')), + ), + ], ), - if (bloc.backend.isAuthenticated) - TextButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => const LogoutMessage(), - ).then((value) async { - if (value == true) { - await bloc.backend.signOut(); - setState(() {}); - } - }); - }, - child: Text(locale.translate('pages.login.logout')), - ) - ])))); + ), + ), + ); } } diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 8a8c6b45..61ed4dbd 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -4,24 +4,25 @@ class MessageDetailsPage extends StatelessWidget { static const String route = '/message'; final String messageId; - const MessageDetailsPage({ - super.key, - required this.messageId, - }); + const MessageDetailsPage({super.key, required this.messageId}); @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - Message message = bloc.messages.firstWhere((element) => element.id == messageId, orElse: () { - return Message( + Message message = bloc.messages.firstWhere( + (element) => element.id == messageId, + orElse: () { + return Message( id: '0', title: 'Unknown message', subTitle: 'Unknown message', type: MessageType.announcement, timestamp: DateTime.now(), - image: './assets/images/kids.png'); - }); + image: './assets/images/kids.png', + ); + }, + ); return Scaffold( body: SafeArea( @@ -37,10 +38,7 @@ class MessageDetailsPage extends StatelessWidget { 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, - ), + icon: Icon(Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600), onPressed: () { if (context.canPop()) { context.pop(); @@ -51,8 +49,10 @@ class MessageDetailsPage extends StatelessWidget { ), Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text(locale.translate(message.title!), - style: fs20fw700.copyWith(color: Theme.of(context).extension()!.grey900)), + child: Text( + locale.translate(message.title!), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.grey900), + ), ), Spacer(), Padding( @@ -62,8 +62,10 @@ class MessageDetailsPage extends StatelessWidget { borderRadius: BorderRadius.circular(100.0), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), - child: Text(locale.translate(message.type.toString().split('.').last.toLowerCase()), - style: fs16fw600.copyWith(color: Colors.white)), + child: Text( + locale.translate(message.type.toString().split('.').last.toLowerCase()), + style: fs16fw600.copyWith(color: Colors.white), + ), ), ), ), @@ -76,24 +78,26 @@ class MessageDetailsPage extends StatelessWidget { message.subTitle != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), - child: Text(locale.translate(message.subTitle!), - style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700)), + child: Text( + locale.translate(message.subTitle!), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700), + ), ) : const SizedBox.shrink(), if (message.image != null && message.image!.isNotEmpty) - LayoutBuilder(builder: (context, constraints) { - final screenHeight = MediaQuery.of(context).size.height; - final screenWidth = MediaQuery.of(context).size.height; - return ConstrainedBox( - constraints: BoxConstraints( - maxWidth: screenHeight, - maxHeight: screenWidth, - ), - child: FittedBox( + 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: bloc.appViewModel.studyPageViewModel.getMessageImage(message.image)), - ); - }), + child: bloc.appViewModel.studyPageViewModel.getMessageImage(message.image), + ), + ); + }, + ), // DetailsBanner(message.title ?? '', message.image), Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), @@ -105,7 +109,7 @@ class MessageDetailsPage extends StatelessWidget { locale.translate(message.message!), style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), textAlign: TextAlign.justify, - ) + ), ], ), ), diff --git a/lib/ui/pages/process_message_page.dart b/lib/ui/pages/process_message_page.dart index cca49cc4..4f523923 100644 --- a/lib/ui/pages/process_message_page.dart +++ b/lib/ui/pages/process_message_page.dart @@ -1,10 +1,6 @@ part of carp_study_app; -enum ProcessStatus { - done, - error, - other, -} +enum ProcessStatus { done, error, other } class ProcessMessagePage extends StatelessWidget { // Type of message to display (e.g Error, Success, Informative) @@ -25,14 +21,15 @@ class ProcessMessagePage extends StatelessWidget { // Display cancel button. If true the button is displayed. final bool canCancel; - const ProcessMessagePage( - {super.key, - required this.statusType, - required this.title, - required this.description, - required this.actionFunction, - required this.actionText, - this.canCancel = true}); + const ProcessMessagePage({ + super.key, + required this.statusType, + required this.title, + required this.description, + required this.actionFunction, + required this.actionText, + this.canCancel = true, + }); @override Widget build(BuildContext context) { @@ -41,15 +38,21 @@ class ProcessMessagePage extends StatelessWidget { switch (statusType) { case ProcessStatus.done: image = Image( - image: const AssetImage('assets/icons/done.png'), height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/done.png'), + height: MediaQuery.of(context).size.height * 0.35, + ); break; case ProcessStatus.error: image = Image( - image: const AssetImage('assets/icons/error.png'), height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/error.png'), + height: MediaQuery.of(context).size.height * 0.35, + ); break; case ProcessStatus.other: image = Image( - image: const AssetImage('assets/icons/info.png'), height: MediaQuery.of(context).size.height * 0.35); + image: const AssetImage('assets/icons/info.png'), + height: MediaQuery.of(context).size.height * 0.35, + ); break; } @@ -58,52 +61,57 @@ class ProcessMessagePage extends StatelessWidget { RPLocalizations locale = RPLocalizations.of(context)!; return Scaffold( - body: Padding( - padding: const EdgeInsets.all(15.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox(height: 40), - messageImage(), - const SizedBox(height: 20), - Center(child: Text(locale.translate(title), style: fs22fw700)), - const SizedBox(height: 10), - Text(locale.translate(description), style: fs16fw600), - ]), - ), - bottomSheet: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + body: Padding( + padding: const EdgeInsets.all(15.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, children: [ - canCancel == true - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(width: 15), - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(locale.translate('cancel').toUpperCase(), - style: TextStyle(color: Theme.of(context).primaryColor))), - const SizedBox(width: 10), - ], - ) - : const SizedBox.shrink(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor), - onPressed: () { - actionFunction(); - }, - child: Text(locale.translate(actionText).toUpperCase()), - ), - const SizedBox(width: 15), - ], - ), + const SizedBox(height: 40), + messageImage(), + const SizedBox(height: 20), + Center(child: Text(locale.translate(title), style: fs22fw700)), + const SizedBox(height: 10), + Text(locale.translate(description), style: fs16fw600), ], - )); + ), + ), + bottomSheet: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + canCancel == true + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 15), + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + locale.translate('cancel').toUpperCase(), + style: TextStyle(color: Theme.of(context).primaryColor), + ), + ), + const SizedBox(width: 10), + ], + ) + : const SizedBox.shrink(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor), + onPressed: () { + actionFunction(); + }, + child: Text(locale.translate(actionText).toUpperCase()), + ), + const SizedBox(width: 15), + ], + ), + ], + ), + ); } } diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 9d438717..44365339 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -41,8 +41,10 @@ class ProfilePageState extends State { 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)), + label: Text( + locale.translate("pages.profile.title"), + style: fs20fw700.copyWith(color: Theme.of(context).primaryColor), + ), ), IconButton( icon: Icon(Icons.close, color: Theme.of(context).primaryColor, size: 30), @@ -61,114 +63,75 @@ class ProfilePageState extends State { child: ListView( padding: EdgeInsets.zero, children: [ - _buildSectionCard( - context, - [ - _buildListTile( - locale.translate('pages.profile.username'), - widget.model.username, - ), - _buildListTile( - locale.translate('pages.profile.account_id'), - widget.model.userId, - ), - _buildListTile( - locale.translate('pages.profile.full_name'), - LocalSettings().isAnonymous - ? locale.translate('pages.about.anonymous.anonymous') - : widget.model.fullName, - ), - _buildListTile( - locale.translate('pages.profile.email'), - LocalSettings().isAnonymous - ? locale.translate('pages.about.anonymous.anonymous') - : widget.model.email, - ), - ], - ), - _buildSectionCard( - context, - [ - _buildListTile( - locale.translate('pages.profile.study_id'), - widget.model.studyId, - ), - _buildListTile( - locale.translate('pages.profile.study_deployment_id'), - widget.model.studyDeploymentId, - ), - _buildListTile( - locale.translate('pages.profile.study_name'), - locale.translate(widget.model.studyDeploymentTitle), - ), - _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.username'), widget.model.username), + _buildListTile(locale.translate('pages.profile.account_id'), widget.model.userId), _buildListTile( - locale.translate('pages.profile.app_version'), - appVersion, + locale.translate('pages.profile.full_name'), + LocalSettings().isAnonymous + ? locale.translate('pages.about.anonymous.anonymous') + : widget.model.fullName, ), _buildListTile( - locale.translate('pages.profile.app_version_code'), - buildNumber, + locale.translate('pages.profile.email'), + LocalSettings().isAnonymous + ? locale.translate('pages.about.anonymous.anonymous') + : widget.model.email, ), + ]), + _buildSectionCard(context, [ + _buildListTile(locale.translate('pages.profile.study_id'), widget.model.studyId), _buildListTile( - locale.translate('pages.profile.server_name'), - widget.model.currentServer, + locale.translate('pages.profile.study_deployment_id'), + widget.model.studyDeploymentId, ), _buildListTile( - locale.translate('pages.profile.device_id'), - widget.model.deviceID, + locale.translate('pages.profile.study_name'), + locale.translate(widget.model.studyDeploymentTitle), + ), + _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 {} + }, ), ]), - _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 {} - }, - ), - ], - ), _buildSectionCard(context, [ _buildActionListTile( leading: const Icon(Icons.logout, color: CACHET.RED_1), @@ -191,7 +154,7 @@ class ProfilePageState extends State { } }, ), - ]) + ]), ], ), ), @@ -204,9 +167,7 @@ class ProfilePageState extends State { Widget _buildSectionCard(BuildContext context, List children) { return Card( color: Theme.of(context).extension()!.grey50, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -226,16 +187,12 @@ class ProfilePageState extends State { subtitle: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, - child: Text( - subtitle, - style: fs14fw600, - maxLines: 1, - ), + child: Text(subtitle, style: fs14fw600, maxLines: 1), ), ); } -// Helper method to build a ListTile for actions with an icon + // Helper method to build a ListTile for actions with an icon Widget _buildActionListTile({ required Icon leading, required String title, @@ -263,8 +220,11 @@ class ProfilePageState extends State { /// 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"); + final url = Uri( + scheme: 'mailto', + path: email, + queryParameters: {'subject': subject}, + ).toString().replaceAll("+", "%20"); try { await launchUrl(Uri.parse(url)); } finally {} @@ -280,21 +240,23 @@ class ProfilePageState extends State { title: Text(locale.translate("pages.profile.log_out.confirmation")), actions: [ TextButton( - child: Text(locale.translate("NO")), - onPressed: () async { - if (builderContext.mounted) { - Navigator.of(builderContext).pop(); - } - }), + child: Text(locale.translate("NO")), + onPressed: () async { + if (builderContext.mounted) { + Navigator.of(builderContext).pop(); + } + }, + ), TextButton( - child: Text(locale.translate("YES")), - onPressed: () async { - if (builderContext.mounted) { - await bloc.signOutAndLeaveStudy(); - builderContext.pop(); - builderContext.go(CarpStudyAppState.homeRoute); - } - }), + child: Text(locale.translate("YES")), + onPressed: () async { + if (builderContext.mounted) { + await bloc.signOutAndLeaveStudy(); + builderContext.pop(); + builderContext.go(CarpStudyAppState.homeRoute); + } + }, + ), ], ); }, @@ -319,14 +281,15 @@ class ProfilePageState extends State { }, ), TextButton( - child: Text(locale.translate("YES")), - onPressed: () async { - if (builderContext.mounted) { - await bloc.leaveStudy(); - builderContext.pop(); - builderContext.go(InvitationListPage.route); - } - }), + child: Text(locale.translate("YES")), + onPressed: () async { + if (builderContext.mounted) { + await bloc.leaveStudy(); + builderContext.pop(); + builderContext.go(InvitationListPage.route); + } + }, + ), ], ); }, @@ -346,18 +309,15 @@ 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, - ); - }, - ); + : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + var begin = Offset(1.0, 0.0); + var end = Offset.zero; + var curve = Curves.easeInOut; + var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); + var offsetAnimation = animation.drive(tween); + return SlideTransition(position: offsetAnimation, child: child); + }, + ); } diff --git a/lib/ui/pages/qr_scanner.dart b/lib/ui/pages/qr_scanner.dart index 430337aa..e589bf1b 100644 --- a/lib/ui/pages/qr_scanner.dart +++ b/lib/ui/pages/qr_scanner.dart @@ -45,20 +45,21 @@ class _QRViewExampleState extends State { margin: const EdgeInsets.all(8), height: 30, child: ElevatedButton( - onPressed: () async { - await controller?.flipCamera(); - setState(() {}); + onPressed: () async { + await controller?.flipCamera(); + setState(() {}); + }, + child: FutureBuilder( + future: controller?.getCameraInfo(), + builder: (context, snapshot) { + if (snapshot.data != null) { + return Icon(Icons.cameraswitch); + } else { + return const Text('loading'); + } }, - child: FutureBuilder( - future: controller?.getCameraInfo(), - builder: (context, snapshot) { - if (snapshot.data != null) { - return Icon(Icons.cameraswitch); - } else { - return const Text('loading'); - } - }, - )), + ), + ), ), Container( margin: const EdgeInsets.all(8), @@ -75,7 +76,7 @@ class _QRViewExampleState extends State { ], ), ), - ) + ), ], ), ), @@ -84,15 +85,21 @@ class _QRViewExampleState extends State { Widget _buildQrView(BuildContext context) { // For this example we check how width or tall the device is and change the scanArea and overlay accordingly. - var scanArea = - (MediaQuery.of(context).size.width < 400 || MediaQuery.of(context).size.height < 400) ? 150.0 : 300.0; + var scanArea = (MediaQuery.of(context).size.width < 400 || MediaQuery.of(context).size.height < 400) + ? 150.0 + : 300.0; // To ensure the Scanner view is properly sizes after rotation // we need to listen for Flutter SizeChanged notification and update controller return qr.QRView( key: qrKey, onQRViewCreated: _onQRViewCreated, overlay: qr.QrScannerOverlayShape( - borderColor: Colors.red, borderRadius: 10, borderLength: 30, borderWidth: 10, cutOutSize: scanArea), + borderColor: Colors.red, + borderRadius: 10, + borderLength: 30, + borderWidth: 10, + cutOutSize: scanArea, + ), onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), ); } @@ -121,9 +128,7 @@ class _QRViewExampleState extends State { void _onPermissionSet(BuildContext context, qr.QRViewController ctrl, bool p) { if (!p) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('no Permission')), - ); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('no Permission'))); } } } diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index da41a976..978cee7a 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -24,10 +24,7 @@ class StudyDetailsPage extends StatelessWidget { 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, - ), + icon: Icon(Icons.arrow_back_ios, color: Theme.of(context).extension()!.grey600), onPressed: () { if (context.canPop()) { context.pop(); @@ -38,8 +35,10 @@ class StudyDetailsPage extends StatelessWidget { ), Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Text(locale.translate(model.title), - style: fs20fw700.copyWith(color: Theme.of(context).extension()!.primary)), + child: Text( + locale.translate(model.title), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.primary), + ), ), ], ), @@ -49,64 +48,61 @@ class StudyDetailsPage extends StatelessWidget { 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, - )); - }), + 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}', + _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.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)}"); - } - }, - ), - ], - ), + } + }, + ), + _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), child: Column( @@ -149,9 +145,7 @@ class StudyDetailsPage extends StatelessWidget { ], ), ), - Divider( - color: Theme.of(context).extension()!.grey300, - ), + Divider(color: Theme.of(context).extension()!.grey300), Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -197,9 +191,7 @@ class StudyDetailsPage extends StatelessWidget { return Card( margin: EdgeInsets.zero, color: Theme.of(context).extension()!.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -213,7 +205,7 @@ class StudyDetailsPage extends StatelessWidget { ); } -// Helper method to build a ListTile for actions with an icon + // Helper method to build a ListTile for actions with an icon Widget _buildActionListTile({ required BuildContext context, required Icon leading, @@ -232,8 +224,11 @@ class StudyDetailsPage extends StatelessWidget { // 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"); + final url = Uri( + scheme: 'mailto', + path: email, + queryParameters: {'subject': subject}, + ).toString().replaceAll("+", "%20"); try { await launchUrl(Uri.parse(url)); } finally {} diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 39804f27..16791014 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -44,10 +44,7 @@ class StudyPageState extends State { } bloc.deploymentService.getStudyDeploymentStatus(widget.model.studyDeploymentId); }, - child: ListView.builder( - itemCount: cards.length, - itemBuilder: (context, index) => cards[index], - ), + child: ListView.builder(itemCount: cards.length, itemBuilder: (context, index) => cards[index]), ); }, ); @@ -64,13 +61,15 @@ class StudyPageState extends State { final items = []; final updateCard = _hasUpdateCard(); items.add(updateCard); - items.add(_studyCard( - context, - widget.model.studyDescriptionMessage, - onTap: () { - context.push(StudyDetailsPage.route); - }, - )); + items.add( + _studyCard( + context, + widget.model.studyDescriptionMessage, + onTap: () { + context.push(StudyDetailsPage.route); + }, + ), + ); items.add(_studyStatusCard()); if (LocalSettings().isAnonymous) { items.add(AnonymousCard()); @@ -79,9 +78,11 @@ class StudyPageState extends State { items.add(_buildAnnouncementsTitle(context)); // Show newest announcements first: sort by timestamp descending final messages = List.from(widget.model.messages)..sort((a, b) => b.timestamp.compareTo(a.timestamp)); - items.addAll(messages.map((message) { - return _announcementCard(context, message); - }).toList()); + items.addAll( + messages.map((message) { + return _announcementCard(context, message); + }).toList(), + ); } return items; } @@ -89,63 +90,50 @@ class StudyPageState extends State { Widget _hasUpdateCard() { RPLocalizations locale = RPLocalizations.of(context)!; return FutureBuilder( - future: bloc.getAppHasUpdate(), - builder: (context, snapshot) { - if (snapshot.data == true) { - return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, - elevation: 8, - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - locale.translate('pages.about.app_update'), - style: fs16fw600.copyWith( - color: Theme.of(context).extension()!.grey900, - ), - ), + future: bloc.getAppHasUpdate(), + builder: (context, snapshot) { + if (snapshot.data == true) { + return StudiesMaterial( + backgroundColor: Theme.of(context).extension()!.grey50!, + elevation: 8, + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + locale.translate('pages.about.app_update'), + style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900), ), ), - Padding( - padding: const EdgeInsets.only(right: 16), - child: ElevatedButton( - onPressed: () async { - _redirectToUpdateStore(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: CACHET.DEPLOYMENT_DEPLOYING, - padding: const EdgeInsets.symmetric( - horizontal: 30, - vertical: 12, - ), - ), - child: Text( - locale.translate("get"), - style: TextStyle( - color: Colors.white, - ), - ), + ), + Padding( + padding: const EdgeInsets.only(right: 16), + child: ElevatedButton( + onPressed: () async { + _redirectToUpdateStore(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: CACHET.DEPLOYMENT_DEPLOYING, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), + child: Text(locale.translate("get"), style: TextStyle(color: Colors.white)), ), - ], - ), + ), + ], ), - ); - } else { - return SizedBox.shrink(); - } - }); + ), + ); + } else { + return SizedBox.shrink(); + } + }, + ); } - Widget _studyCard( - BuildContext context, - Message message, { - Function? onTap, - }) { + Widget _studyCard(BuildContext context, Message message, {Function? onTap}) { RPLocalizations locale = RPLocalizations.of(context)!; // Initialization the language of the timeago package @@ -178,8 +166,10 @@ class StudyPageState extends State { ), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text(locale.translate(message.title!), - style: fs24fw700.copyWith(color: Theme.of(context).extension()!.primary)), + child: Text( + locale.translate(message.title!), + style: fs24fw700.copyWith(color: Theme.of(context).extension()!.primary), + ), ), if (message.subTitle != null && message.subTitle!.isNotEmpty) Row( @@ -187,22 +177,23 @@ class StudyPageState extends State { Expanded( child: Text( locale.translate(message.subTitle!), - style: fs16fw400.copyWith( - color: Theme.of(context).extension()!.grey700, - ), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700), ), ), ], ), if (message.message != null && message.message!.isNotEmpty) - Row(children: [ - Expanded( + Row( + children: [ + Expanded( child: Text( - "${locale.translate(message.message!).substring(0, (message.message!.length > 150) ? 150 : null)}...", - style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), - textAlign: TextAlign.start, - )), - ]), + "${locale.translate(message.message!).substring(0, (message.message!.length > 150) ? 150 : null)}...", + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey900), + textAlign: TextAlign.start, + ), + ), + ], + ), ], ), ), @@ -220,42 +211,32 @@ class StudyPageState extends State { return StudiesMaterial( child: Padding( padding: const EdgeInsets.symmetric(vertical: 28), - child: Center( - child: CircularProgressIndicator(), - ), + child: Center(child: CircularProgressIndicator()), ), ); } else if (snapshot.hasError) { return StudiesMaterial( child: Padding( padding: const EdgeInsets.symmetric(vertical: 28), - child: Text( - 'Error: ${snapshot.error}', - textAlign: TextAlign.center, - ), + child: Text('Error: ${snapshot.error}', textAlign: TextAlign.center), ), ); // Show an error message if the future fails } else if (!snapshot.hasData || snapshot.data == null) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 28), - child: Center( - child: CircularProgressIndicator(), - ), + child: Center(child: CircularProgressIndicator()), ); // Handle the case where data is null } - final deploymentStatus = snapshot.data!.status; + // `status` may be null; fall back to the domain default. + final deploymentStatus = snapshot.data!.status ?? StudyDeploymentStatusTypes.Invited; return StudiesMaterial( margin: const EdgeInsets.all(16.0), backgroundColor: Theme.of(context).extension()!.grey50!, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16.0)), child: Row( children: [ Expanded( @@ -267,10 +248,7 @@ class StudyPageState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), - child: CircleAvatar( - radius: 18, - backgroundColor: studyStatusColors[deploymentStatus], - ), + child: CircleAvatar(radius: 18, backgroundColor: studyStatusColors[deploymentStatus]), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), @@ -320,11 +298,13 @@ class StudyPageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(locale.translate('Announcements'), - style: fs24fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - fontWeight: FontWeight.bold, - )), + Text( + locale.translate('Announcements'), + style: fs24fw700.copyWith( + color: Theme.of(context).extension()!.grey900, + fontWeight: FontWeight.bold, + ), + ), ], ), ), @@ -332,11 +312,7 @@ class StudyPageState extends State { ); } - Widget _announcementCard( - BuildContext context, - Message message, { - Function? onTap, - }) { + Widget _announcementCard(BuildContext context, Message message, {Function? onTap}) { RPLocalizations locale = RPLocalizations.of(context)!; // Initialization the language of the timeago package @@ -368,9 +344,7 @@ class StudyPageState extends State { child: Text( locale.translate(message.title!), overflow: TextOverflow.ellipsis, - style: fs20fw700.copyWith( - color: Theme.of(context).extension()!.grey900, - ), + style: fs20fw700.copyWith(color: Theme.of(context).extension()!.grey900), ), ), ), @@ -392,18 +366,14 @@ class StudyPageState extends State { Expanded( child: Text( locale.translate(message.subTitle!), - style: fs16fw400.copyWith( - color: Theme.of(context).extension()!.grey700, - ), + style: fs16fw400.copyWith(color: Theme.of(context).extension()!.grey700), ), ), Spacer(), Text( timeago.format(message.timestamp.toLocal()), - style: fs10fw600.copyWith( - color: Theme.of(context).extension()!.grey600, - ), - ) + style: fs10fw600.copyWith(color: Theme.of(context).extension()!.grey600), + ), ], ), ), @@ -411,13 +381,14 @@ class StudyPageState extends State { Row( children: [ Expanded( - child: Text( - locale.translate(message.message!).length > 150 - ? '${locale.translate(message.message!).substring(0, 150)}...' - : locale.translate(message.message!), - style: fs16fw400, - textAlign: TextAlign.start, - )), + child: Text( + locale.translate(message.message!).length > 150 + ? '${locale.translate(message.message!).substring(0, 150)}...' + : locale.translate(message.message!), + style: fs16fw400, + textAlign: TextAlign.start, + ), + ), ], ), ], diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 3b82bef3..3c0ab44e 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -118,50 +118,38 @@ class TaskListPageState extends State with TickerProviderStateMixi unselectedLabelColor: Theme.of(context).extension()!.grey900, dividerColor: Colors.transparent, indicator: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), color: Theme.of(context).extension()!.white, ), tabs: [ Container( width: double.infinity, - child: Tab( - text: locale.translate('pages.task_list.pending'), - ), + child: Tab(text: locale.translate('pages.task_list.pending')), ), Container( width: double.infinity, - child: Tab( - text: locale.translate('pages.task_list.completed'), - ), + child: Tab(text: locale.translate('pages.task_list.completed')), ), ], ), ), ), ), - if (showParticipantDataCard) - SliverToBoxAdapter( - child: _buildParticipantDataCard(), - ), + if (showParticipantDataCard) SliverToBoxAdapter(child: _buildParticipantDataCard()), SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - UserTask userTask = widget.model.tasks[index]; - if (_tabController.index == 0) { - if (userTask.availableForUser) { - return _buildAvailableTaskCard(context, userTask); - } - } else if (_tabController.index == 1) { - if (userTask.state == UserTaskState.done || userTask.state == UserTaskState.expired) { - return _buildCompletedTaskCard(context, userTask); - } + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + UserTask userTask = widget.model.tasks[index]; + if (_tabController.index == 0) { + if (userTask.availableForUser) { + return _buildAvailableTaskCard(context, userTask); } - return const SizedBox.shrink(); - }, - childCount: widget.model.tasks.length, - ), + } else if (_tabController.index == 1) { + if (userTask.state == UserTaskState.done || userTask.state == UserTaskState.expired) { + return _buildCompletedTaskCard(context, userTask); + } + } + return const SizedBox.shrink(); + }, childCount: widget.model.tasks.length), ), ], ); @@ -182,10 +170,7 @@ class TaskListPageState extends State with TickerProviderStateMixi hasBorder: true, borderColor: taskTypeColors["ExpectedParticipantData"]!, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal( - left: Radius.circular(2.0), - right: Radius.circular(8.0), - ), + borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), ), backgroundColor: Theme.of(context).extension()!.grey50!, child: Padding( @@ -201,8 +186,10 @@ class TaskListPageState extends State with TickerProviderStateMixi children: [ Row( children: [ - Icon(taskTypeIcons["ExpectedParticipantData"]!.icon, - color: taskTypeColors["ExpectedParticipantData"]), + Icon( + taskTypeIcons["ExpectedParticipantData"]!.icon, + color: taskTypeColors["ExpectedParticipantData"], + ), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( @@ -223,17 +210,12 @@ class TaskListPageState extends State with TickerProviderStateMixi children: [ Text( "Participant Data", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), ), const SizedBox(height: 4.0), Padding( padding: const EdgeInsets.only(right: 20), - child: Text( - "Fill in the required participant data to continue with the study.", - ), + child: Text("Fill in the required participant data to continue with the study."), ), ], ), @@ -261,10 +243,7 @@ class TaskListPageState extends State with TickerProviderStateMixi hasBorder: true, borderColor: taskTypeColors[userTask.type]!, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal( - left: Radius.circular(2.0), - right: Radius.circular(8.0), - ), + borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), ), backgroundColor: userTask.expiresIn != null && userTask.expiresIn!.inHours < 24 ? CACHET.TASK_TO_EXPIRE_BACKGROUND @@ -289,10 +268,7 @@ class TaskListPageState extends State with TickerProviderStateMixi padding: const EdgeInsets.only(left: 4.0), child: Text( userTask.type[0].toUpperCase() + userTask.type.substring(1), - style: TextStyle( - color: taskTypeColors[userTask.type], - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: taskTypeColors[userTask.type], fontWeight: FontWeight.bold), ), ), Spacer(), @@ -315,7 +291,7 @@ class TaskListPageState extends State with TickerProviderStateMixi fontSize: 12.0, ), ), - ) + ), ], ), const SizedBox(height: 8.0), @@ -326,17 +302,12 @@ class TaskListPageState extends State with TickerProviderStateMixi children: [ Text( locale.translate(userTask.title), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), ), const SizedBox(height: 4.0), Padding( padding: const EdgeInsets.only(right: 20), - child: Text( - locale.translate(userTask.description), - ), + child: Text(locale.translate(userTask.description)), ), Padding( padding: const EdgeInsets.only(top: 8.0), @@ -347,10 +318,7 @@ class TaskListPageState extends State with TickerProviderStateMixi padding: const EdgeInsets.only(right: 20), child: Text( _estimatedTimeSubtitle(userTask), - style: TextStyle( - color: Colors.grey, - fontSize: 12.0, - ), + style: TextStyle(color: Colors.grey, fontSize: 12.0), ), ), ], @@ -376,13 +344,14 @@ class TaskListPageState extends State with TickerProviderStateMixi } else { Timer(const Duration(seconds: 10), () { userTask.onDone(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( backgroundColor: Theme.of(context).extension()!.grey700, content: Text(locale.translate('Done!')), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - duration: const Duration(seconds: 1))); + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + duration: const Duration(seconds: 1), + ), + ); }); } } @@ -405,13 +374,7 @@ class TaskListPageState extends State with TickerProviderStateMixi } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.started) { return Padding( padding: const EdgeInsets.all(4), - child: SizedBox( - child: CircularProgressIndicator( - strokeWidth: 3, - ), - height: 14, - width: 14, - ), + child: SizedBox(child: CircularProgressIndicator(strokeWidth: 3), height: 14, width: 14), ); } else if (taskTypeIcons[userTask.type] != null && userTask.state == UserTaskState.done) { return Icon(originalIcon.icon, color: CACHET.TASK_COMPLETED_BLUE); @@ -459,10 +422,7 @@ class TaskListPageState extends State with TickerProviderStateMixi backgroundColor: Theme.of(context).extension()!.grey50!, hasBorder: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal( - left: Radius.circular(2.0), - right: Radius.circular(8.0), - ), + borderRadius: BorderRadius.horizontal(left: Radius.circular(2.0), right: Radius.circular(8.0)), ), borderColor: (userTask.state == UserTaskState.done) ? CACHET.TASK_COMPLETED_BLUE : CACHET.GREY_6, child: Padding( @@ -503,7 +463,7 @@ class TaskListPageState extends State with TickerProviderStateMixi : Colors.grey, fontSize: 12.0, ), - ) + ), ], ), const SizedBox(height: 8.0), @@ -514,10 +474,7 @@ class TaskListPageState extends State with TickerProviderStateMixi children: [ Text( locale.translate(userTask.title), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16.0, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), ), ], ), @@ -544,157 +501,67 @@ class TaskListPageState extends State with TickerProviderStateMixi Ink( width: 60, height: 60, - decoration: const ShapeDecoration( - color: CACHET.GREY_1, - shape: CircleBorder(), - ), - child: const Icon( - Icons.playlist_add_check, - color: Colors.white, - ), + decoration: const ShapeDecoration(color: CACHET.GREY_1, shape: CircleBorder()), + child: const Icon(Icons.playlist_add_check, color: Colors.white), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Text( - locale.translate("pages.task_list.no_tasks"), - style: fs16fw600, - textAlign: TextAlign.center, - )) + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text(locale.translate("pages.task_list.no_tasks"), style: fs16fw600, textAlign: TextAlign.center), + ), ], ); } static Map taskTypeIcons = { - SurveyUserTask.SURVEY_TYPE: const Icon( - Icons.workspaces, - color: CACHET.TASK_BLUE, - ), - SurveyUserTask.COGNITIVE_ASSESSMENT_TYPE: const Icon( - Icons.psychology, - color: CACHET.LIGHT_PURPLE, - ), - SurveyUserTask.AUDIO_TYPE: const Icon( - Icons.hearing, - color: CACHET.GREEN, - ), - SurveyUserTask.VIDEO_TYPE: const Icon( - Icons.videocam, - color: CACHET.LIGHT_BLUE, - ), - SurveyUserTask.IMAGE_TYPE: const Icon( - Icons.image, - color: CACHET.YELLOW, - ), - HealthUserTask.HEALTH_ASSESSMENT_TYPE: const Icon( - Icons.favorite_rounded, - color: CACHET.RED_1, - ), - BackgroundSensingUserTask.SENSING_TYPE: const Icon( - Icons.sensors, - color: CACHET.LIGHT_BROWN, - ), - "ExpectedParticipantData": const Icon( - Icons.dataset, - color: CACHET.TASK_INPUT_DATA, - ), + AppTask.SURVEY_TYPE: const Icon(Icons.workspaces, color: CACHET.TASK_BLUE), + AppTask.COGNITIVE_ASSESSMENT_TYPE: const Icon(Icons.psychology, color: CACHET.LIGHT_PURPLE), + AppTask.AUDIO_TYPE: const Icon(Icons.hearing, color: CACHET.GREEN), + AppTask.VIDEO_TYPE: const Icon(Icons.videocam, color: CACHET.LIGHT_BLUE), + AppTask.IMAGE_TYPE: const Icon(Icons.image, color: CACHET.YELLOW), + AppTask.HEALTH_ASSESSMENT_TYPE: const Icon(Icons.favorite_rounded, color: CACHET.RED_1), + AppTask.SENSING_TYPE: const Icon(Icons.sensors, color: CACHET.LIGHT_BROWN), + "ExpectedParticipantData": const Icon(Icons.dataset, color: CACHET.TASK_INPUT_DATA), }; static Map taskTypeColors = { - SurveyUserTask.SURVEY_TYPE: CACHET.TASK_BLUE, - SurveyUserTask.COGNITIVE_ASSESSMENT_TYPE: CACHET.LIGHT_PURPLE, - SurveyUserTask.AUDIO_TYPE: CACHET.GREEN, - SurveyUserTask.VIDEO_TYPE: CACHET.LIGHT_BLUE, - SurveyUserTask.IMAGE_TYPE: CACHET.YELLOW, - HealthUserTask.HEALTH_ASSESSMENT_TYPE: CACHET.RED_1, - BackgroundSensingUserTask.SENSING_TYPE: CACHET.LIGHT_BROWN, + AppTask.SURVEY_TYPE: CACHET.TASK_BLUE, + AppTask.COGNITIVE_ASSESSMENT_TYPE: CACHET.LIGHT_PURPLE, + AppTask.AUDIO_TYPE: CACHET.GREEN, + AppTask.VIDEO_TYPE: CACHET.LIGHT_BLUE, + AppTask.IMAGE_TYPE: CACHET.YELLOW, + AppTask.HEALTH_ASSESSMENT_TYPE: CACHET.RED_1, + AppTask.SENSING_TYPE: CACHET.LIGHT_BROWN, "ExpectedParticipantData": CACHET.TASK_INPUT_DATA, }; static Map measureTypeIcons = { - DeviceSamplingPackage.FREE_MEMORY: const Icon( - Icons.memory, - color: CACHET.GREY_4, - ), - DeviceSamplingPackage.DEVICE_INFORMATION: const Icon( - Icons.phone_android, - color: CACHET.GREY_4, - ), - DeviceSamplingPackage.BATTERY_STATE: const Icon( - Icons.battery_charging_full, - color: CACHET.GREEN, - ), - SensorSamplingPackage.STEP_COUNT: const Icon( - Icons.directions_walk, - color: CACHET.LIGHT_PURPLE, - ), - SensorSamplingPackage.ACCELERATION: const Icon( - Icons.adb, - color: CACHET.GREY_4, - ), - SensorSamplingPackage.ROTATION: const Icon( - Icons.adb, - color: CACHET.GREY_4, - ), - SensorSamplingPackage.AMBIENT_LIGHT: const Icon( - Icons.highlight, - color: CACHET.YELLOW, - ), - MediaSamplingPackage.AUDIO: const Icon( - Icons.mic, - color: CACHET.GREEN, - ), - MediaSamplingPackage.NOISE: const Icon( - Icons.hearing, - color: CACHET.YELLOW, - ), - MediaSamplingPackage.VIDEO: const Icon( - Icons.videocam, - color: CACHET.LIGHT_BLUE, - ), - MediaSamplingPackage.IMAGE: const Icon( - Icons.image, - color: CACHET.YELLOW, - ), - DeviceSamplingPackage.SCREEN_EVENT: const Icon( - Icons.screen_lock_portrait, - color: CACHET.LIGHT_PURPLE, - ), - ContextSamplingPackage.LOCATION: const Icon( - Icons.location_searching, - color: CACHET.CYAN, - ), - ContextSamplingPackage.ACTIVITY: const Icon( - Icons.local_fire_department, - color: CACHET.ORANGE, - ), - ContextSamplingPackage.WEATHER: const Icon( - Icons.cloud, - color: CACHET.LIGHT_BLUE, - ), - ContextSamplingPackage.AIR_QUALITY: const Icon( - Icons.air, - color: CACHET.GREY_3, - ), - ContextSamplingPackage.GEOFENCE: const Icon( - Icons.location_on, - color: CACHET.CYAN, - ), - ContextSamplingPackage.MOBILITY: const Icon( - Icons.location_on, - color: CACHET.ORANGE, - ), - SurveySamplingPackage.SURVEY: const Icon( - Icons.workspaces, - color: CACHET.ORANGE, - ), + DeviceSamplingPackage.FREE_MEMORY: const Icon(Icons.memory, color: CACHET.GREY_4), + DeviceSamplingPackage.DEVICE_INFORMATION: const Icon(Icons.phone_android, color: CACHET.GREY_4), + DeviceSamplingPackage.BATTERY_STATE: const Icon(Icons.battery_charging_full, color: CACHET.GREEN), + CarpDataTypes.STEP_COUNT: const Icon(Icons.directions_walk, color: CACHET.LIGHT_PURPLE), + SensorSamplingPackage.ACCELERATION: const Icon(Icons.adb, color: CACHET.GREY_4), + SensorSamplingPackage.ROTATION: const Icon(Icons.adb, color: CACHET.GREY_4), + SensorSamplingPackage.AMBIENT_LIGHT: const Icon(Icons.highlight, color: CACHET.YELLOW), + MediaSamplingPackage.AUDIO: const Icon(Icons.mic, color: CACHET.GREEN), + MediaSamplingPackage.NOISE: const Icon(Icons.hearing, color: CACHET.YELLOW), + MediaSamplingPackage.VIDEO: const Icon(Icons.videocam, color: CACHET.LIGHT_BLUE), + MediaSamplingPackage.IMAGE: const Icon(Icons.image, color: CACHET.YELLOW), + DeviceSamplingPackage.SCREEN_EVENT: const Icon(Icons.screen_lock_portrait, color: CACHET.LIGHT_PURPLE), + ContextSamplingPackage.LOCATION: const Icon(Icons.location_searching, color: CACHET.CYAN), + ContextSamplingPackage.ACTIVITY: const Icon(Icons.local_fire_department, color: CACHET.ORANGE), + ContextSamplingPackage.WEATHER: const Icon(Icons.cloud, color: CACHET.LIGHT_BLUE), + ContextSamplingPackage.AIR_QUALITY: const Icon(Icons.air, color: CACHET.GREY_3), + ContextSamplingPackage.GEOFENCE: const Icon(Icons.location_on, color: CACHET.CYAN), + ContextSamplingPackage.MOBILITY: const Icon(Icons.location_on, color: CACHET.ORANGE), + SurveySamplingPackage.SURVEY: const Icon(Icons.workspaces, color: CACHET.ORANGE), }; static Map get taskStateIcon => { - UserTaskState.initialized: const Icon(Icons.stream, color: CACHET.YELLOW), - UserTaskState.enqueued: const Icon(Icons.notifications, color: CACHET.YELLOW), - UserTaskState.dequeued: const Icon(Icons.stop, color: CACHET.YELLOW), - UserTaskState.started: const Icon(Icons.play_arrow, color: CACHET.GREY_4), - UserTaskState.canceled: const Icon(Icons.pause, color: CACHET.GREY_4), - UserTaskState.done: const Icon(Icons.check, color: CACHET.GREEN), - }; + UserTaskState.initialized: const Icon(Icons.stream, color: CACHET.YELLOW), + UserTaskState.enqueued: const Icon(Icons.notifications, color: CACHET.YELLOW), + UserTaskState.dequeued: const Icon(Icons.stop, color: CACHET.YELLOW), + UserTaskState.started: const Icon(Icons.play_arrow, color: CACHET.GREY_4), + UserTaskState.canceled: const Icon(Icons.pause, color: CACHET.GREY_4), + UserTaskState.done: const Icon(Icons.check, color: CACHET.GREEN), + }; } diff --git a/lib/ui/tasks/audio_page.dart b/lib/ui/tasks/audio_page.dart index f5c526be..8b41bdc9 100644 --- a/lib/ui/tasks/audio_page.dart +++ b/lib/ui/tasks/audio_page.dart @@ -29,9 +29,7 @@ class AudioPageState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), + child: const CarpAppBar(hasProfileIcon: false), ), Spacer(), IconButton( @@ -39,10 +37,7 @@ class AudioPageState extends State { onPressed: () { _showCancelConfirmationDialog(); }, - icon: const Icon( - Icons.close, - size: 30, - ), + icon: const Icon(Icons.close, size: 30), ), ], ), @@ -59,9 +54,7 @@ class AudioPageState extends State { Padding( padding: const EdgeInsets.only(bottom: 24), child: Text( - locale.translate( - widget.audioUserTask!.title, - ), + locale.translate(widget.audioUserTask!.title), style: fs22fw700.copyWith( color: Theme.of(context).extension()!.primary, ), @@ -75,9 +68,7 @@ class AudioPageState extends State { child: Padding( padding: const EdgeInsets.all(8.0), child: Text( - locale.translate( - widget.audioUserTask!.instructions, - ), + locale.translate(widget.audioUserTask!.instructions), style: fs16fw600, ), ), @@ -91,19 +82,12 @@ class AudioPageState extends State { child: IconButton( onPressed: () => widget.audioUserTask!.onRecordStart(), padding: const EdgeInsets.all(0), - icon: const Icon( - Icons.mic, - color: Colors.white, - size: 30, - ), + icon: const Icon(Icons.mic, color: Colors.white, size: 30), ), ), Padding( padding: const EdgeInsets.only(top: 8, bottom: 40), - child: Text( - locale.translate("pages.audio_task.play"), - style: fs16fw600, - ), + child: Text(locale.translate("pages.audio_task.play"), style: fs16fw600), ), ], ), @@ -115,9 +99,7 @@ class AudioPageState extends State { Padding( padding: const EdgeInsets.only(bottom: 24), child: Text( - locale.translate( - widget.audioUserTask!.title, - ), + locale.translate(widget.audioUserTask!.title), style: fs22fw700.copyWith( color: Theme.of(context).extension()!.primary, ), @@ -148,24 +130,14 @@ class AudioPageState extends State { child: IconButton( onPressed: () => widget.audioUserTask!.onRecordStop(), padding: const EdgeInsets.all(0), - icon: const Icon( - Icons.stop, - color: Colors.white, - size: 30, - ), + icon: const Icon(Icons.stop, color: Colors.white, size: 30), ), ), ], ), Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 40, - ), - child: Text( - locale.translate("pages.audio_task.recording"), - style: fs22fw700, - ), + padding: const EdgeInsets.only(top: 8, bottom: 40), + child: Text(locale.translate("pages.audio_task.recording"), style: fs22fw700), ), ], ), @@ -185,17 +157,14 @@ class AudioPageState extends State { ), Padding( padding: const EdgeInsets.only(bottom: 20), - child: Text(locale.translate('pages.audio_task.recording_completed'), - style: fs16fw600), + child: Text( + locale.translate('pages.audio_task.recording_completed'), + style: fs16fw600, + ), ), Spacer(), Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 40, - left: 20, - right: 20, - ), + padding: const EdgeInsets.only(top: 8, bottom: 40, left: 20, right: 20), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -229,10 +198,7 @@ class AudioPageState extends State { ), ), ), - Expanded( - flex: 1, - child: Container(), - ), + Expanded(flex: 1, child: Container()), ], ), ), @@ -281,7 +247,7 @@ class AudioPageState extends State { // Exit the Ordered Task context.canPop() ? context.pop() : null; }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/audio_task_page.dart b/lib/ui/tasks/audio_task_page.dart index 6e2bb751..2fdeb482 100644 --- a/lib/ui/tasks/audio_task_page.dart +++ b/lib/ui/tasks/audio_task_page.dart @@ -18,87 +18,73 @@ class AudioTaskPageState extends State { canPop: true, child: Scaffold( body: Container( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: false), + ), + Spacer(), + IconButton( + color: Theme.of(context).extension()!.grey900!, + onPressed: () { + _showCancelConfirmationDialog(); + }, + icon: const Icon(Icons.close, size: 30), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: const Image(image: AssetImage('assets/icons/audio.png'), width: 220, height: 220), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(locale.translate(widget.audioUserTask!.title), style: fs22fw700), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Text( + '${locale.translate(widget.audioUserTask!.description)}\n\n' + '${locale.translate('pages.audio_task.play')}', + style: fs16fw600, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), - ), - Spacer(), - IconButton( - color: Theme.of(context).extension()!.grey900!, + OutlinedButton( onPressed: () { - _showCancelConfirmationDialog(); + widget.audioUserTask!.onCancel(); + Navigator.pop(context); }, - icon: const Icon( - Icons.close, - size: 30, - ), + child: Text(locale.translate("Cancel")), ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: const Image(image: AssetImage('assets/icons/audio.png'), width: 220, height: 220), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text(locale.translate(widget.audioUserTask!.title), style: fs22fw700), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), - child: Text( - '${locale.translate(widget.audioUserTask!.description)}\n\n' - '${locale.translate('pages.audio_task.play')}', - style: fs16fw600, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - onPressed: () { - widget.audioUserTask!.onCancel(); - Navigator.pop(context); - }, - child: Text(locale.translate("Cancel")), - ), - ElevatedButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AudioPage( - audioUserTask: widget.audioUserTask, - ), - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, - vertical: 12, - ), - ), - child: Text( - locale.translate("next"), - style: TextStyle( - color: Colors.white, - ), + ElevatedButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AudioPage(audioUserTask: widget.audioUserTask), ), ), - ], - ), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + ), + child: Text(locale.translate("next"), style: TextStyle(color: Colors.white)), + ), + ], ), - ], - )), + ), + ], + ), + ), ), ), ), @@ -126,7 +112,7 @@ class AudioTaskPageState extends State { onPressed: () { context.pushReplacement(CarpStudyAppState.homeRoute); }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/camera_page.dart b/lib/ui/tasks/camera_page.dart index 64ccec2c..cf728759 100644 --- a/lib/ui/tasks/camera_page.dart +++ b/lib/ui/tasks/camera_page.dart @@ -76,10 +76,7 @@ class CameraPageState extends State { if (context.mounted) { await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => DisplayPicturePage( - file: picture, - videoUserTask: widget.videoUserTask, - ), + builder: (context) => DisplayPicturePage(file: picture, videoUserTask: widget.videoUserTask), ), ); } @@ -109,8 +106,8 @@ class CameraPageState extends State { if (context.mounted) { await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => - DisplayPicturePage(file: video, isVideo: true, videoUserTask: widget.videoUserTask)), + builder: (context) => DisplayPicturePage(file: video, isVideo: true, videoUserTask: widget.videoUserTask), + ), ); } setState(() { @@ -125,9 +122,7 @@ class CameraPageState extends State { @override Widget build(BuildContext context) { if (cameras == null || cameraInit == null) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } return Scaffold( backgroundColor: Colors.black, @@ -174,12 +169,7 @@ class CameraPageState extends State { Icons.close, color: Colors.white, size: 30, - shadows: [ - Shadow( - blurRadius: 3.0, - color: Colors.black, - ), - ], + shadows: [Shadow(blurRadius: 3.0, color: Colors.black)], ), ), ), @@ -195,12 +185,7 @@ class CameraPageState extends State { icon: const Icon( Icons.flip_camera_android, color: Colors.white, - shadows: [ - Shadow( - blurRadius: 3.0, - color: Colors.black, - ), - ], + shadows: [Shadow(blurRadius: 3.0, color: Colors.black)], ), ), GestureDetector( @@ -223,10 +208,7 @@ class CameraPageState extends State { Container( height: 60, width: 60, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.red, - ), + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.red), ), ], ) @@ -234,12 +216,7 @@ class CameraPageState extends State { height: 60, width: 60, decoration: const BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black, - blurRadius: 3.0, - ) - ], + boxShadow: [BoxShadow(color: Colors.black, blurRadius: 3.0)], shape: BoxShape.circle, color: Colors.white, ), @@ -250,12 +227,7 @@ class CameraPageState extends State { icon: Icon( flashIcon, color: Colors.white, - shadows: [ - Shadow( - blurRadius: 3.0, - color: Colors.black, - ), - ], + shadows: [Shadow(blurRadius: 3.0, color: Colors.black)], ), ), ], @@ -294,7 +266,7 @@ class CameraPageState extends State { Navigator.of(context).pop(); Navigator.of(context).pop(); }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/camera_task_page.dart b/lib/ui/tasks/camera_task_page.dart index dd0916ea..f814e5b6 100644 --- a/lib/ui/tasks/camera_task_page.dart +++ b/lib/ui/tasks/camera_task_page.dart @@ -3,10 +3,7 @@ part of carp_study_app; class CameraTaskPage extends StatefulWidget { final VideoUserTask mediaUserTask; - const CameraTaskPage({ - super.key, - required this.mediaUserTask, - }); + const CameraTaskPage({super.key, required this.mediaUserTask}); @override CameraTaskPageState createState() => CameraTaskPageState(); @@ -15,115 +12,94 @@ class CameraTaskPage extends StatefulWidget { class CameraTaskPageState extends State { @override Widget build(BuildContext context) => PopScope( - canPop: true, - child: Scaffold( - body: SafeArea( - child: StreamBuilder( - stream: widget.mediaUserTask.stateEvents, - initialData: UserTaskState.enqueued, - builder: (context, AsyncSnapshot snapshot) { - RPLocalizations locale = RPLocalizations.of(context)!; - switch (snapshot.data) { - case UserTaskState.enqueued: - return StreamBuilder( - stream: widget.mediaUserTask.stateEvents, - initialData: UserTaskState.enqueued, - builder: (context, AsyncSnapshot snapshot) { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( + canPop: true, + child: Scaffold( + body: SafeArea( + child: StreamBuilder( + stream: widget.mediaUserTask.stateEvents, + initialData: UserTaskState.enqueued, + builder: (context, AsyncSnapshot snapshot) { + RPLocalizations locale = RPLocalizations.of(context)!; + switch (snapshot.data) { + case UserTaskState.enqueued: + return StreamBuilder( + stream: widget.mediaUserTask.stateEvents, + initialData: UserTaskState.enqueued, + builder: (context, AsyncSnapshot snapshot) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), + child: const CarpAppBar(hasProfileIcon: false), + ), + Spacer(), + IconButton( + color: Theme.of(context).extension()!.grey900!, + onPressed: () { + _showCancelConfirmationDialog(); + }, + icon: const Icon(Icons.close, size: 30), + ), + ], + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: const Image(image: AssetImage('assets/icons/camera.png'), width: 220, height: 220), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(locale.translate(widget.mediaUserTask.title), style: fs22fw700), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + child: Text(locale.translate(widget.mediaUserTask.description), style: fs16fw600), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), - ), - Spacer(), - IconButton( - color: Theme.of(context).extension()!.grey900!, + OutlinedButton( onPressed: () { - _showCancelConfirmationDialog(); + Navigator.pop(context); }, - icon: const Icon( - Icons.close, - size: 30, - ), + child: Text(locale.translate("Cancel")), ), - ], - ), - Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: const Image( - image: AssetImage('assets/icons/camera.png'), width: 220, height: 220), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text( - locale.translate(widget.mediaUserTask.title), - style: fs22fw700, + ElevatedButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CameraPage(videoUserTask: widget.mediaUserTask), + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), - child: Text( - locale.translate(widget.mediaUserTask.description), - style: fs16fw600, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton( - onPressed: () { - Navigator.pop(context); - }, - child: Text(locale.translate("Cancel")), - ), - ElevatedButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CameraPage( - videoUserTask: widget.mediaUserTask, - ), - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).extension()!.primary, - padding: const EdgeInsets.symmetric( - horizontal: 30, - vertical: 12, - ), - ), - child: Text( - locale.translate("next"), - style: TextStyle( - color: Colors.white, - ), - ), - ), - ], + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).extension()!.primary, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), + child: Text(locale.translate("next"), style: TextStyle(color: Colors.white)), ), ], ), - ], - ); - }, - ); - default: - return const SizedBox.shrink(); - } - }), - ), + ), + ], + ), + ], + ); + }, + ); + default: + return const SizedBox.shrink(); + } + }, ), - ); + ), + ), + ); Future _showCancelConfirmationDialog() { RPLocalizations locale = RPLocalizations.of(context)!; @@ -150,7 +126,7 @@ class CameraTaskPageState extends State { // Exit the Ordered Task context.canPop() ? context.pop() : null; }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/display_picture_page.dart b/lib/ui/tasks/display_picture_page.dart index d8566af1..43d87acf 100644 --- a/lib/ui/tasks/display_picture_page.dart +++ b/lib/ui/tasks/display_picture_page.dart @@ -49,9 +49,7 @@ class DisplayPicturePageState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), + child: const CarpAppBar(hasProfileIcon: false), ), Spacer(), IconButton( @@ -59,10 +57,7 @@ class DisplayPicturePageState extends State { onPressed: () { _showCancelConfirmationDialog(); }, - icon: const Icon( - Icons.close, - size: 30, - ), + icon: const Icon(Icons.close, size: 30), ), ], ), @@ -75,10 +70,11 @@ class DisplayPicturePageState extends State { width: MediaQuery.of(context).size.width * 0.5, child: (widget.isVideo && _videoPlayerController != null) ? _videoPlayerController!.value.isInitialized - ? AspectRatio( - aspectRatio: _videoPlayerController!.value.aspectRatio, - child: VideoPlayer(_videoPlayerController!)) - : const CircularProgressIndicator() + ? AspectRatio( + aspectRatio: _videoPlayerController!.value.aspectRatio, + child: VideoPlayer(_videoPlayerController!), + ) + : const CircularProgressIndicator() : Image.file(File(videoFilePath)), ), ), @@ -95,10 +91,7 @@ class DisplayPicturePageState extends State { const SizedBox(height: 40), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text( - locale.translate('pages.audio_task.recording_completed'), - style: fs16fw600, - ), + child: Text(locale.translate('pages.audio_task.recording_completed'), style: fs16fw600), ), const SizedBox(height: 20), Align( @@ -153,10 +146,7 @@ class DisplayPicturePageState extends State { return AlertDialog( title: Text(locale.translate("pages.audio_task.discard")), actions: [ - TextButton( - child: Text(locale.translate("NO")), - onPressed: () => Navigator.of(context).pop(), - ), + TextButton(child: Text(locale.translate("NO")), onPressed: () => Navigator.of(context).pop()), TextButton( child: Text(locale.translate("YES")), onPressed: () { @@ -167,7 +157,7 @@ class DisplayPicturePageState extends State { Navigator.of(context).pop(); Navigator.of(context).pop(); }, - ) + ), ], ); }, diff --git a/lib/ui/tasks/participant_data_page.dart b/lib/ui/tasks/participant_data_page.dart index 8ed6638f..1d94dde8 100644 --- a/lib/ui/tasks/participant_data_page.dart +++ b/lib/ui/tasks/participant_data_page.dart @@ -178,10 +178,7 @@ class ParticipantDataPageState extends State { controller: widget.model._phoneNumberController, ); - widget.model.ssnField = StepField( - title: "tasks.participant_data.ssn.ssn", - controller: widget.model._ssnController, - ); + widget.model.ssnField = StepField(title: "tasks.participant_data.ssn.ssn", controller: widget.model._ssnController); } @override @@ -222,13 +219,15 @@ class ParticipantDataPageState extends State { setState(() { switch (currentStep) { case ParticipantStep.address: - _nextEnabled = widget.model._address1Controller.text.isNotEmpty && + _nextEnabled = + widget.model._address1Controller.text.isNotEmpty && widget.model._streetController.text.isNotEmpty && widget.model._postalCodeController.text.isNotEmpty && widget.model._countryController.text.isNotEmpty; break; case ParticipantStep.diagnosis: - _nextEnabled = widget.model._effectiveDateController.text.isNotEmpty && + _nextEnabled = + widget.model._effectiveDateController.text.isNotEmpty && widget.model._icd11CodeController.text.isNotEmpty && widget.model._conclusionController.text.isNotEmpty; break; @@ -266,9 +265,7 @@ class ParticipantDataPageState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 10), - child: const CarpAppBar( - hasProfileIcon: false, - ), + child: const CarpAppBar(hasProfileIcon: false), ), Spacer(), IconButton( @@ -276,10 +273,7 @@ class ParticipantDataPageState extends State { onPressed: () { _showCancelConfirmationDialog(); }, - icon: const Icon( - Icons.close, - size: 30, - ), + icon: const Icon(Icons.close, size: 30), ), ], ), @@ -293,9 +287,7 @@ class ParticipantDataPageState extends State { Expanded( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: SizedBox( - child: _buildStepContent(locale, widget.model.expectedData), - ), + child: SizedBox(child: _buildStepContent(locale, widget.model.expectedData)), ), ), Padding( @@ -337,9 +329,7 @@ class ParticipantDataPageState extends State { Flexible( child: Text( stepTitleMap[currentStep] ?? '', - style: fs22fw700.copyWith( - color: Theme.of(context).primaryColor, - ), + style: fs22fw700.copyWith(color: Theme.of(context).primaryColor), textAlign: TextAlign.center, ), ), @@ -354,12 +344,14 @@ class ParticipantDataPageState extends State { List fields = []; switch (currentStep) { case ParticipantStep.presentTypes: - fields.add(_buildPresentTypes( - _includedSteps - .where((step) => step != ParticipantStep.presentTypes && step != ParticipantStep.review) - .map((step) => participantStepDescriptions[step]) - .toList(), - )); + fields.add( + _buildPresentTypes( + _includedSteps + .where((step) => step != ParticipantStep.presentTypes && step != ParticipantStep.review) + .map((step) => participantStepDescriptions[step]) + .toList(), + ), + ); break; case ParticipantStep.address: fields.addAll([ @@ -392,18 +384,12 @@ class ParticipantDataPageState extends State { fields.add(_buildField(locale, widget.model.ssnField, isCPR: true)); break; case ParticipantStep.review: - fields.add(_buildReviewStep( - locale, - _allUsedStepFields, - )); + fields.add(_buildReviewStep(locale, _allUsedStepFields)); break; } return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: fields, - ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: fields), ); } @@ -417,11 +403,7 @@ class ParticipantDataPageState extends State { child: Text( "\u2022 ${step ?? ''}", textAlign: TextAlign.start, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.4), ), ); }).toList(), @@ -452,20 +434,9 @@ class ParticipantDataPageState extends State { children: [ Text( locale.translate(field), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), - ), - Text( - input, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.4), ), + Text(input, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.4)), ], ), ); @@ -492,10 +463,7 @@ class ParticipantDataPageState extends State { widget.model._phoneNumberCodeController.text = phoneNumber.dialCode ?? ''; }, textFieldController: stepField.controller, - selectorConfig: SelectorConfig( - selectorType: PhoneInputSelectorType.DIALOG, - useBottomSheetSafeArea: true, - ), + selectorConfig: SelectorConfig(selectorType: PhoneInputSelectorType.DIALOG, useBottomSheetSafeArea: true), inputDecoration: _buildInputDecoration(locale, stepField, isThicc), ignoreBlank: false, autoValidateMode: AutovalidateMode.disabled, @@ -515,10 +483,7 @@ class ParticipantDataPageState extends State { width: 125, child: Container( decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).extension()!.grey600!, - width: 1.0, - ), + border: Border.all(color: Theme.of(context).extension()!.grey600!, width: 1.0), borderRadius: BorderRadius.circular(16.0), ), child: CountryCodePicker( @@ -531,9 +496,7 @@ class ParticipantDataPageState extends State { showCountryOnly: true, showOnlyCountryWhenClosed: true, alignLeft: false, - textStyle: fs16fw600.copyWith( - color: Theme.of(context).extension()!.grey900!, - ), + textStyle: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900!), ), ), ), @@ -556,30 +519,31 @@ class ParticipantDataPageState extends State { Padding( padding: const EdgeInsets.only(bottom: 16), child: TextFormField( - controller: stepField.controller, - focusNode: stepField.focusNode, - textInputAction: TextInputAction.next, - onFieldSubmitted: (_) { - if (stepField.nextFocusNode != null) { - FocusScope.of(context).requestFocus(stepField.nextFocusNode); - } - }, - onTap: isDatePicker - ? () async { - DateTime? pickedDate = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(1900), - lastDate: DateTime.now(), - ); - if (pickedDate != null) { - stepField.controller.text = "${pickedDate.toLocal()}".split(' ')[0]; - } + controller: stepField.controller, + focusNode: stepField.focusNode, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) { + if (stepField.nextFocusNode != null) { + FocusScope.of(context).requestFocus(stepField.nextFocusNode); + } + }, + onTap: isDatePicker + ? () async { + DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (pickedDate != null) { + stepField.controller.text = "${pickedDate.toLocal()}".split(' ')[0]; } - : null, - keyboardType: TextInputType.multiline, - maxLines: isThicc ? null : 1, - decoration: _buildInputDecoration(locale, stepField, isThicc)), + } + : null, + keyboardType: TextInputType.multiline, + maxLines: isThicc ? null : 1, + decoration: _buildInputDecoration(locale, stepField, isThicc), + ), ), ], ); @@ -588,21 +552,22 @@ class ParticipantDataPageState extends State { InputDecoration _buildInputDecoration(RPLocalizations locale, StepField stepField, bool isThicc) { return InputDecoration( - labelText: locale.translate(stepField.title), - floatingLabelBehavior: FloatingLabelBehavior.always, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue, width: 2), - ), - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: isThicc ? 70 : 12)); + labelText: locale.translate(stepField.title), + floatingLabelBehavior: FloatingLabelBehavior.always, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.blue), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.blue, width: 2), + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: isThicc ? 70 : 12), + ); } /// Builds the action buttons at the bottom of the page. @@ -610,30 +575,44 @@ class ParticipantDataPageState extends State { /// The "Next" button is enabled only if the required fields for the current step are filled. List _buildActionButtons(RPLocalizations locale) { Widget buildTranslatedButton( - String key, VoidCallback onPressed, bool enabled, ButtonStyle? buttonStyle, TextStyle? buttonTextStyle) { + String key, + VoidCallback onPressed, + bool enabled, + ButtonStyle? buttonStyle, + TextStyle? buttonTextStyle, + ) { return ElevatedButton( onPressed: enabled ? onPressed : null, - child: Text( - locale.translate(key).toUpperCase(), - style: buttonTextStyle, - ), + child: Text(locale.translate(key).toUpperCase(), style: buttonTextStyle), style: buttonStyle, ); } return [ currentStep == ParticipantStep.presentTypes - ? buildTranslatedButton("cancel", () { - context.pop(); - }, true, null, null) - : buildTranslatedButton("previous", () { - setState(() { - final idx = _includedSteps.indexOf(currentStep); - if (currentStep.index - 1 >= 0) { - currentStep = _includedSteps[idx - 1]; - } - }); - }, true, null, null), + ? buildTranslatedButton( + "cancel", + () { + context.pop(); + }, + true, + null, + null, + ) + : buildTranslatedButton( + "previous", + () { + setState(() { + final idx = _includedSteps.indexOf(currentStep); + if (currentStep.index - 1 >= 0) { + currentStep = _includedSteps[idx - 1]; + } + }); + }, + true, + null, + null, + ), currentStep.index == ParticipantStep.values.length - 1 ? buildTranslatedButton( "submit", @@ -646,9 +625,7 @@ class ParticipantDataPageState extends State { backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ) : buildTranslatedButton( "next", @@ -665,9 +642,7 @@ class ParticipantDataPageState extends State { backgroundColor: Theme.of(context).extension()!.primary, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), - TextStyle( - color: Colors.white, - ), + TextStyle(color: Colors.white), ), ]; } @@ -680,7 +655,7 @@ class ParticipantDataPageState extends State { final Map> participantStepToDataType = { ParticipantStep.address: { - AddressInput.type: AddressInput( + InputType.ADDRESS: AddressInput( address1: widget.model._address1Controller.text, address2: widget.model._address2Controller.text, street: widget.model._streetController.text, @@ -689,11 +664,11 @@ class ParticipantDataPageState extends State { ), }, ParticipantStep.diagnosis: { - DiagnosisInput.type: DiagnosisInput( + InputType.DIAGNOSIS: DiagnosisInput( effectiveDate: widget.model._effectiveDateController.text.isNotEmpty - ? DateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") - .parse('${widget.model._effectiveDateController.text}T00:00:00Z') - .toUtc() + ? DateFormat( + "yyyy-MM-dd'T'HH:mm:ss'Z'", + ).parse('${widget.model._effectiveDateController.text}T00:00:00Z').toUtc() : null, diagnosis: widget.model._diagnosisDescriptionController.text, icd11Code: widget.model._icd11CodeController.text, @@ -701,20 +676,20 @@ class ParticipantDataPageState extends State { ), }, ParticipantStep.fullName: { - FullNameInput.type: FullNameInput( + InputType.FULL_NAME: FullNameInput( firstName: widget.model._firstNameController.text, middleName: widget.model._middleNameController.text, lastName: widget.model._lastNameController.text, ), }, ParticipantStep.phoneNumber: { - PhoneNumberInput.type: PhoneNumberInput( + InputType.PHONE_NUMBER: PhoneNumberInput( countryCode: widget.model._phoneNumberCodeController.text, number: widget.model._phoneNumberController.text, ), }, ParticipantStep.socialSecurityNumber: { - SocialSecurityNumberInput.type: SocialSecurityNumberInput( + InputType.SSN: SocialSecurityNumberInput( country: widget.model._ssnCountryController.text, socialSecurityNumber: widget.model._ssnController.text, ), @@ -727,11 +702,7 @@ class ParticipantDataPageState extends State { } } - bloc.setParticipantData( - bloc.study!.studyDeploymentId, - participantData, - bloc.study!.participantRoleName, - ); + bloc.setParticipantData(bloc.study!.studyDeploymentId, participantData, bloc.study!.participantRoleName); } Future _showCancelConfirmationDialog() { @@ -756,7 +727,7 @@ class ParticipantDataPageState extends State { context.pop(); context.pop(); }, - ) + ), ], ); }, @@ -770,10 +741,5 @@ class StepField { final FocusNode? focusNode; final FocusNode? nextFocusNode; - StepField({ - required this.title, - required this.controller, - this.focusNode, - this.nextFocusNode, - }); + StepField({required this.title, required this.controller, this.focusNode, this.nextFocusNode}); } diff --git a/lib/ui/widgets/battery_icon.dart b/lib/ui/widgets/battery_icon.dart index bc402ee6..75a2a9a6 100644 --- a/lib/ui/widgets/battery_icon.dart +++ b/lib/ui/widgets/battery_icon.dart @@ -1,11 +1,8 @@ part of carp_study_app; class BatteryPercentage extends StatelessWidget { - const BatteryPercentage({ - super.key, - required this.batteryLevel, - this.scale = 1.0, - }) : assert(batteryLevel >= 0 && batteryLevel <= 100, 'Battery level must be between 0 and 100'); + const BatteryPercentage({super.key, required this.batteryLevel, this.scale = 1.0}) + : assert(batteryLevel >= 0 && batteryLevel <= 100, 'Battery level must be between 0 and 100'); // Battery level from 0 to 100 final int batteryLevel; @@ -16,35 +13,43 @@ class BatteryPercentage extends StatelessWidget { Widget build(BuildContext context) { double width = 25 * scale; double height = 12 * scale; - return Row(mainAxisSize: MainAxisSize.min, children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(2)), - child: Container( - decoration: BoxDecoration( + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(2)), + child: Container( + decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, - border: Border.all(color: Theme.of(context).primaryColor)), - width: width, - height: height, - child: Row(children: [ - SizedBox( - width: batteryLevel != 0 ? batteryLevel * (width * 0.9 / 100) : 0, - height: height * 0.75, - child: Container(color: Theme.of(context).primaryColor)), - ]), + border: Border.all(color: Theme.of(context).primaryColor), + ), + width: width, + height: height, + child: Row( + children: [ + SizedBox( + width: batteryLevel != 0 ? batteryLevel * (width * 0.9 / 100) : 0, + height: height * 0.75, + child: Container(color: Theme.of(context).primaryColor), + ), + ], + ), + ), ), - ), - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: Container( - decoration: BoxDecoration( + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Container( + decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, - border: Border.all(color: Theme.of(context).primaryColor)), - width: 2, - height: 4, + border: Border.all(color: Theme.of(context).primaryColor), + ), + width: 2, + height: 4, + ), ), - ), - const SizedBox(width: 4), - Text("$batteryLevel%") - ]); + const SizedBox(width: 4), + Text("$batteryLevel%"), + ], + ); } } diff --git a/lib/ui/widgets/carp_app_bar.dart b/lib/ui/widgets/carp_app_bar.dart index 8b5b60b3..38719d87 100644 --- a/lib/ui/widgets/carp_app_bar.dart +++ b/lib/ui/widgets/carp_app_bar.dart @@ -16,19 +16,11 @@ class CarpAppBar extends StatelessWidget { children: [ Container( padding: EdgeInsets.only(left: 8), - child: Image.asset( - 'assets/carp_logo.png', - fit: BoxFit.contain, - height: 16, - ), + child: Image.asset('assets/carp_logo.png', fit: BoxFit.contain, height: 16), ), if (hasProfileIcon) IconButton( - icon: Icon( - Icons.account_circle, - color: Theme.of(context).primaryColor, - size: 30, - ), + icon: Icon(Icons.account_circle, color: Theme.of(context).primaryColor, size: 30), tooltip: 'Profile', onPressed: () { Navigator.push(context, SlidePageRoute(ProfilePage(ProfilePageViewModel()))); @@ -37,7 +29,7 @@ class CarpAppBar extends StatelessWidget { ], ), ], - ) + ), ], ), ); diff --git a/lib/ui/widgets/charts_legend.dart b/lib/ui/widgets/charts_legend.dart index 99cc4135..1b4434c8 100644 --- a/lib/ui/widgets/charts_legend.dart +++ b/lib/ui/widgets/charts_legend.dart @@ -7,8 +7,14 @@ class ChartsLegend extends StatelessWidget { final String? heroTag; final List colors; - const ChartsLegend( - {super.key, this.heroTag, this.iconAssetName, required this.title, this.values = const [], required this.colors}); + const ChartsLegend({ + super.key, + this.heroTag, + this.iconAssetName, + required this.title, + this.values = const [], + required this.colors, + }); @override Widget build(BuildContext context) { diff --git a/lib/ui/widgets/details_banner.dart b/lib/ui/widgets/details_banner.dart index aa9926ff..4f0d3196 100644 --- a/lib/ui/widgets/details_banner.dart +++ b/lib/ui/widgets/details_banner.dart @@ -6,19 +6,14 @@ class DetailsBanner extends StatelessWidget { final String? imagePath; @override - Widget build( - BuildContext context, - ) { + Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; return Column( mainAxisAlignment: MainAxisAlignment.start, children: [ // only show this widget if there is an image = imagePath is not null and not empty if (imagePath != null && imagePath!.isNotEmpty) - SizedBox( - height: 300, - child: bloc.appViewModel.studyPageViewModel.getMessageImage(imagePath), - ), + SizedBox(height: 300, child: bloc.appViewModel.studyPageViewModel.getMessageImage(imagePath)), Padding( padding: const EdgeInsets.all(16), child: Stack( diff --git a/lib/ui/widgets/dialog_title.dart b/lib/ui/widgets/dialog_title.dart index 3c36339b..29f1d4d6 100644 --- a/lib/ui/widgets/dialog_title.dart +++ b/lib/ui/widgets/dialog_title.dart @@ -35,14 +35,10 @@ class DialogTitle extends StatelessWidget { children: [ Flexible( child: Text( - locale.translate( - title, - ) + + locale.translate(title) + (deviceName != null ? " ${locale.translate(deviceName!)} " : "") + (titleEnd != null ? ' ${locale.translate(titleEnd!)}' : ""), - style: fs18fw700.copyWith( - color: Theme.of(context).primaryColor, - ), + style: fs18fw700.copyWith(color: Theme.of(context).primaryColor), textAlign: TextAlign.center, ), ), diff --git a/lib/ui/widgets/horizontal_bar.dart b/lib/ui/widgets/horizontal_bar.dart index 58bc1709..415a83bb 100644 --- a/lib/ui/widgets/horizontal_bar.dart +++ b/lib/ui/widgets/horizontal_bar.dart @@ -145,14 +145,15 @@ class MyAssetsBar extends StatelessWidget { .entries .map( (entry) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(Icons.circle, color: entry.value.color, size: 12.0), - Text(' ${entry.value.name!} ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.right), - ], - )), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.circle, color: entry.value.color, size: 12.0), + Text(' ${entry.value.name!} ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.right), + ], + ), + ), ) .toList(), ); @@ -172,17 +173,23 @@ class MyAssetsBar extends StatelessWidget { .entries .map( (entry) => Padding( - padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(Icons.circle, color: entry.value.color, size: 12.0), - Text(' ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.left), - Expanded( - child: Text(' ${entry.value.name!}', - style: fs12fw400, textAlign: TextAlign.left, overflow: TextOverflow.ellipsis)), - ], - )), + padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.circle, color: entry.value.color, size: 12.0), + Text(' ${entry.value.size}', style: fs12fw400, textAlign: TextAlign.left), + Expanded( + child: Text( + ' ${entry.value.name!}', + style: fs12fw400, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), ) .toList(), ); diff --git a/lib/ui/widgets/location_usage_dialog.dart b/lib/ui/widgets/location_usage_dialog.dart index 85b3280e..84186a5a 100644 --- a/lib/ui/widgets/location_usage_dialog.dart +++ b/lib/ui/widgets/location_usage_dialog.dart @@ -24,13 +24,7 @@ class LocationUsageDialog { child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, - children: [ - Text( - locale.translate(message), - style: fs16fw400, - textAlign: TextAlign.justify, - ), - ], + children: [Text(locale.translate(message), style: fs16fw400, textAlign: TextAlign.justify)], ), ), ), @@ -43,9 +37,7 @@ class LocationUsageDialog { backgroundColor: WidgetStateProperty.all(Theme.of(context).primaryColor), foregroundColor: WidgetStateProperty.all(Theme.of(context).colorScheme.onPrimary), ), - child: Text( - locale.translate("dialog.location.continue"), - ), + child: Text(locale.translate("dialog.location.continue")), ), ], ); @@ -53,12 +45,7 @@ class LocationUsageDialog { return Scaffold( body: Column( crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - flex: 4, - child: locationUsageDialog, - ), - ], + children: [Expanded(flex: 4, child: locationUsageDialog)], ), ); } diff --git a/lib/ui/widgets/studies_material.dart b/lib/ui/widgets/studies_material.dart index 6c9060b0..322c2b35 100644 --- a/lib/ui/widgets/studies_material.dart +++ b/lib/ui/widgets/studies_material.dart @@ -17,9 +17,7 @@ class StudiesMaterial extends StatelessWidget { required this.child, this.elevation = 0, this.margin = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - this.shape = const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), + this.shape = const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))), this.clipBehavior, this.hasBorder = false, this.hasBox = false, @@ -39,21 +37,14 @@ class StudiesMaterial extends StatelessWidget { child: Container( decoration: hasBorder ? BoxDecoration( - border: Border( - left: BorderSide( - color: borderColor, - width: 4.0, - ), - ), + 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, + ? BoxDecoration( + border: Border.all(color: CACHET.LIGHT_2, width: 1.0), + borderRadius: BorderRadius.circular(16.0), + ) + : null, child: child, ), ), diff --git a/lib/view_models/cards/activity_data_model.dart b/lib/view_models/cards/activity_data_model.dart index 9bd47b4c..7a1d732d 100644 --- a/lib/view_models/cards/activity_data_model.dart +++ b/lib/view_models/cards/activity_data_model.dart @@ -13,8 +13,9 @@ class ActivityCardViewModel extends SerializableViewModel { controller?.measurements.where((measurement) => measurement.data is Activity); final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); - final DateTime _endOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)).add(Duration(days: 6)); + final DateTime _endOfWeek = DateTime.now() + .subtract(Duration(days: DateTime.now().weekday - 1)) + .add(Duration(days: 6)); String get startOfWeek => DateFormat('dd').format(_startOfWeek); @@ -27,7 +28,7 @@ class ActivityCardViewModel extends SerializableViewModel { String get currentYear => DateFormat('yyyy').format(DateTime(DateTime.now().year)); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); // listen for activity events and count the minutes @@ -77,11 +78,7 @@ class WeeklyActivities extends DataModel { } /// Increase the number of minutes of doing [activityType] on [weekday] with [minutes]. - void increaseActivityDuration( - ActivityType activityType, - int weekday, - int minutes, - ) { + void increaseActivityDuration(ActivityType activityType, int weekday, int minutes) { activities[activityType]![weekday] = (activities[activityType]![weekday] ?? 0) + minutes; } @@ -93,8 +90,10 @@ class WeeklyActivities extends DataModel { @override String toString() { String str = ' TYPE\t| day | min.\n'; - activities.forEach((type, data) => - data.forEach((day, minutes) => str += '${type.toString().split(".").last}\t| $day | $minutes\n')); + activities.forEach( + (type, data) => + data.forEach((day, minutes) => str += '${type.toString().split(".").last}\t| $day | $minutes\n'), + ); return str; } } diff --git a/lib/view_models/cards/heart_rate_data_model.dart b/lib/view_models/cards/heart_rate_data_model.dart index 65c71ffe..48e935c0 100644 --- a/lib/view_models/cards/heart_rate_data_model.dart +++ b/lib/view_models/cards/heart_rate_data_model.dart @@ -28,24 +28,22 @@ class HeartRateCardViewModel extends SerializableViewModel { .map((measurement) => (measurement.data as MovesenseHR).hr); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); 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; - model.resetDataAtMidnight(); - }, - ); + 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; + model.resetDataAtMidnight(); + }); } } diff --git a/lib/view_models/cards/measurements_data_model.dart b/lib/view_models/cards/measurements_data_model.dart index 542b8286..d74a54d1 100644 --- a/lib/view_models/cards/measurements_data_model.dart +++ b/lib/view_models/cards/measurements_data_model.dart @@ -10,9 +10,8 @@ class MeasurementsCardViewModel extends ViewModel { Stream? get quietMeasureEvents => controller?.measurements.where((measurement) => measurement.dataType.name != 'sensor'); - /// The total sampling size - int get samplingSize => controller?.samplingSize == null ? 0 : controller!.samplingSize; - // samplingTable.values.fold(0, (prev, element) => prev + element); + /// The total sampling size, derived from the per-type counts in [samplingTable]. + int get samplingSize => _samplingTable.values.fold(0, (sum, count) => sum + count); /// A table with sampling size of each measure type Map get samplingTable { @@ -31,15 +30,16 @@ class MeasurementsCardViewModel extends ViewModel { Map sortedTasksTable = {}..addEntries(mapEntries); // and map to the [TaskCount] model - List tasksList = - sortedTasksTable.entries.map((entry) => MeasureCount(entry.key, entry.value)).toList(); + List tasksList = sortedTasksTable.entries + .map((entry) => MeasureCount(entry.key, entry.value)) + .toList(); return tasksList; } MeasurementsCardViewModel() : super(); - // void init(SmartphoneDeploymentController controller) { + // void init(SmartphoneStudyController controller) { // super.init(controller); // // listen to incoming events in order to count the measure types diff --git a/lib/view_models/cards/mobility_data_model.dart b/lib/view_models/cards/mobility_data_model.dart index 03dcd0c7..f93795dc 100644 --- a/lib/view_models/cards/mobility_data_model.dart +++ b/lib/view_models/cards/mobility_data_model.dart @@ -11,8 +11,9 @@ class MobilityCardViewModel extends SerializableViewModel { controller?.measurements.where((measurement) => measurement.data is Mobility); final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); - final DateTime _endOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)).add(Duration(days: 6)); + final DateTime _endOfWeek = DateTime.now() + .subtract(Duration(days: DateTime.now().weekday - 1)) + .add(Duration(days: 6)); String get startOfWeek => DateFormat('dd').format(_startOfWeek); @@ -26,7 +27,7 @@ class MobilityCardViewModel extends SerializableViewModel { MobilityCardViewModel(); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); // listen for mobility events and update the features @@ -59,8 +60,12 @@ class WeeklyMobility extends DataModel { void setMobilityFeatures(Mobility data) { DateTime day = data.date ?? DateTime.now(); - weekMobility[day.weekday] = DailyMobility(day.weekday, data.numberOfPlaces ?? 0, - data.homeStay != null && data.homeStay! > 0 ? (100 * (data.homeStay!)).toInt() : 0, data.distanceTraveled ?? 0); + weekMobility[day.weekday] = DailyMobility( + day.weekday, + data.numberOfPlaces ?? 0, + data.homeStay != null && data.homeStay! > 0 ? (100 * (data.homeStay!)).toInt() : 0, + data.distanceTraveled ?? 0, + ); } @override diff --git a/lib/view_models/cards/steps_data_model.dart b/lib/view_models/cards/steps_data_model.dart index da57763d..e23c6cbe 100644 --- a/lib/view_models/cards/steps_data_model.dart +++ b/lib/view_models/cards/steps_data_model.dart @@ -13,8 +13,9 @@ class StepsCardViewModel extends SerializableViewModel { List get steps => model.steps; final DateTime _startOfWeek = DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)); - final DateTime _endOfWeek = - DateTime.now().subtract(Duration(days: DateTime.now().weekday - 1)).add(Duration(days: 6)); + final DateTime _endOfWeek = DateTime.now() + .subtract(Duration(days: DateTime.now().weekday - 1)) + .add(Duration(days: 6)); String get startOfWeek => DateFormat('dd').format(_startOfWeek); @@ -31,7 +32,7 @@ class StepsCardViewModel extends SerializableViewModel { controller?.measurements.where((dataPoint) => dataPoint.data is StepCount); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); // listen for pedometer events and count them diff --git a/lib/view_models/cards/study_progress_data_model.dart b/lib/view_models/cards/study_progress_data_model.dart index ee2fa8f9..788e2f34 100644 --- a/lib/view_models/cards/study_progress_data_model.dart +++ b/lib/view_models/cards/study_progress_data_model.dart @@ -26,7 +26,7 @@ class StudyProgressCardViewModel extends ViewModel { StudyProgressCardViewModel() : super(); @override - Future init(SmartphoneDeploymentController ctrl) async { + Future init(SmartphoneStudyController ctrl) async { super.init(ctrl); updateProgress(); } diff --git a/lib/view_models/cards/task_data_model.dart b/lib/view_models/cards/task_data_model.dart index dcc0b374..4bcc426f 100644 --- a/lib/view_models/cards/task_data_model.dart +++ b/lib/view_models/cards/task_data_model.dart @@ -17,19 +17,17 @@ class TaskCardViewModel extends ViewModel { Map get tasksTable { Map tasksTable = {}; - AppTaskController() - .userTaskQueue + AppTaskController().userTaskQueue .where((task) => task.state == UserTaskState.done && task.type == taskType) .forEach((task) { - if (!tasksTable.containsKey(task.title)) tasksTable[task.title] = 0; - tasksTable[task.title] = tasksTable[task.title]! + 1; - }); + if (!tasksTable.containsKey(task.title)) tasksTable[task.title] = 0; + tasksTable[task.title] = tasksTable[task.title]! + 1; + }); return tasksTable; } /// The total number of tasks done of type [taskType]. - int get tasksDone => AppTaskController() - .userTaskQueue + int get tasksDone => AppTaskController().userTaskQueue .where((task) => task.state == UserTaskState.done && task.type == taskType) .length; diff --git a/lib/view_models/data_visualization_page_model.dart b/lib/view_models/data_visualization_page_model.dart index cfc1a9ff..047867b7 100644 --- a/lib/view_models/data_visualization_page_model.dart +++ b/lib/view_models/data_visualization_page_model.dart @@ -5,10 +5,10 @@ class DataVisualizationPageViewModel extends ViewModel { final StepsCardViewModel _stepsCardDataModel = StepsCardViewModel(); final MeasurementsCardViewModel _measuresCardDataModel = MeasurementsCardViewModel(); final MobilityCardViewModel _mobilityCardDataModel = MobilityCardViewModel(); - final TaskCardViewModel _surveysCardDataModel = TaskCardViewModel(SurveyUserTask.SURVEY_TYPE); - final TaskCardViewModel _audioCardDataModel = TaskCardViewModel(SurveyUserTask.AUDIO_TYPE); - final TaskCardViewModel _videoCardDataModel = TaskCardViewModel(SurveyUserTask.VIDEO_TYPE); - final TaskCardViewModel _imageCardDataModel = TaskCardViewModel(SurveyUserTask.IMAGE_TYPE); + final TaskCardViewModel _surveysCardDataModel = TaskCardViewModel(AppTask.SURVEY_TYPE); + final TaskCardViewModel _audioCardDataModel = TaskCardViewModel(AppTask.AUDIO_TYPE); + final TaskCardViewModel _videoCardDataModel = TaskCardViewModel(AppTask.VIDEO_TYPE); + final TaskCardViewModel _imageCardDataModel = TaskCardViewModel(AppTask.IMAGE_TYPE); final StudyProgressCardViewModel _studyProgressCardDataModel = StudyProgressCardViewModel(); final HeartRateCardViewModel _heartRateCardDataModel = HeartRateCardViewModel(); @@ -37,7 +37,7 @@ class DataVisualizationPageViewModel extends ViewModel { DataVisualizationPageViewModel(); @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); _activityCardDataModel.init(ctrl); _stepsCardDataModel.init(ctrl); diff --git a/lib/view_models/device_view_models.dart b/lib/view_models/device_view_models.dart index 03bbce31..490be5fb 100644 --- a/lib/view_models/device_view_models.dart +++ b/lib/view_models/device_view_models.dart @@ -15,7 +15,7 @@ class DeviceViewModel extends ViewModel { DeviceViewModel(this.deviceManager) : super(); /// The type of this device. - String? get type => deviceManager.type; + String? get type => deviceManager.deviceType; /// A printer-friendly name for this [type] of device. String get typeName => _deviceTypeName[type!] ?? 'pages.devices.type.unknown.name'; @@ -28,12 +28,12 @@ class DeviceViewModel extends ViewModel { Stream get statusEvents => deviceManager.statusEvents; /// The device id - String get id => deviceManager.id; + String get id => deviceManager.displayName ?? ''; /// A printer-friendly name for this device. String get name { - if (deviceManager is BTLEDeviceManager) { - return (deviceManager as BTLEDeviceManager).btleName; + if (deviceManager is BLEDeviceManager) { + return (deviceManager as BLEDeviceManager).bleName ?? ''; } else if (deviceManager is PolarDeviceManager) { return (deviceManager as PolarDeviceManager).displayName ?? ''; } else { @@ -78,15 +78,15 @@ class DeviceViewModel extends ViewModel { PolarDeviceType get polarDeviceType { if (deviceManager is PolarDeviceManager) { - return (deviceManager as PolarDeviceManager).configuration?.deviceType ?? PolarDeviceType.UNKNOWN; + return (deviceManager as PolarDeviceManager).polarDeviceType ?? PolarDeviceType.Unknown; } else { - return PolarDeviceType.UNKNOWN; + return PolarDeviceType.Unknown; } } MovesenseDeviceType get movesenseDeviceType { if (deviceManager is MovesenseDeviceManager) { - return (deviceManager as MovesenseDeviceManager).configuration?.deviceType ?? MovesenseDeviceType.UNKNOWN; + return (deviceManager as MovesenseDeviceManager).movesenseDeviceType; } else { return MovesenseDeviceType.UNKNOWN; } @@ -94,19 +94,18 @@ class DeviceViewModel extends ViewModel { /// Display information about this phone. Map get phoneInfo => { - 'name': '${DeviceInfo().deviceID}', - 'model': '${DeviceInfo().deviceModel} (${DeviceInfo().deviceManufacturer?.toUpperCase()})', - 'version': 'SDK ${DeviceInfo().sdk}', - }; + 'name': '${DeviceInfoService().deviceID}', + 'model': '${DeviceInfoService().deviceModel} (${DeviceInfoService().deviceManufacturer?.toUpperCase()})', + 'version': 'SDK ${DeviceInfoService().sdk}', + }; /// Map a selected device to the device in the protocol and connect to it. void connectToDevice(BluetoothDevice selectedDevice) { - if (deviceManager is BTLEDeviceManager) { - (deviceManager as BTLEDeviceManager).btleAddress = selectedDevice.remoteId.str; - (deviceManager as BTLEDeviceManager).btleName = selectedDevice.platformName; + if (deviceManager is BLEDeviceManager) { + (deviceManager as BLEDeviceManager).bleAddress = selectedDevice.remoteId.str; + (deviceManager as BLEDeviceManager).bleName = selectedDevice.platformName; } - Sensing().controller?.saveDeployment(); deviceManager.connect(); } @@ -115,15 +114,13 @@ class DeviceViewModel extends ViewModel { try { await deviceManager.disconnect(); - // Erase BTLE information so the user can connect to another device, if needed. - if (deviceManager is BTLEDeviceManager) { - (deviceManager as BTLEDeviceManager).btleAddress = ''; - (deviceManager as BTLEDeviceManager).btleName = ''; + // Erase BLE information so the user can connect to another device, if needed. + if (deviceManager is BLEDeviceManager) { + (deviceManager as BLEDeviceManager).bleAddress = ''; + (deviceManager as BLEDeviceManager).bleName = ''; } - - Sensing().controller?.saveDeployment(); } catch (error) { - warning("$runtimeType - Error disconnecting to device '${deviceManager.id}' - $error."); + warning("$runtimeType - Error disconnecting to device '${deviceManager.displayName}' - $error."); } } } @@ -149,57 +146,30 @@ const Map _deviceTypeDescription = { }; const Map _deviceTypeIcon = { - Smartphone.DEVICE_TYPE: Icon( - Icons.phone_android, - size: 30, - color: CACHET.GREEN_1, - ), - WeatherService.DEVICE_TYPE: Icon( - Icons.wb_cloudy, - color: CACHET.BLUE_1, - ), - AirQualityService.DEVICE_TYPE: Icon( - Icons.air, - color: CACHET.LIGHT_BLUE, - ), - LocationService.DEVICE_TYPE: Icon( - Icons.location_on, - color: CACHET.GREEN, - ), - PolarDevice.DEVICE_TYPE: Icon( - Icons.monitor_heart, - size: 30, - color: CACHET.RED, - ), - MovesenseDevice.DEVICE_TYPE: Icon( - Icons.circle, - size: 30, - color: CACHET.GREY_1, - ), - HealthService.DEVICE_TYPE: Icon( - Icons.favorite_rounded, - size: 30, - color: CACHET.RED_1, - ), + Smartphone.DEVICE_TYPE: Icon(Icons.phone_android, size: 30, color: CACHET.GREEN_1), + WeatherService.DEVICE_TYPE: Icon(Icons.wb_cloudy, color: CACHET.BLUE_1), + AirQualityService.DEVICE_TYPE: Icon(Icons.air, color: CACHET.LIGHT_BLUE), + LocationService.DEVICE_TYPE: Icon(Icons.location_on, color: CACHET.GREEN), + PolarDevice.DEVICE_TYPE: Icon(Icons.monitor_heart, size: 30, color: CACHET.RED), + MovesenseDevice.DEVICE_TYPE: Icon(Icons.circle, size: 30, color: CACHET.GREY_1), + HealthService.DEVICE_TYPE: Icon(Icons.favorite_rounded, size: 30, color: CACHET.RED_1), }; const Map _deviceStatusIcon = { - DeviceStatus.initialized: "pages.devices.status.action.connect", + DeviceStatus.configured: "pages.devices.status.action.connect", DeviceStatus.connecting: Icon(Icons.bluetooth_searching_rounded, color: CACHET.DARK_BLUE, size: 30), DeviceStatus.connected: Icon(Icons.bluetooth_rounded, color: CACHET.GREEN_1, size: 30), DeviceStatus.disconnected: "pages.devices.status.action.connect", DeviceStatus.paired: "pages.devices.status.action.connect", - DeviceStatus.error: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), DeviceStatus.unknown: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), }; const Map _serviceStatusIcon = { - DeviceStatus.initialized: "pages.devices.status.action.connect", + DeviceStatus.configured: "pages.devices.status.action.connect", DeviceStatus.connecting: Icon(Icons.sensors_off_rounded, color: CACHET.GREEN_1, size: 30), DeviceStatus.connected: Icon(Icons.sensors_rounded, color: CACHET.GREEN_1, size: 30), DeviceStatus.disconnected: "pages.devices.status.action.connect", DeviceStatus.paired: "pages.devices.status.action.connect", - DeviceStatus.error: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), DeviceStatus.unknown: Icon(Icons.error_outline, color: CACHET.RED_1, size: 30), }; @@ -208,8 +178,7 @@ const Map _deviceStatusText = { DeviceStatus.connected: "pages.devices.status.connected", DeviceStatus.disconnected: "pages.devices.status.disconnected", DeviceStatus.paired: "pages.devices.status.paired", - DeviceStatus.error: "pages.devices.status.error", - DeviceStatus.initialized: "pages.devices.status.initialized", + DeviceStatus.configured: "pages.devices.status.initialized", DeviceStatus.unknown: "pages.devices.status.unknown", }; diff --git a/lib/view_models/informed_consent_page_model.dart b/lib/view_models/informed_consent_page_model.dart index 2527de9b..2b84ccc7 100644 --- a/lib/view_models/informed_consent_page_model.dart +++ b/lib/view_models/informed_consent_page_model.dart @@ -24,8 +24,6 @@ class InformedConsentViewModel extends ViewModel { /// 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, - ) => + Future informedConsentHasBeenAccepted(RPTaskResult informedConsentResult) => bloc.informedConsentHasBeenAccepted(informedConsentResult); } diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_page_model.dart index 0229b7f4..22d185b3 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -1,7 +1,7 @@ part of carp_study_app; class ProfilePageViewModel extends ViewModel { - String get userId => bloc.user?.id ?? bloc.deployment?.participantId ?? ''; + String get userId => bloc.user?.id ?? bloc.study?.participantId ?? ''; String get username => bloc.user?.username ?? ''; String get firstName => bloc.user?.firstName ?? ''; String get lastName => bloc.user?.lastName ?? ''; @@ -11,15 +11,15 @@ class ProfilePageViewModel extends ViewModel { String get studyId => bloc.deployment?.studyId ?? ''; String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; String get studyDeploymentTitle => bloc.deployment?.studyDescription?.title ?? ''; - String get participantId => bloc.deployment?.participantId ?? ''; - String get participantRole => bloc.deployment?.participantRoleName ?? ''; + String get participantId => bloc.study?.participantId ?? ''; + String get participantRole => bloc.study?.participantRoleName ?? ''; String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; String get responsibleEmail => bloc.deployment?.studyDescription?.responsible?.email ?? 'study@carp.dk'; String get privacyPolicyUrl => bloc.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; String get studyDescriptionUrl => bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; - String get deviceID => DeviceInfo().deviceID ?? ''; + String get deviceID => DeviceInfoService().deviceID ?? ''; String get currentServer => bloc.backend.uri.toString(); ProfilePageViewModel(); diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index 4e57b56d..29c2769a 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -7,7 +7,7 @@ class StudyPageViewModel extends ViewModel { String get description => bloc.deployment?.studyDescription?.description ?? ''; String get purpose => bloc.deployment?.studyDescription?.purpose ?? ''; Image get image => Image.asset('assets/images/exercise.png'); - String? get userID => bloc.deployment?.participantId; + String? get userID => bloc.study?.participantId; String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; String get responsibleName => bloc.deployment?.studyDescription?.responsible?.name ?? ''; String get responsibleEmail => bloc.deployment?.studyDescription?.responsible?.email ?? ''; @@ -22,7 +22,7 @@ class StudyPageViewModel extends ViewModel { String get piAffiliation => bloc.deployment?.responsible?.affiliation ?? 'Department of Health Technology, Technical University of Denmark'; - String get participantRole => bloc.deployment?.participantRoleName ?? ''; + String get participantRole => bloc.study?.participantRoleName ?? ''; String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; Future get studyDeploymentStatus => bloc.studyDeploymentStatus; @@ -51,11 +51,11 @@ class StudyPageViewModel extends ViewModel { static const dummyID = '00000000-0000-0000-0000-000000000000'; Message get studyDescriptionMessage => Message( - id: dummyID, - title: title, - message: description, - type: MessageType.announcement, - timestamp: bloc.studyStartTimestamp ?? DateTime.now(), - image: 'assets/images/kids.png', - ); + id: dummyID, + title: title, + message: description, + type: MessageType.announcement, + timestamp: bloc.studyStartTimestamp ?? DateTime.now(), + image: 'assets/images/kids.png', + ); } diff --git a/lib/view_models/user_tasks.dart b/lib/view_models/user_tasks.dart index 6ce14b68..2e09757c 100644 --- a/lib/view_models/user_tasks.dart +++ b/lib/view_models/user_tasks.dart @@ -3,19 +3,15 @@ part of carp_study_app; /// A [UserTaskFactory] that can handle the user tasks in this app. class AppUserTaskFactory implements UserTaskFactory { @override - List types = [ - SurveyUserTask.AUDIO_TYPE, - SurveyUserTask.VIDEO_TYPE, - SurveyUserTask.IMAGE_TYPE, - ]; + List types = [AppTask.AUDIO_TYPE, AppTask.VIDEO_TYPE, AppTask.IMAGE_TYPE]; @override UserTask create(AppTaskExecutor executor) => switch (executor.task.type) { - SurveyUserTask.AUDIO_TYPE => AudioUserTask(executor), - SurveyUserTask.VIDEO_TYPE => VideoUserTask(executor), - SurveyUserTask.IMAGE_TYPE => VideoUserTask(executor), - _ => BackgroundSensingUserTask(executor), - }; + AppTask.AUDIO_TYPE => AudioUserTask(executor), + AppTask.VIDEO_TYPE => VideoUserTask(executor), + AppTask.IMAGE_TYPE => VideoUserTask(executor), + _ => BackgroundSensingUserTask(executor), + }; } /// A user task handling audio recordings. @@ -50,7 +46,7 @@ class AudioUserTask extends UserTask { _countDownController = StreamController.broadcast(); ongoingRecordingDuration = recordingDuration; state = UserTaskState.started; - backgroundTaskExecutor.start(); + backgroundTaskExecutor.resume(); try { backgroundTaskExecutor.measurements @@ -72,7 +68,7 @@ class AudioUserTask extends UserTask { void onRecordStop() { _timer?.cancel(); _countDownController?.close(); - backgroundTaskExecutor.stop(); + backgroundTaskExecutor.pause(); } /// Callback when recording is to start. @@ -81,7 +77,7 @@ class AudioUserTask extends UserTask { _timer?.cancel(); _countDownController?.close(); - backgroundTaskExecutor.stop(); + backgroundTaskExecutor.pause(); } } @@ -108,14 +104,14 @@ class VideoUserTask extends UserTask { _startRecordingTime = DateTime.now(); _endRecordingTime = DateTime.now(); - backgroundTaskExecutor.start(); + backgroundTaskExecutor.resume(); } /// Callback when video recording is started. void onRecordStart() { debug('$runtimeType - onRecordStart()'); _startRecordingTime = DateTime.now(); - backgroundTaskExecutor.start(); + backgroundTaskExecutor.resume(); } /// Callback when video recording is stopped. @@ -130,18 +126,26 @@ class VideoUserTask extends UserTask { /// the data stream. void onSave() { MediaData? media; - backgroundTaskExecutor.stop(); + backgroundTaskExecutor.pause(); if (_file != null) { // create the media measurement ... media = switch (_mediaType) { - MediaType.image => ImageMedia( - filename: _file!.path, startRecordingTime: _startRecordingTime!, endRecordingTime: _endRecordingTime) - ..filename = _file!.path.split("/").last - ..path = _file!.path, - MediaType.video => VideoMedia( - filename: _file!.path, startRecordingTime: _startRecordingTime!, endRecordingTime: _endRecordingTime) - ..filename = _file!.path.split("/").last - ..path = _file!.path, + MediaType.image => + ImageMedia( + filename: _file!.path, + startRecordingTime: _startRecordingTime!, + endRecordingTime: _endRecordingTime, + ) + ..filename = _file!.path.split("/").last + ..path = _file!.path, + MediaType.video => + VideoMedia( + filename: _file!.path, + startRecordingTime: _startRecordingTime!, + endRecordingTime: _endRecordingTime, + ) + ..filename = _file!.path.split("/").last + ..path = _file!.path, _ => null, }; diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index d0b29f69..3566e6c4 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -5,13 +5,13 @@ part of carp_study_app; /// Note that a view model is a [ChangeNotifier] and will notify its listeners /// if changed, including any [ListenableBuilder] widgets. abstract class ViewModel extends ChangeNotifier { - SmartphoneDeploymentController? _controller; + SmartphoneStudyController? _controller; - SmartphoneDeploymentController? get controller => _controller; + SmartphoneStudyController? get controller => _controller; /// Initialize this view model before use. @mustCallSuper - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { _controller = ctrl; } @@ -65,7 +65,7 @@ abstract class SerializableViewModel extends ViewModel { @override @mustCallSuper - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); _model = createModel(); @@ -208,7 +208,7 @@ class CarpStudyAppViewModel extends ViewModel { ParticipantDataPageViewModel get participantDataPageViewModel => _participantDataPageViewModel; @override - void init(SmartphoneDeploymentController ctrl) { + void init(SmartphoneStudyController ctrl) { super.init(ctrl); _taskListPageViewModel.init(ctrl); _studyPageViewModel.init(ctrl); diff --git a/pubspec.lock b/pubspec.lock index 569ab9b3..96460f1f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: "direct main" description: name: app_version_update - sha256: "9277bd6539b4ced74fbcabd5d6aba47b183b2f0ebc56e1d2689be0e6025ab458" + sha256: a7f351142be6d2ae1c504041e4c875a69dd1a97349b5a34ab16cdf20ac4d4810 url: "https://pub.dev" source: hosted - version: "6.1.3" + version: "6.2.0" appcheck: dependency: "direct main" description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: audio_streamer - sha256: "77b4271058fb9273d51e59a47729c6c02d046adcae1c93bc71af26822705b2ab" + sha256: aed4bc55f57d65953b16344f5848239e1c9bd058cf5a6c23d7b4173f337d4ea8 url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.3.0" audioplayers: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: battery_plus - sha256: "03d5a6bb36db9d2b977c548f6b0262d5a84c4d5a4cfee2edac4a91d57011b365" + sha256: ad16fcb55b7384be6b4bbc763d5e2031ac7ea62b2d9b6b661490c7b9741155bf url: "https://pub.dev" source: hosted - version: "6.2.3" + version: "7.0.0" battery_plus_platform_interface: dependency: transitive description: @@ -277,74 +277,74 @@ packages: dependency: "direct main" description: name: carp_audio_package - sha256: cdcc18d011c34b1e29ada818596e6b628669705609a490c3d71d704b18e4cc9e + sha256: f309c570207cc14a7dc22dfcf5eeef5d3bddef2147cc1bf77256af8e696f6007 url: "https://pub.dev" source: hosted - version: "1.10.3" + version: "2.0.0" carp_backend: dependency: "direct main" description: name: carp_backend - sha256: f92462c41a38cf85cf0b5f602126b123f60a96dcccfda3d2751dcdf0b650feec + sha256: f5998fd5cf14b54e5c159d6f3e18ad2b63c3b97d3062a592d311425dc45dcf4b url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "2.0.0" carp_connectivity_package: dependency: "direct main" description: name: carp_connectivity_package - sha256: "3a65729dadeead390071f5e54faf017c5d2977f492d1e709e3a501ffdb8b4951" + sha256: a23dfbe0ed987439a3af3d5228bf68d63c2b68a9e10f496561a8501736b36065 url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "2.0.0" carp_context_package: dependency: "direct main" description: name: carp_context_package - sha256: a1a708be991d8e7a66e43b43ad606c2915ac9c55851f5c57f63a61f35baefb6e + sha256: "3a4d697c0632164667598bc4a08ecbabd16e8fba26294cbb04ff2dad13625ca5" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "2.0.0" carp_core: dependency: "direct main" description: name: carp_core - sha256: "1fcac456368fc32ec9738834b5e0e59132985cb6bf0d8157bbe8d8914ecb6fb2" + sha256: "2644ff9bacd05de0b8290e3abd74542c98380ee9c361e83be9ded86b9cb3ed7c" url: "https://pub.dev" source: hosted - version: "1.9.4" + version: "2.1.2" carp_health_package: dependency: "direct main" description: name: carp_health_package - sha256: db2ff8fdc4acd7fcbfc240fb4a53a7e0557766f188e4b0fc44a375ceebf50743 + sha256: "0cf14b9813058c9ce8e1654da3d9dc6af5f0a4a512f6c1a08a939f764f234999" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" carp_mobile_sensing: dependency: "direct main" description: name: carp_mobile_sensing - sha256: c9b58ba07c7468a681619aad4ff87c7382b453538994a974bce63b87bd9959cc + sha256: "715e2dda7db3cab8c966975b1ab69d755dee15076712b865f164955674d89456" url: "https://pub.dev" source: hosted - version: "1.13.1" + version: "2.1.1" carp_movesense_package: dependency: "direct main" description: name: carp_movesense_package - sha256: e17094a42fae084d26c7759459493389016d9efc9f1555c7a5044eec1469d87d + sha256: fc18d9a4c0585490dab47cb707a46a7b35c64f1d3f8fde25f8cca74b09058991 url: "https://pub.dev" source: hosted - version: "1.7.6" + version: "2.0.0" carp_polar_package: dependency: "direct main" description: name: carp_polar_package - sha256: "21446b411ffc3bcb34f93be33552e5a98cba59a162b2228d92fbdc21ffd7857a" + sha256: d8eeae5b729da833914f2a6cdbd38cca5e5557b71e24d3d7640630b323e07de4 url: "https://pub.dev" source: hosted - version: "1.6.2" + version: "2.0.0" carp_serializable: dependency: "direct main" description: @@ -357,10 +357,10 @@ packages: dependency: "direct main" description: name: carp_survey_package - sha256: "25c055e2267ace486beff4e31bf13eab7d6806f413a54020f74297b6d62fe6b7" + sha256: "5b20d8ccae6af04df6b7c77b8c0e5cf7aef2411f830d830329ba96651d75bae7" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "2.0.0" carp_themes_package: dependency: "direct main" description: @@ -373,10 +373,10 @@ packages: dependency: "direct main" description: name: carp_webservices - sha256: "6459af381237f38e65e6300763cddc04186c69e746ab97210f8134426e8a17dc" + sha256: "24e07669a9ff2f6886be2c34d5d331e3e7e8a8a71a640ec3d605a7d6439a9c59" url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "4.1.0" characters: dependency: transitive description: @@ -453,10 +453,10 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "7.1.1" connectivity_plus_platform_interface: dependency: transitive description: @@ -581,10 +581,10 @@ packages: dependency: transitive description: name: device_info_plus - sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "11.5.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -702,6 +702,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + flutter_background: + dependency: transitive + description: + name: flutter_background + sha256: ee47be26beddc59875158a93eeaaad0c0b5f3b21afbdad3de73a2903be824f0e + url: "https://pub.dev" + source: hosted + version: "1.3.1" flutter_blue_plus: dependency: "direct main" description: @@ -762,34 +770,34 @@ packages: dependency: transitive description: name: flutter_local_notifications - sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" url: "https://pub.dev" source: hosted - version: "19.5.0" + version: "21.0.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "8.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "11.0.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "3.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -892,10 +900,10 @@ packages: dependency: transitive description: name: flutter_timezone - sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" + sha256: "869677426fde92dbe170fb7d2d4929f2a8343c2f5f62f08b0bb64f908630b073" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "5.1.0" flutter_web_auth_2: dependency: transitive description: @@ -969,10 +977,10 @@ packages: dependency: transitive description: name: health - sha256: "0432c4e5c5348164adff57e78ca3191c88f0cdf7c2b0d72b6785a6af965177ac" + sha256: "2d9e119f3a1d281139f93149b41032b9f80b759960875bc784c5a25dd3c17524" url: "https://pub.dev" source: hosted - version: "12.2.1" + version: "13.3.1" hooks: dependency: transitive description: @@ -993,10 +1001,10 @@ packages: dependency: transitive description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -1169,10 +1177,10 @@ packages: dependency: transitive description: name: light - sha256: cb6551bf3ac336fcd5d0ce85ba3345a6f195785bb913f7541a976b4367aeb3a3 + sha256: e1f9b09222881390203a3f31ab2a19e1375e067840018bbdb49a8716e849bdcb url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.0" lints: dependency: transitive description: @@ -1273,10 +1281,10 @@ packages: dependency: transitive description: name: mobility_features - sha256: "6c2f522f2fb6891234f65b0fd0743824567d90110beb461933cdd1cf5f69f0ba" + sha256: "102728cbd718451a462ad2ea28b5f0735b1920eafc6a00148fc3fa3195daea4b" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" mockito: dependency: "direct dev" description: @@ -1289,10 +1297,10 @@ packages: dependency: transitive description: name: network_info_plus - sha256: f926b2ba86aa0086a0dfbb9e5072089bc213d854135c1712f1d29fc89ba3c877 + sha256: "2866dadcbee2709e20d67737a1556f5675b8b0cdcf2c1659ba74bc21bffede4f" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" network_info_plus_platform_interface: dependency: transitive description: @@ -1481,10 +1489,10 @@ packages: dependency: transitive description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1569,18 +1577,18 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 url: "https://pub.dev" source: hosted - version: "11.4.0" + version: "12.0.3" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "13.0.1" permission_handler_apple: dependency: transitive description: @@ -1649,10 +1657,10 @@ packages: dependency: transitive description: name: polar - sha256: "703e13b2c0646d2327c3ef7e83ed09ed5f01f6de14d8813d9b62951db1f1357f" + sha256: f0c168159a57f12863981d1dba14dc3622582f54ef0ce9658c377804997dc7c7 url: "https://pub.dev" source: hosted - version: "7.6.0" + version: "7.10.0" pool: dependency: transitive description: @@ -1761,18 +1769,18 @@ packages: dependency: transitive description: name: screen_state - sha256: de85988dc39f03ab1a8bdadfdd4b0592d71842c161b065a6660dad62d4e42385 + sha256: "64a4583456f9fa91a8d4c681864384513b65590b0bc18fbc4b74c519c4b66c75" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "5.0.0" sensors_plus: dependency: transitive description: name: sensors_plus - sha256: "89e2bfc3d883743539ce5774a2b93df61effde40ff958ecad78cd66b1a8b8d52" + sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" sensors_plus_platform_interface: dependency: transitive description: @@ -1990,10 +1998,10 @@ packages: dependency: transitive description: name: stats - sha256: "35dba13a465ac4f788d2f21211bb4736902f49ef02471ba39337d56c4644290c" + sha256: f08a0b8dcf95cc68960e85eaa21c80e8837fb816c719a99a0edd36c44831d403 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" stream_channel: dependency: transitive description: @@ -2078,10 +2086,10 @@ packages: dependency: transitive description: name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" url: "https://pub.dev" source: hosted - version: "0.10.1" + version: "0.11.0" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 39b3f24d..61be3ff1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,30 +4,30 @@ publish_to: "none" version: 4.3.0+1 environment: - sdk: ">=3.2.0 <4.0.0" - flutter: ">=3.16.0" + sdk: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" dependencies: flutter: sdk: flutter - carp_serializable: ^2.0.0 - carp_core: ^1.9.0 - carp_mobile_sensing: ^1.13.0 - carp_context_package: ^1.10.0 - carp_connectivity_package: ^1.7.0 - carp_survey_package: ^1.8.2 - carp_audio_package: ^1.10.0 - carp_polar_package: ^1.6.1 - carp_health_package: ^3.2.0 - carp_movesense_package: ^1.7.2 + carp_serializable: ^2.0.1 + carp_core: ^2.1.2 + carp_mobile_sensing: ^2.1.1 + 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_movesense_package: ^2.0.0 carp_themes_package: ^0.0.5 - carp_webservices: ^3.8.0 - carp_backend: ^1.9.2 + carp_webservices: ^4.0.0 + carp_backend: ^2.0.0 research_package: ^2.2.0 - cognition_package: ^1.6.1 + cognition_package: ^1.7.0 url_launcher: ^6.1.5 timeago: ^3.1.0 @@ -46,8 +46,8 @@ dependencies: go_router: ^15.0.0 flutter_svg: ^2.0.4 flutter_blue_plus: ^1.15.5 - permission_handler: ^11.0.1 - connectivity_plus: ^6.0.0 + permission_handler: ^12.0.3 + connectivity_plus: ^7.0.0 open_settings_plus: ^0.4.0 appcheck: ^1.5.2 flutter_plugin_android_lifecycle: ^2.0.24 @@ -119,6 +119,13 @@ dev_dependencies: # 3. run 'dart run flutter_launcher_icons:main' flutter: + # Route all iOS native deps through CocoaPods. The Polar BLE SDK (via the + # `polar` plugin) and Movesense (`mdsflutter`, CocoaPods-only) both pull in + # SwiftProtobuf; with Swift Package Manager enabled Polar resolves it via SPM + # while Movesense uses CocoaPods, producing duplicate-symbol link errors. + config: + enable-swift-package-manager: false + uses-material-design: true assets: diff --git a/test/exports.dart b/test/exports.dart index 93c84f57..a5d89980 100644 --- a/test/exports.dart +++ b/test/exports.dart @@ -6,6 +6,6 @@ export 'package:mockito/mockito.dart'; export 'package:mockito/annotations.dart'; export 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; export 'package:carp_polar_package/carp_polar_package.dart'; -export 'package:carp_core/carp_core.dart'; +export 'package:carp_core/carp_core.dart' hide Smartphone, BLEHeartRateDevice; export 'heart_rate_data_model_test.mocks.dart'; diff --git a/test/heart_rate_data_model_test.dart b/test/heart_rate_data_model_test.dart index a3df3ed2..accd46f2 100644 --- a/test/heart_rate_data_model_test.dart +++ b/test/heart_rate_data_model_test.dart @@ -1,7 +1,7 @@ import 'exports.dart'; @GenerateNiceMocks([ - MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), @@ -13,7 +13,7 @@ void main() { setUp(() {}); group("HeartRateCardViewModel", () { test('initializes HeartRateCardViewModel', skip: true, () { - final controller = MockSmartphoneDeploymentController(); + final controller = MockSmartphoneStudyController(); final model = MockHeartRateCardViewModel(); final dataModel = MockHourlyHeartRate(); when(model.createModel()).thenReturn(dataModel); @@ -25,7 +25,7 @@ void main() { }); group('init', () { group('should listen to heart rate events', () { - final mockSmartphoneDeploymentController = MockSmartphoneDeploymentController(); + final mockSmartphoneStudyController = MockSmartphoneStudyController(); final mockPolarHRSample = MockPolarHRSample(); final mockPolarHRDatum = MockPolarHR(); final mockMeasurement = MockMeasurement(); @@ -33,9 +33,9 @@ void main() { final heartRateStreamController = StreamController.broadcast(); setUp(() { - when(mockSmartphoneDeploymentController.measurements).thenAnswer((_) => heartRateStreamController.stream); + when(mockSmartphoneStudyController.measurements).thenAnswer((_) => heartRateStreamController.stream); - viewModel.init(mockSmartphoneDeploymentController); + viewModel.init(mockSmartphoneStudyController); }); tearDownAll(() { heartRateStreamController.close(); @@ -52,8 +52,10 @@ void main() { await Future.delayed(const Duration(milliseconds: 100)); expect(viewModel.currentHeartRate, equals(80.0)); expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(80, 80))); - expect(viewModel.hourlyHeartRate, - equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 80)).hourlyHeartRate)); + expect( + viewModel.hourlyHeartRate, + equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 80)).hourlyHeartRate), + ); }); test('with multiple events', () async { // Add a heart rate data point to the stream @@ -74,9 +76,12 @@ void main() { expect(viewModel.currentHeartRate, equals(60)); expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(60, 90))); expect( - viewModel.hourlyHeartRate, - equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 60).addHeartRate(DateTime.now().hour, 90)) - .hourlyHeartRate)); + viewModel.hourlyHeartRate, + equals( + (HourlyHeartRate().addHeartRate(DateTime.now().hour, 60).addHeartRate(DateTime.now().hour, 90)) + .hourlyHeartRate, + ), + ); }); test('with events with data that is 0', () async { // Add a heart rate data point to the stream diff --git a/test/heart_rate_data_model_test.mocks.dart b/test/heart_rate_data_model_test.mocks.dart index 41e4e452..ff2d14b4 100644 --- a/test/heart_rate_data_model_test.mocks.dart +++ b/test/heart_rate_data_model_test.mocks.dart @@ -6,10 +6,10 @@ import 'dart:async' as _i7; import 'dart:ui' as _i8; -import 'package:carp_core/carp_core.dart' as _i3; +import 'package:carp_core/carp_core.dart' as _i4; import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i2; import 'package:carp_polar_package/carp_polar_package.dart' as _i9; -import 'package:carp_study_app/main.dart' as _i4; +import 'package:carp_study_app/main.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i6; import 'package:permission_handler/permission_handler.dart' as _i5; @@ -27,799 +27,347 @@ import 'package:permission_handler/permission_handler.dart' as _i5; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member -class _FakeDeviceController_0 extends _i1.SmartFake implements _i2.DeviceController { - _FakeDeviceController_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, - ); + _FakeSmartphoneDeploymentExecutor_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeData_2 extends _i1.SmartFake implements _i3.Data { - _FakeData_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeHeartRateMinMaxPrHour_2 extends _i1.SmartFake implements _i3.HeartRateMinMaxPrHour { + _FakeHeartRateMinMaxPrHour_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeDeploymentService_3 extends _i1.SmartFake implements _i3.DeploymentService { - _FakeDeploymentService_3( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeHourlyHeartRate_3 extends _i1.SmartFake implements _i3.HourlyHeartRate { + _FakeHourlyHeartRate_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeHeartRateMinMaxPrHour_4 extends _i1.SmartFake implements _i4.HeartRateMinMaxPrHour { - _FakeHeartRateMinMaxPrHour_4( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeDateTime_4 extends _i1.SmartFake implements DateTime { + _FakeDateTime_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeHourlyHeartRate_5 extends _i1.SmartFake implements _i4.HourlyHeartRate { - _FakeHourlyHeartRate_5( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeDataType_5 extends _i1.SmartFake implements _i4.DataType { + _FakeDataType_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeDateTime_6 extends _i1.SmartFake implements DateTime { - _FakeDateTime_6( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeData_6 extends _i1.SmartFake implements _i4.Data { + _FakeData_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeDataType_7 extends _i1.SmartFake implements _i3.DataType { - _FakeDataType_7( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeDataModel_7 extends _i1.SmartFake implements _i3.DataModel { + _FakeDataModel_7(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeDataModel_8 extends _i1.SmartFake implements _i4.DataModel { - _FakeDataModel_8( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [SmartphoneDeploymentController]. +/// A class which mocks [SmartphoneStudyController]. /// /// See the documentation for Mockito's code generation for more information. -class MockSmartphoneDeploymentController extends _i1.Mock implements _i2.SmartphoneDeploymentController { - @override - _i2.DeviceController get deviceRegistry => (super.noSuchMethod( - Invocation.getter(#deviceRegistry), - returnValue: _FakeDeviceController_0( - this, - Invocation.getter(#deviceRegistry), - ), - returnValueForMissingStub: _FakeDeviceController_0( - this, - Invocation.getter(#deviceRegistry), - ), - ) as _i2.DeviceController); - - @override - Map<_i5.Permission, _i5.PermissionStatus> get permissions => (super.noSuchMethod( - Invocation.getter(#permissions), - returnValue: <_i5.Permission, _i5.PermissionStatus>{}, - returnValueForMissingStub: <_i5.Permission, _i5.PermissionStatus>{}, - ) as Map<_i5.Permission, _i5.PermissionStatus>); - - @override - _i2.SmartphoneDeploymentExecutor get executor => (super.noSuchMethod( - Invocation.getter(#executor), - returnValue: _FakeSmartphoneDeploymentExecutor_1( - this, - Invocation.getter(#executor), - ), - returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1( - this, - Invocation.getter(#executor), - ), - ) as _i2.SmartphoneDeploymentExecutor); - - @override - String get privacySchemaName => (super.noSuchMethod( - Invocation.getter(#privacySchemaName), - returnValue: _i6.dummyValue( - this, - Invocation.getter(#privacySchemaName), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.getter(#privacySchemaName), - ), - ) as String); - - @override - _i2.DataTransformer get transformer => (super.noSuchMethod( - Invocation.getter(#transformer), - returnValue: (_i3.Data __p0) => _FakeData_2( - this, - Invocation.getter(#transformer), - ), - returnValueForMissingStub: (_i3.Data __p0) => _FakeData_2( - this, - Invocation.getter(#transformer), - ), - ) as _i2.DataTransformer); - - @override - _i7.Stream<_i3.Measurement> get measurements => (super.noSuchMethod( - Invocation.getter(#measurements), - returnValue: _i7.Stream<_i3.Measurement>.empty(), - returnValueForMissingStub: _i7.Stream<_i3.Measurement>.empty(), - ) as _i7.Stream<_i3.Measurement>); - - @override - int get samplingSize => (super.noSuchMethod( - Invocation.getter(#samplingSize), - returnValue: 0, - returnValueForMissingStub: 0, - ) as int); - - @override - _i3.DeploymentService get deploymentService => (super.noSuchMethod( - Invocation.getter(#deploymentService), - returnValue: _FakeDeploymentService_3( - this, - Invocation.getter(#deploymentService), - ), - returnValueForMissingStub: _FakeDeploymentService_3( - this, - Invocation.getter(#deploymentService), - ), - ) as _i3.DeploymentService); - - @override - _i7.Stream<_i3.StudyStatus> get statusEvents => (super.noSuchMethod( - Invocation.getter(#statusEvents), - returnValue: _i7.Stream<_i3.StudyStatus>.empty(), - returnValueForMissingStub: _i7.Stream<_i3.StudyStatus>.empty(), - ) as _i7.Stream<_i3.StudyStatus>); - - @override - _i3.StudyStatus get status => (super.noSuchMethod( - Invocation.getter(#status), - returnValue: _i3.StudyStatus.DeploymentNotStarted, - returnValueForMissingStub: _i3.StudyStatus.DeploymentNotStarted, - ) as _i3.StudyStatus); - - @override - bool get isInitialized => (super.noSuchMethod( - Invocation.getter(#isInitialized), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - bool get isDeployed => (super.noSuchMethod( - Invocation.getter(#isDeployed), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - bool get isStopped => (super.noSuchMethod( - Invocation.getter(#isStopped), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> get remainingDevicesToRegister => (super.noSuchMethod( - Invocation.getter(#remainingDevicesToRegister), - returnValue: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], - returnValueForMissingStub: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], - ) as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); - - @override - set deployment(_i3.PrimaryDeviceDeployment? _deployment) => super.noSuchMethod( - Invocation.setter( - #deployment, - _deployment, - ), - returnValueForMissingStub: null, - ); - - @override - set deviceRegistry(_i3.DeviceDataCollectorFactory? _deviceRegistry) => super.noSuchMethod( - Invocation.setter( - #deviceRegistry, - _deviceRegistry, - ), - returnValueForMissingStub: null, - ); - - @override - set deploymentService(_i3.DeploymentService? _deploymentService) => super.noSuchMethod( - Invocation.setter( - #deploymentService, - _deploymentService, - ), - returnValueForMissingStub: null, - ); - - @override - set deploymentStatus(_i3.StudyDeploymentStatus? _deploymentStatus) => super.noSuchMethod( - Invocation.setter( - #deploymentStatus, - _deploymentStatus, - ), - returnValueForMissingStub: null, - ); - - @override - set status(_i3.StudyStatus? newStatus) => super.noSuchMethod( - Invocation.setter( - #status, - newStatus, - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Stream<_i3.Measurement> measurementsByType(String? type) => (super.noSuchMethod( - Invocation.method( - #measurementsByType, - [type], - ), - returnValue: _i7.Stream<_i3.Measurement>.empty(), - returnValueForMissingStub: _i7.Stream<_i3.Measurement>.empty(), - ) as _i7.Stream<_i3.Measurement>); +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)), + ) + as _i2.SmartphoneStudy); @override - _i7.Future configure({ - _i2.DataEndPoint? dataEndPoint, - _i2.DataTransformer? transformer, - }) => + List<_i4.DeviceConfiguration<_i4.DeviceRegistration>> get remainingDevicesToRegister => (super.noSuchMethod( - Invocation.method( - #configure, - [], - { - #dataEndPoint: dataEndPoint, - #transformer: transformer, - }, - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future askForAllPermissions() => (super.noSuchMethod( - Invocation.method( - #askForAllPermissions, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - void initializeDevices() => super.noSuchMethod( - Invocation.method( - #initializeDevices, - [], - ), - returnValueForMissingStub: null, - ); - - @override - void initializeDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? configuration) => super.noSuchMethod( - Invocation.method( - #initializeDevice, - [configuration], - ), - returnValueForMissingStub: null, - ); - - @override - void startHeartbeatMonitoring() => super.noSuchMethod( - Invocation.method( - #startHeartbeatMonitoring, - [], - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Future connectAllConnectableDevices() => (super.noSuchMethod( - Invocation.method( - #connectAllConnectableDevices, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future<_i3.StudyStatus> tryDeployment({bool? useCached = true}) => (super.noSuchMethod( - Invocation.method( - #tryDeployment, - [], - {#useCached: useCached}, - ), - returnValue: _i7.Future<_i3.StudyStatus>.value(_i3.StudyStatus.DeploymentNotStarted), - returnValueForMissingStub: _i7.Future<_i3.StudyStatus>.value(_i3.StudyStatus.DeploymentNotStarted), - ) as _i7.Future<_i3.StudyStatus>); - - @override - _i7.Future saveDeployment() => (super.noSuchMethod( - Invocation.method( - #saveDeployment, - [], - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); - - @override - _i7.Future restoreDeployment() => (super.noSuchMethod( - Invocation.method( - #restoreDeployment, - [], - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); - - @override - _i7.Future eraseDeployment() => (super.noSuchMethod( - Invocation.method( - #eraseDeployment, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future start([bool? start = true]) => (super.noSuchMethod( - Invocation.method( - #start, - [start], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future stop() => (super.noSuchMethod( - Invocation.method( - #stop, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future remove() => (super.noSuchMethod( - Invocation.method( - #remove, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Future addStudy( - _i3.Study? study, - _i3.DeviceRegistration? deviceRegistration, - ) => + Invocation.getter(#remainingDevicesToRegister), + returnValue: <_i4.DeviceConfiguration<_i4.DeviceRegistration>>[], + returnValueForMissingStub: <_i4.DeviceConfiguration<_i4.DeviceRegistration>>[], + ) + as List<_i4.DeviceConfiguration<_i4.DeviceRegistration>>); + + @override + Map<_i5.Permission, _i5.PermissionStatus> get permissions => (super.noSuchMethod( - Invocation.method( - #addStudy, - [ - study, - deviceRegistration, - ], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future<_i3.StudyDeploymentStatus?> getStudyDeploymentStatus() => (super.noSuchMethod( - Invocation.method( - #getStudyDeploymentStatus, - [], - ), - returnValue: _i7.Future<_i3.StudyDeploymentStatus?>.value(), - returnValueForMissingStub: _i7.Future<_i3.StudyDeploymentStatus?>.value(), - ) as _i7.Future<_i3.StudyDeploymentStatus?>); - - @override - _i7.Future tryRegisterConnectedDevice(_i3.DeviceConfiguration<_i3.DeviceRegistration>? device) => + Invocation.getter(#permissions), + returnValue: <_i5.Permission, _i5.PermissionStatus>{}, + returnValueForMissingStub: <_i5.Permission, _i5.PermissionStatus>{}, + ) + as Map<_i5.Permission, _i5.PermissionStatus>); + + @override + _i2.SmartphoneDeploymentExecutor get executor => (super.noSuchMethod( - Invocation.method( - #tryRegisterConnectedDevice, - [device], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); - - @override - _i7.Future tryRegisterRemainingDevicesToRegister() => (super.noSuchMethod( - Invocation.method( - #tryRegisterRemainingDevicesToRegister, - [], - ), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) as _i7.Future); + Invocation.getter(#executor), + returnValue: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), + returnValueForMissingStub: _FakeSmartphoneDeploymentExecutor_1(this, Invocation.getter(#executor)), + ) + as _i2.SmartphoneDeploymentExecutor); + + @override + String get privacySchemaName => + (super.noSuchMethod( + Invocation.getter(#privacySchemaName), + returnValue: _i6.dummyValue(this, Invocation.getter(#privacySchemaName)), + returnValueForMissingStub: _i6.dummyValue(this, Invocation.getter(#privacySchemaName)), + ) + as String); + + @override + _i7.Stream<_i4.Measurement> get measurements => + (super.noSuchMethod( + Invocation.getter(#measurements), + returnValue: _i7.Stream<_i4.Measurement>.empty(), + returnValueForMissingStub: _i7.Stream<_i4.Measurement>.empty(), + ) + as _i7.Stream<_i4.Measurement>); + + @override + _i7.Stream<_i4.Measurement> measurementsByType(String? type) => + (super.noSuchMethod( + Invocation.method(#measurementsByType, [type]), + returnValue: _i7.Stream<_i4.Measurement>.empty(), + returnValueForMissingStub: _i7.Stream<_i4.Measurement>.empty(), + ) + as _i7.Stream<_i4.Measurement>); + + @override + _i7.Future tryRegisterConnectedDevice(_i4.DeviceConfiguration<_i4.DeviceRegistration>? device) => + (super.noSuchMethod( + Invocation.method(#tryRegisterConnectedDevice, [device]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future tryRegisterRemainingDevicesToRegister() => + (super.noSuchMethod( + Invocation.method(#tryRegisterRemainingDevicesToRegister, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future tryUnregisterDisconnectedDevice(_i4.DeviceConfiguration<_i4.DeviceRegistration>? device) => + (super.noSuchMethod( + Invocation.method(#tryUnregisterDisconnectedDevice, [device]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future tryReregisterDevice(_i4.DeviceConfiguration<_i4.DeviceRegistration>? device) => + (super.noSuchMethod( + Invocation.method(#tryReregisterDevice, [device]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future askForAllPermissions() => + (super.noSuchMethod( + Invocation.method(#askForAllPermissions, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + void restart() => super.noSuchMethod(Invocation.method(#restart, []), returnValueForMissingStub: null); + + @override + void resume() => super.noSuchMethod(Invocation.method(#resume, []), returnValueForMissingStub: null); + + @override + void pause() => super.noSuchMethod(Invocation.method(#pause, []), returnValueForMissingStub: null); + + @override + void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null); } /// A class which mocks [HeartRateCardViewModel]. /// /// See the documentation for Mockito's code generation for more information. -class MockHeartRateCardViewModel extends _i1.Mock implements _i4.HeartRateCardViewModel { - @override - Map get hourlyHeartRate => (super.noSuchMethod( - Invocation.getter(#hourlyHeartRate), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); - - @override - _i4.HeartRateMinMaxPrHour get dayMinMax => (super.noSuchMethod( - Invocation.getter(#dayMinMax), - returnValue: _FakeHeartRateMinMaxPrHour_4( - this, - Invocation.getter(#dayMinMax), - ), - returnValueForMissingStub: _FakeHeartRateMinMaxPrHour_4( - this, - Invocation.getter(#dayMinMax), - ), - ) as _i4.HeartRateMinMaxPrHour); - - @override - _i4.HourlyHeartRate get model => (super.noSuchMethod( - Invocation.getter(#model), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.getter(#model), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.getter(#model), - ), - ) as _i4.HourlyHeartRate); - - @override - _i7.Future get filename => (super.noSuchMethod( - Invocation.getter(#filename), - returnValue: _i7.Future.value(_i6.dummyValue( - this, - Invocation.getter(#filename), - )), - returnValueForMissingStub: _i7.Future.value(_i6.dummyValue( - this, - Invocation.getter(#filename), - )), - ) as _i7.Future); - - @override - bool get hasListeners => (super.noSuchMethod( - Invocation.getter(#hasListeners), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - _i4.HourlyHeartRate createModel() => (super.noSuchMethod( - Invocation.method( - #createModel, - [], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #createModel, - [], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #createModel, - [], - ), - ), - ) as _i4.HourlyHeartRate); - - @override - void init(_i2.SmartphoneDeploymentController? ctrl) => super.noSuchMethod( - Invocation.method( - #init, - [ctrl], - ), - returnValueForMissingStub: null, - ); - - @override - void clear() => super.noSuchMethod( - Invocation.method( - #clear, - [], - ), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); - - @override - _i7.Future save() => (super.noSuchMethod( - Invocation.method( - #save, - [], - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); - - @override - bool delete() => (super.noSuchMethod( - Invocation.method( - #delete, - [], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - _i7.Future<_i4.HourlyHeartRate?> restore() => (super.noSuchMethod( - Invocation.method( - #restore, - [], - ), - returnValue: _i7.Future<_i4.HourlyHeartRate?>.value(), - returnValueForMissingStub: _i7.Future<_i4.HourlyHeartRate?>.value(), - ) as _i7.Future<_i4.HourlyHeartRate?>); - - @override - void addListener(_i8.VoidCallback? listener) => super.noSuchMethod( - Invocation.method( - #addListener, - [listener], - ), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i8.VoidCallback? listener) => super.noSuchMethod( - Invocation.method( - #removeListener, - [listener], - ), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method( - #notifyListeners, - [], - ), - returnValueForMissingStub: null, - ); +class MockHeartRateCardViewModel extends _i1.Mock implements _i3.HeartRateCardViewModel { + @override + Map get hourlyHeartRate => + (super.noSuchMethod( + Invocation.getter(#hourlyHeartRate), + returnValue: {}, + returnValueForMissingStub: {}, + ) + as Map); + + @override + _i3.HeartRateMinMaxPrHour get dayMinMax => + (super.noSuchMethod( + Invocation.getter(#dayMinMax), + returnValue: _FakeHeartRateMinMaxPrHour_2(this, Invocation.getter(#dayMinMax)), + returnValueForMissingStub: _FakeHeartRateMinMaxPrHour_2(this, Invocation.getter(#dayMinMax)), + ) + as _i3.HeartRateMinMaxPrHour); + + @override + _i3.HourlyHeartRate get model => + (super.noSuchMethod( + Invocation.getter(#model), + returnValue: _FakeHourlyHeartRate_3(this, Invocation.getter(#model)), + returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.getter(#model)), + ) + as _i3.HourlyHeartRate); + + @override + _i7.Future get filename => + (super.noSuchMethod( + Invocation.getter(#filename), + returnValue: _i7.Future.value(_i6.dummyValue(this, Invocation.getter(#filename))), + returnValueForMissingStub: _i7.Future.value( + _i6.dummyValue(this, Invocation.getter(#filename)), + ), + ) + as _i7.Future); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + _i3.HourlyHeartRate createModel() => + (super.noSuchMethod( + Invocation.method(#createModel, []), + returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#createModel, [])), + returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.method(#createModel, [])), + ) + as _i3.HourlyHeartRate); + + @override + void init(_i2.SmartphoneStudyController? ctrl) => + super.noSuchMethod(Invocation.method(#init, [ctrl]), returnValueForMissingStub: null); + + @override + void clear() => super.noSuchMethod(Invocation.method(#clear, []), returnValueForMissingStub: null); + + @override + void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null); + + @override + _i7.Future save() => + (super.noSuchMethod( + Invocation.method(#save, []), + returnValue: _i7.Future.value(false), + returnValueForMissingStub: _i7.Future.value(false), + ) + as _i7.Future); + + @override + bool delete() => + (super.noSuchMethod(Invocation.method(#delete, []), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + _i7.Future<_i3.HourlyHeartRate?> restore() => + (super.noSuchMethod( + Invocation.method(#restore, []), + returnValue: _i7.Future<_i3.HourlyHeartRate?>.value(), + returnValueForMissingStub: _i7.Future<_i3.HourlyHeartRate?>.value(), + ) + as _i7.Future<_i3.HourlyHeartRate?>); + + @override + void addListener(_i8.VoidCallback? listener) => + super.noSuchMethod(Invocation.method(#addListener, [listener]), returnValueForMissingStub: null); + + @override + void removeListener(_i8.VoidCallback? listener) => + super.noSuchMethod(Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null); + + @override + void notifyListeners() => + super.noSuchMethod(Invocation.method(#notifyListeners, []), returnValueForMissingStub: null); } /// A class which mocks [HourlyHeartRate]. /// /// See the documentation for Mockito's code generation for more information. -class MockHourlyHeartRate extends _i1.Mock implements _i4.HourlyHeartRate { - @override - Map get hourlyHeartRate => (super.noSuchMethod( - Invocation.getter(#hourlyHeartRate), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); - - @override - DateTime get lastUpdated => (super.noSuchMethod( - Invocation.getter(#lastUpdated), - returnValue: _FakeDateTime_6( - this, - Invocation.getter(#lastUpdated), - ), - returnValueForMissingStub: _FakeDateTime_6( - this, - Invocation.getter(#lastUpdated), - ), - ) as DateTime); - - @override - set hourlyHeartRate(Map? _hourlyHeartRate) => super.noSuchMethod( - Invocation.setter( - #hourlyHeartRate, - _hourlyHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - set lastUpdated(DateTime? _lastUpdated) => super.noSuchMethod( - Invocation.setter( - #lastUpdated, - _lastUpdated, - ), - returnValueForMissingStub: null, - ); - - @override - set currentHeartRate(double? _currentHeartRate) => super.noSuchMethod( - Invocation.setter( - #currentHeartRate, - _currentHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - set maxHeartRate(double? _maxHeartRate) => super.noSuchMethod( - Invocation.setter( - #maxHeartRate, - _maxHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - set minHeartRate(double? _minHeartRate) => super.noSuchMethod( - Invocation.setter( - #minHeartRate, - _minHeartRate, - ), - returnValueForMissingStub: null, - ); - - @override - _i4.HourlyHeartRate resetDataAtMidnight() => (super.noSuchMethod( - Invocation.method( - #resetDataAtMidnight, - [], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #resetDataAtMidnight, - [], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #resetDataAtMidnight, - [], - ), - ), - ) as _i4.HourlyHeartRate); - - @override - _i4.HourlyHeartRate addHeartRate( - int? hour, - double? heartRate, - ) => +class MockHourlyHeartRate extends _i1.Mock implements _i3.HourlyHeartRate { + @override + Map get hourlyHeartRate => + (super.noSuchMethod( + Invocation.getter(#hourlyHeartRate), + returnValue: {}, + returnValueForMissingStub: {}, + ) + as Map); + + @override + DateTime get lastUpdated => (super.noSuchMethod( - Invocation.method( - #addHeartRate, - [ - hour, - heartRate, - ], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #addHeartRate, - [ - hour, - heartRate, - ], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #addHeartRate, - [ - hour, - heartRate, - ], - ), - ), - ) as _i4.HourlyHeartRate); - - @override - _i4.HourlyHeartRate fromJson(Map? json) => (super.noSuchMethod( - Invocation.method( - #fromJson, - [json], - ), - returnValue: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - returnValueForMissingStub: _FakeHourlyHeartRate_5( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - ) as _i4.HourlyHeartRate); - - @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); + Invocation.getter(#lastUpdated), + returnValue: _FakeDateTime_4(this, Invocation.getter(#lastUpdated)), + returnValueForMissingStub: _FakeDateTime_4(this, Invocation.getter(#lastUpdated)), + ) + as DateTime); + + @override + set hourlyHeartRate(Map? value) => + super.noSuchMethod(Invocation.setter(#hourlyHeartRate, value), returnValueForMissingStub: null); + + @override + set lastUpdated(DateTime? value) => + super.noSuchMethod(Invocation.setter(#lastUpdated, value), returnValueForMissingStub: null); + + @override + set currentHeartRate(double? value) => + super.noSuchMethod(Invocation.setter(#currentHeartRate, value), returnValueForMissingStub: null); + + @override + set maxHeartRate(double? value) => + super.noSuchMethod(Invocation.setter(#maxHeartRate, value), returnValueForMissingStub: null); + + @override + set minHeartRate(double? value) => + super.noSuchMethod(Invocation.setter(#minHeartRate, value), returnValueForMissingStub: null); + + @override + _i3.HourlyHeartRate resetDataAtMidnight() => + (super.noSuchMethod( + Invocation.method(#resetDataAtMidnight, []), + returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#resetDataAtMidnight, [])), + returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.method(#resetDataAtMidnight, [])), + ) + as _i3.HourlyHeartRate); + + @override + _i3.HourlyHeartRate addHeartRate(int? hour, double? heartRate) => + (super.noSuchMethod( + Invocation.method(#addHeartRate, [hour, heartRate]), + returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#addHeartRate, [hour, heartRate])), + returnValueForMissingStub: _FakeHourlyHeartRate_3( + this, + Invocation.method(#addHeartRate, [hour, heartRate]), + ), + ) + as _i3.HourlyHeartRate); + + @override + _i3.HourlyHeartRate fromJson(Map? json) => + (super.noSuchMethod( + Invocation.method(#fromJson, [json]), + returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#fromJson, [json])), + returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.method(#fromJson, [json])), + ) + as _i3.HourlyHeartRate); + + @override + Map toJson() => + (super.noSuchMethod( + Invocation.method(#toJson, []), + returnValue: {}, + returnValueForMissingStub: {}, + ) + as Map); } /// A class which mocks [PolarHRSample]. @@ -827,42 +375,35 @@ class MockHourlyHeartRate extends _i1.Mock implements _i4.HourlyHeartRate { /// See the documentation for Mockito's code generation for more information. class MockPolarHRSample extends _i1.Mock implements _i9.PolarHRSample { @override - int get hr => (super.noSuchMethod( - Invocation.getter(#hr), - returnValue: 0, - returnValueForMissingStub: 0, - ) as int); + int get hr => (super.noSuchMethod(Invocation.getter(#hr), returnValue: 0, returnValueForMissingStub: 0) as int); @override - List get rrsMs => (super.noSuchMethod( - Invocation.getter(#rrsMs), - returnValue: [], - returnValueForMissingStub: [], - ) as List); + List get rrsMs => + (super.noSuchMethod(Invocation.getter(#rrsMs), returnValue: [], returnValueForMissingStub: []) + as List); @override - bool get contactStatus => (super.noSuchMethod( - Invocation.getter(#contactStatus), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); + bool get contactStatus => + (super.noSuchMethod(Invocation.getter(#contactStatus), returnValue: false, returnValueForMissingStub: false) + as bool); @override - bool get contactStatusSupported => (super.noSuchMethod( - Invocation.getter(#contactStatusSupported), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); + bool get contactStatusSupported => + (super.noSuchMethod( + Invocation.getter(#contactStatusSupported), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); + Map toJson() => + (super.noSuchMethod( + Invocation.method(#toJson, []), + returnValue: {}, + returnValueForMissingStub: {}, + ) + as Map); } /// A class which mocks [PolarHR]. @@ -870,201 +411,133 @@ class MockPolarHRSample extends _i1.Mock implements _i9.PolarHRSample { /// See the documentation for Mockito's code generation for more information. class MockPolarHR extends _i1.Mock implements _i9.PolarHR { @override - Function get fromJsonFunction => (super.noSuchMethod( - Invocation.getter(#fromJsonFunction), - returnValue: () {}, - returnValueForMissingStub: () {}, - ) as Function); - - @override - String get jsonType => (super.noSuchMethod( - Invocation.getter(#jsonType), - returnValue: _i6.dummyValue( - this, - Invocation.getter(#jsonType), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.getter(#jsonType), - ), - ) as String); - - @override - List<_i9.PolarHRSample> get samples => (super.noSuchMethod( - Invocation.getter(#samples), - returnValue: <_i9.PolarHRSample>[], - returnValueForMissingStub: <_i9.PolarHRSample>[], - ) as List<_i9.PolarHRSample>); - - @override - set sensorSpecificData(_i3.Data? _sensorSpecificData) => super.noSuchMethod( - Invocation.setter( - #sensorSpecificData, - _sensorSpecificData, - ), - returnValueForMissingStub: null, - ); - - @override - _i3.DataType get format => (super.noSuchMethod( - Invocation.getter(#format), - returnValue: _FakeDataType_7( - this, - Invocation.getter(#format), - ), - returnValueForMissingStub: _FakeDataType_7( - this, - Invocation.getter(#format), - ), - ) as _i3.DataType); - - @override - set $type(String? _$type) => super.noSuchMethod( - Invocation.setter( - #$type, - _$type, - ), - returnValueForMissingStub: null, - ); - - @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); - - @override - bool equivalentTo(_i3.Data? other) => (super.noSuchMethod( - Invocation.method( - #equivalentTo, - [other], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); + Function get fromJsonFunction => + (super.noSuchMethod(Invocation.getter(#fromJsonFunction), returnValue: () {}, returnValueForMissingStub: () {}) + as Function); + + @override + String get jsonType => + (super.noSuchMethod( + Invocation.getter(#jsonType), + returnValue: _i6.dummyValue(this, Invocation.getter(#jsonType)), + returnValueForMissingStub: _i6.dummyValue(this, Invocation.getter(#jsonType)), + ) + as String); + + @override + List<_i9.PolarHRSample> get samples => + (super.noSuchMethod( + Invocation.getter(#samples), + returnValue: <_i9.PolarHRSample>[], + returnValueForMissingStub: <_i9.PolarHRSample>[], + ) + as List<_i9.PolarHRSample>); + + @override + set sensorSpecificData(_i4.Data? value) => + super.noSuchMethod(Invocation.setter(#sensorSpecificData, value), returnValueForMissingStub: null); + + @override + _i4.DataType get dataType => + (super.noSuchMethod( + Invocation.getter(#dataType), + returnValue: _FakeDataType_5(this, Invocation.getter(#dataType)), + returnValueForMissingStub: _FakeDataType_5(this, Invocation.getter(#dataType)), + ) + as _i4.DataType); + + @override + set $type(String? value) => super.noSuchMethod(Invocation.setter(#$type, value), returnValueForMissingStub: null); + + @override + Map toJson() => + (super.noSuchMethod( + Invocation.method(#toJson, []), + returnValue: {}, + returnValueForMissingStub: {}, + ) + as Map); + + @override + bool equivalentTo(_i4.Data? other) => + (super.noSuchMethod( + Invocation.method(#equivalentTo, [other]), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); } /// A class which mocks [Measurement]. /// /// See the documentation for Mockito's code generation for more information. -class MockMeasurement extends _i1.Mock implements _i3.Measurement { - @override - int get sensorStartTime => (super.noSuchMethod( - Invocation.getter(#sensorStartTime), - returnValue: 0, - returnValueForMissingStub: 0, - ) as int); - - @override - _i3.Data get data => (super.noSuchMethod( - Invocation.getter(#data), - returnValue: _FakeData_2( - this, - Invocation.getter(#data), - ), - returnValueForMissingStub: _FakeData_2( - this, - Invocation.getter(#data), - ), - ) as _i3.Data); - - @override - _i3.DataType get dataType => (super.noSuchMethod( - Invocation.getter(#dataType), - returnValue: _FakeDataType_7( - this, - Invocation.getter(#dataType), - ), - returnValueForMissingStub: _FakeDataType_7( - this, - Invocation.getter(#dataType), - ), - ) as _i3.DataType); - - @override - set sensorStartTime(int? _sensorStartTime) => super.noSuchMethod( - Invocation.setter( - #sensorStartTime, - _sensorStartTime, - ), - returnValueForMissingStub: null, - ); - - @override - set sensorEndTime(int? _sensorEndTime) => super.noSuchMethod( - Invocation.setter( - #sensorEndTime, - _sensorEndTime, - ), - returnValueForMissingStub: null, - ); - - @override - set taskControl(_i3.TaskControl? _taskControl) => super.noSuchMethod( - Invocation.setter( - #taskControl, - _taskControl, - ), - returnValueForMissingStub: null, - ); - - @override - set data(_i3.Data? _data) => super.noSuchMethod( - Invocation.setter( - #data, - _data, - ), - returnValueForMissingStub: null, - ); - - @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); +class MockMeasurement extends _i1.Mock implements _i4.Measurement { + @override + int get sensorStartTime => + (super.noSuchMethod(Invocation.getter(#sensorStartTime), returnValue: 0, returnValueForMissingStub: 0) as int); + + @override + _i4.DataType get dataType => + (super.noSuchMethod( + Invocation.getter(#dataType), + returnValue: _FakeDataType_5(this, Invocation.getter(#dataType)), + returnValueForMissingStub: _FakeDataType_5(this, Invocation.getter(#dataType)), + ) + as _i4.DataType); + + @override + _i4.Data get data => + (super.noSuchMethod( + Invocation.getter(#data), + returnValue: _FakeData_6(this, Invocation.getter(#data)), + returnValueForMissingStub: _FakeData_6(this, Invocation.getter(#data)), + ) + as _i4.Data); + + @override + set sensorStartTime(int? value) => + super.noSuchMethod(Invocation.setter(#sensorStartTime, value), returnValueForMissingStub: null); + + @override + set sensorEndTime(int? value) => + super.noSuchMethod(Invocation.setter(#sensorEndTime, value), returnValueForMissingStub: null); + + @override + set taskControl(_i4.TaskControl? value) => + super.noSuchMethod(Invocation.setter(#taskControl, value), returnValueForMissingStub: null); + + @override + set data(_i4.Data? value) => super.noSuchMethod(Invocation.setter(#data, value), returnValueForMissingStub: null); + + @override + Map toJson() => + (super.noSuchMethod( + Invocation.method(#toJson, []), + returnValue: {}, + returnValueForMissingStub: {}, + ) + as Map); } /// A class which mocks [DataModel]. /// /// See the documentation for Mockito's code generation for more information. -class MockDataModel extends _i1.Mock implements _i4.DataModel { - @override - _i4.DataModel fromJson(Map? json) => (super.noSuchMethod( - Invocation.method( - #fromJson, - [json], - ), - returnValue: _FakeDataModel_8( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - returnValueForMissingStub: _FakeDataModel_8( - this, - Invocation.method( - #fromJson, - [json], - ), - ), - ) as _i4.DataModel); - - @override - Map toJson() => (super.noSuchMethod( - Invocation.method( - #toJson, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Map); +class MockDataModel extends _i1.Mock implements _i3.DataModel { + @override + _i3.DataModel fromJson(Map? json) => + (super.noSuchMethod( + Invocation.method(#fromJson, [json]), + returnValue: _FakeDataModel_7(this, Invocation.method(#fromJson, [json])), + returnValueForMissingStub: _FakeDataModel_7(this, Invocation.method(#fromJson, [json])), + ) + as _i3.DataModel); + + @override + Map toJson() => + (super.noSuchMethod( + Invocation.method(#toJson, []), + returnValue: {}, + returnValueForMissingStub: {}, + ) + as Map); } From 509b8b3ade2b9e9dfc591190f2aeea7a118dc575 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 11:07:36 +0200 Subject: [PATCH 48/94] fix: remove debugprints + small fix --- .vscode/launch.json | 2 +- lib/blocs/sensing.dart | 5 ----- lib/carp_study_app.dart | 8 -------- pubspec.lock | 4 ++-- pubspec.yaml | 32 ++++++++++++++++---------------- 5 files changed, 19 insertions(+), 32 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 0dafcd10..570ef544 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,7 @@ "--dart-define", "deployment-mode=dev", "--dart-define", - "debug-level=none" + "debug-level=debug" ], "flutterMode": "debug" }, diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 38b3def8..4747cc6c 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -145,11 +145,6 @@ class Sensing { translateStudyProtocol(); - // Mirror each measurement to the console only in debug to avoid spamming logs. - if (Settings().debugLevel.index >= DebugLevel.debug.index) { - controller?.measurements.listen((measurement) => debugPrint(toJsonString(measurement))); - } - info('$runtimeType - Study added, deployment id: $studyDeploymentId'); return status; } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 76cd55f9..602f4c02 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -33,14 +33,9 @@ class CarpStudyAppState extends State { errorBuilder: (context, state) => const ErrorPage(), redirect: (context, state) async { final loc = state.matchedLocation; - debugPrint( - '[redirect] loc=$loc auth=${bloc.backend.isAuthenticated} ' - 'studyDeployed=${bloc.hasStudyBeenDeployed}', - ); // 1) Not authenticated → login page. if (bloc.deploymentMode != DeploymentMode.local && !bloc.backend.isAuthenticated) { - debugPrint('[redirect] → /login (not authenticated)'); return LoginPage.route; } @@ -48,15 +43,12 @@ class CarpStudyAppState extends State { // details page). Anywhere else gets bounced to the list. if (!bloc.hasStudyBeenDeployed) { if (loc == InvitationListPage.route || loc.startsWith('${InvitationDetailsPage.route}/')) { - debugPrint('[redirect] → null (already on invitation route)'); return null; } - debugPrint('[redirect] → /invitations (no study)'); return InvitationListPage.route; } // 3) Fully onboarded. - debugPrint('[redirect] → null (fully onboarded)'); return null; }, routes: [ diff --git a/pubspec.lock b/pubspec.lock index 96460f1f..af1159ef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1753,10 +1753,10 @@ packages: dependency: transitive description: name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" url: "https://pub.dev" source: hosted - version: "0.28.0" + version: "0.27.7" sample_statistics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61be3ff1..3cceb515 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,30 +73,30 @@ dependency_overrides: # path: ../carp.sensing-flutter/carp_mobile_sensing/ # carp_context_package: # path: ../carp.sensing-flutter/packages/carp_context_package/ + # carp_esense_package: + # path: ../carp.sensing-flutter/packages/carp_esense_package/ + # carp_health_package: + # path: ../carp.sensing-flutter/packages/carp_health_package/ + # carp_polar_package: + # path: ../carp.sensing-flutter/packages/carp_polar_package/ + # carp_movisens_package: + # path: ../carp.sensing-flutter/packages/carp_movisens_package/ + # carp_movesense_package: + # path: ../carp.sensing-flutter/packages/carp_movesense_package/ # carp_connectivity_package: - # path: ../../packages/carp_connectivity_package/ + # path: ../carp.sensing-flutter/packages/carp_connectivity_package/ # carp_survey_package: - # path: ../carp/carp.sensing-flutter/packages/carp_survey_package/ + # path: ../carp.sensing-flutter/packages/carp_survey_package/ # carp_communication_package: - # path: ../../packages/carp_communication_package/ + # path: ../carp.sensing-flutter/packages/carp_communication_package/ # carp_audio_package: - # path: ../carp/carp.sensing-flutter/packages/carp_audio_package/ + # path: ../carp.sensing-flutter/packages/carp_audio_package/ # carp_apps_package: - # path: ../../packages/carp_apps_package/ - # carp_esense_package: - # path: ../../packages/carp_esense_package/ - # carp_polar_package: - # path: ../carp/carp.sensing-flutter/packages/carp_polar_package/ - # carp_movisens_package: - # path: ../../packages/carp_movisens_package/ - # carp_health_package: - # path: ../carp/carp.sensing-flutter/packages/carp_health_package/ - # carp_movesense_package: - # path: ../carp/carp.sensing-flutter/packages/carp_movesense_package/ + # path: ../carp.sensing-flutter/packages/carp_apps_package/ # carp_webservices: # path: ../carp.sensing-flutter/backends/carp_webservices/ # carp_backend: - # path: ../carp/carp.sensing-flutter/backends/carp_backend/ + # path: ../carp.sensing-flutter/backends/carp_backend/ # research_package: # path: ../research.package/ # cognition_package: From e47a1e483346d6dcf39771a6553da3a7cbd8f1b8 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 12:28:58 +0200 Subject: [PATCH 49/94] refactor(bloc): split StudyAppBLoC into coordinator and services Decompose the 477-line god-object into a slim coordinator plus focused, unit-testable services (AppConfig, ResourceManagerFactory, AuthService, StudyService, MessageService, ConsentService, SystemInfoService) and break the bloc <-> Sensing/CarpBackend dependency cycle via the dependency-free AppConfig. Also fixes the flow-correctness bugs living in the moved code: - configureStudy is now atomic and retryable; a transient deploy failure no longer bricks the app in the configuring state, and the failure is surfaced to the user - sensing only starts when the device deployment has actually been received (study.isDeployed), checked at runtime in release builds; note that in CAMS 2.x Study.status never yields StudyStatus.Deployed (success maps to Running), so the two existing checks against it could never pass - protocol translation and pull-to-refresh restart now work again - the deploy -> verify -> start sequence is one guarded path instead of being smeared across the bloc, home_page, and sensing - StudyService is the single owner of the active study; the persisted and CAWS-service copies are seeded from its setter - the 30-min message polling timer is owned by MessageService and is cancelled on leaveStudy/dispose; the message stream is closed on dispose - username no longer force-unwraps the user (null-crash on sign-out) - duplicate DataManagerRegistry registration removed Deletes dead bloc members (installedApps, hasDevices, runningProbes, stop, addError, usingLocationPermissions) and constructor-injects services into the hot view models, with first unit tests for the new services and view models. Refs #597, #599 --- assets/lang/da.json | 3 +- assets/lang/en.json | 3 +- assets/lang/es.json | 3 +- lib/blocs/app_bloc.dart | 429 ++++-------------- lib/blocs/app_config.dart | 52 +++ lib/blocs/sensing.dart | 37 +- lib/carp_study_app.dart | 10 +- lib/data/carp_backend.dart | 7 +- lib/main.dart | 8 + lib/services/auth_service.dart | 44 ++ lib/services/consent_service.dart | 53 +++ lib/services/message_service.dart | 56 +++ lib/services/resource_manager_factory.dart | 28 ++ lib/services/study_service.dart | 169 +++++++ lib/services/system_info_service.dart | 44 ++ lib/ui/pages/data_visualization_page.dart | 18 +- lib/ui/pages/device_list_page.dart | 6 +- .../devices_page.health_service_connect.dart | 2 +- lib/ui/pages/home_page.dart | 27 +- lib/ui/pages/informed_consent_page.dart | 2 +- lib/ui/pages/invitation_list_page.dart | 4 +- lib/ui/pages/login_page.dart | 10 +- lib/ui/pages/message_details_page.dart | 2 +- lib/ui/pages/profile_page.dart | 2 +- lib/ui/pages/qr_scanner.dart | 2 +- lib/ui/pages/study_page.dart | 14 +- lib/ui/pages/task_list_page.dart | 2 +- lib/ui/tasks/participant_data_page.dart | 2 +- .../data_visualization_page_model.dart | 5 +- .../informed_consent_page_model.dart | 6 +- lib/view_models/invitations_view_model.dart | 2 +- .../participant_data_page_model.dart | 2 +- lib/view_models/profile_page_model.dart | 42 +- lib/view_models/study_page_model.dart | 50 +- lib/view_models/tasklist_page_model.dart | 4 +- lib/view_models/user_tasks.dart | 2 +- test/services_test.dart | 273 +++++++++++ test/services_test.mocks.dart | 188 ++++++++ test/test_utils.dart | 42 ++ test/view_models_test.dart | 86 ++++ 40 files changed, 1272 insertions(+), 469 deletions(-) create mode 100644 lib/blocs/app_config.dart create mode 100644 lib/services/auth_service.dart create mode 100644 lib/services/consent_service.dart create mode 100644 lib/services/message_service.dart create mode 100644 lib/services/resource_manager_factory.dart create mode 100644 lib/services/study_service.dart create mode 100644 lib/services/system_info_service.dart create mode 100644 test/services_test.dart create mode 100644 test/services_test.mocks.dart create mode 100644 test/test_utils.dart create mode 100644 test/view_models_test.dart diff --git a/assets/lang/da.json b/assets/lang/da.json index 1d44c86c..94ae30a2 100644 --- a/assets/lang/da.json +++ b/assets/lang/da.json @@ -216,5 +216,6 @@ "tasks.participant_data.phone_number.title": "Tilføj telefonnummer", "tasks.participant_data.phone_number.country": "Landekode", "tasks.participant_data.phone_number.phone_number": "Telefonnummer", - "tasks.participant_data.review.title": "Gennemse" + "tasks.participant_data.review.title": "Gennemse", + "pages.home.setup_failed": "Studiet kunne ikke konfigureres. Tjek din internetforbindelse og prøv igen." } \ No newline at end of file diff --git a/assets/lang/en.json b/assets/lang/en.json index 1dce60f2..7bad5d4a 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -243,5 +243,6 @@ "tasks.participant_data.phone_number.title": "Add phone number", "tasks.participant_data.phone_number.country": "Country code", "tasks.participant_data.phone_number.phone_number": "Phone No.", - "tasks.participant_data.review.title": "Review" + "tasks.participant_data.review.title": "Review", + "pages.home.setup_failed": "Could not set up the study. Please check your internet connection and try again." } \ No newline at end of file diff --git a/assets/lang/es.json b/assets/lang/es.json index b9bf74db..aac23e65 100644 --- a/assets/lang/es.json +++ b/assets/lang/es.json @@ -121,5 +121,6 @@ "pages.devices.connection.step.start.3": "Si el dispositivo no está en la lista, asegúrese de que esté cargado y encendido. Leer más en las 'Instrucciones'.", "pages.devices.connection.step.confirm.title": "está conectado!", "pages.devices.connection.step.confirm.1": "El", - "pages.devices.connection.step.confirm.2": " ha sido connectado satisfactoriamente al estudio y está listo para empezar a detectar." + "pages.devices.connection.step.confirm.2": " ha sido connectado satisfactoriamente al estudio y está listo para empezar a detectar.", + "pages.home.setup_failed": "No se pudo configurar el estudio. Comprueba tu conexión a internet e inténtalo de nuevo." } \ No newline at end of file diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 55b7a8f8..ec1fa906 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -15,45 +15,42 @@ enum StudyAppState { configured, } -/// How to deploy a study. -enum DeploymentMode { - /// Use a local study protocol & deployment and store data locally on the phone. - local, - - /// Use the CAWS production server to get the study deployment and store data. - production, - - /// Use the CAWS test server to get the study deployment and store data. - test, - - /// Use the CAWS development server to get the study deployment and store data. - dev, -} - -/// The main Business Logic Component (BLoC) for the entire app. +/// The coordinator for the entire app. /// /// Works as a singleton and can always be accessed via the global `bloc` /// variable. /// -/// Works as a [ChangeNotifier] and will notify its listeners on important -/// changes. Is also stateful and has a [state] and state changes are propagated -/// through the [stateStream]. +/// Holds the app state machine and the focused services doing the actual +/// work ([config], [auth], [study], [messages], [consent], [system], +/// [resources]). Orchestration that spans several services - like +/// [configureStudy] and [leaveStudy] - lives here. /// -/// The BLoC is configured using two environment variables: -/// -/// * `deployment-mode` set the [DeploymentMode]. -/// * `debug-level` set the [DebugLevel]. -/// -/// In Flutter these environment variables are set by specifying the `--dart-define` -/// option in `flutter run`. For example: -/// -/// `flutter run --dart-define=deployment-mode=local,debug-level=info` +/// Works as a [ChangeNotifier] and will notify its listeners (incl. the +/// router) on important changes. class StudyAppBLoC extends ChangeNotifier { StudyAppState _state = StudyAppState.created; - final CarpBackend _backend = CarpBackend(); final CarpStudyAppViewModel _appViewModel = CarpStudyAppViewModel(); - List _messages = []; - final StreamController _messageStreamController = StreamController.broadcast(); + + /// App-wide configuration (deployment mode, debug level, localization). + final AppConfig config = AppConfig(); + + /// The resource managers matching the current deployment mode. + late final ResourceManagerFactory resources = ResourceManagerFactory(config: config); + + /// Device- and platform-level checks. + late final SystemInfoService system = SystemInfoService(); + + /// User identity and authentication. + late final AuthService auth = AuthService(); + + /// The study running on this phone and its deployment. + late final StudyService study = StudyService(config: config, resources: resources); + + /// The messages shown in the app, kept refreshed by polling. + late final MessageService messages = MessageService(resources.messageManager); + + /// The informed consent flow. + late final ConsentService consent = ConsentService(resources.informedConsentManager, study, config: config); /// The state of this BloC. StudyAppState get state => _state; @@ -62,189 +59,49 @@ class StudyAppBLoC extends ChangeNotifier { bool get isConfiguring => _state.index >= StudyAppState.configuring.index; bool get isConfigured => _state.index >= StudyAppState.configured.index; - /// Debug level for the app and CAMS. - DebugLevel debugLevel = DebugLevel.info; - - /// What kind of deployment are we running? - DeploymentMode deploymentMode = DeploymentMode.production; - - /// The localization (language)) of this app. - RPLocalizations? localization; - - /// The list of currently available messages. - List get messages => _messages; - - /// A stream of event when the list of [messages] is updated. - /// The data send on the stream is the number of available messages. - Stream get messageStream => _messageStreamController.stream; + /// The overall data model for this app + CarpStudyAppViewModel get appViewModel => _appViewModel; // ScaffoldMessenger for showing snack bars final _scaffoldKey = GlobalKey(); GlobalKey get scaffoldKey => _scaffoldKey; - State? get scaffoldMessengerState => scaffoldKey.currentState; /// Create the BLoC for the app. StudyAppBLoC() : super() { - const dep = String.fromEnvironment('deployment-mode', defaultValue: 'production'); - deploymentMode = DeploymentMode.values.where((element) => element.name == dep).first; - - const deb = String.fromEnvironment('debug-level', defaultValue: 'info'); - debugLevel = DebugLevel.values.where((element) => element.name == deb).first; - info( '$runtimeType created. ' - 'DeploymentMode: ${deploymentMode.name}, ' - 'DebugLevel: ${debugLevel.name}', + 'DeploymentMode: ${config.deploymentMode.name}, ' + 'DebugLevel: ${config.debugLevel.name}', ); - } - - LocalizationManager get localizationManager => - (deploymentMode == DeploymentMode.local ? LocalResourceManager() : CarpResourceManager()) as LocalizationManager; - LocalizationLoader get localizationLoader { - debug('$runtimeType - using localizationManager: $localizationManager'); - return ResourceLocalizationLoader(localizationManager); + // The coordinator is the sole router-notifier - forward service changes. + consent.addListener(notifyListeners); } - MessageManager get messageManager => - (deploymentMode == DeploymentMode.local ? LocalResourceManager() : CarpResourceManager()) as MessageManager; - - InformedConsentManager get informedConsentManager => - (bloc.deploymentMode == DeploymentMode.local ? LocalResourceManager() : CarpResourceManager()) - as InformedConsentManager; - - ParticipationService get participationService => - (bloc.deploymentMode == DeploymentMode.local ? LocalParticipationService() : CarpParticipationService()); - - CarpBackend get backend => _backend; - - /// The study running on this phone. - /// Typical set based on an invitation. - /// `null` if no deployment have been specified. - SmartphoneStudy? get study => LocalSettings().study; - set study(SmartphoneStudy? study) => LocalSettings().study = study; - - /// Has a study been deployed on this phone? - bool get hasStudyBeenDeployed => study != null; - - /// The deployment running on this phone. - SmartphoneDeployment? get deployment => Sensing().controller?.deployment; - - Set get expectedParticipantData => deployment?.expectedParticipantData ?? {}; - - /// Get the status for the current study deployment. - /// Returns null if the study is not yet deployed on this phone. - Future get studyDeploymentStatus async => await Sensing().getStudyDeploymentStatus(); - - /// When was this study deployed on this phone. - DateTime? get studyStartTimestamp => deployment?.deployed; - - /// The overall data model for this app - CarpStudyAppViewModel get appViewModel => _appViewModel; - - final appCheck = AppCheck(); - - List? installedApps; - /// Initialize this BLOC. Called before being used for anything. Future initialize() async { if (isInitialized) return; - Settings().debugLevel = debugLevel; + Settings().debugLevel = config.debugLevel; await Settings().init(); CarpResourceManager().initialize(); Sensing(); - // Initialize and use the CAWS backend if not in local deployment mode - if (deploymentMode != DeploymentMode.local) { - if (await checkConnectivity()) { - await backend.initialize(); + if (config.deploymentMode != DeploymentMode.local) { + // Initialize and use the CAWS backend if not in local deployment mode + if (await system.checkConnectivity()) { + await auth.initialize(); } - } - - // Deploy the local protocol if running in local mode - if (deploymentMode == DeploymentMode.local) { - await deployLocalProtocol(); + } else { + // Deploy the local protocol if running in local mode + await study.deployLocalProtocol(); } _state = StudyAppState.initialized; notifyListeners(); - debug('$runtimeType initialized - deployment mode: ${deploymentMode.name}'); - } - - /// Is the phone connected to the internet either via wifi or mobile network? - Future checkConnectivity() async { - final List results = await (Connectivity().checkConnectivity()); - - return results.any((element) => element == ConnectivityResult.mobile || element == ConnectivityResult.wifi); - } - - /// Check if the Health database is installed on this phone. - /// - /// Always returns true on iOS, since Health is part of the OS and hence always installed. - /// On Android, returns true if Google Health Connect is installed, false otherwise. - Future isHealthInstalled() async { - if (Platform.isIOS) return true; - - try { - return await appCheck.isAppInstalled(LocalSettings.healthConnectPackageName); - } catch (e) { - debug("$runtimeType - Error checking Health Connect installation: $e"); - return false; - } - } - - Future getAppHasUpdate() async { - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - AppVersionResult result = await AppVersionUpdate.checkForUpdates( - playStoreId: packageInfo.packageName, - appleId: '1569798025', - country: 'dk', - ); - return result.canUpdate; - } - - /// Deploy the local protocol if running in local mode. - /// - /// We can run the app in local mode to debug a local protocol stored in - /// assets/carp/resources/protocol.json - /// - /// This method will deploy the protocol in the local SmartphoneDeploymentService - /// which later will be used for deployment. See [Sensing.deploymentService]. - Future deployLocalProtocol() async { - if (deploymentMode != DeploymentMode.local) return; - - if (hasStudyBeenDeployed) { - info( - 'Running in local deployment mode. Note that the local protocol has ' - 'already been deployed and the cached version will be loaded and used. ' - 'If you want to reload a modified protocol, delete the app with the ' - 'cached protocol from the phone before running it.', - ); - } else { - debug('$runtimeType - deploying local protocol'); - - // Get the protocol from the local study protocol manager. - // Note that the study id is not used since it always returns the same protocol. - var protocol = await LocalResourceManager().getStudyProtocol(''); - - // Deploy this protocol using the on-phone deployment service. - final status = await SmartphoneDeploymentService().createStudyDeployment(protocol!); - - // The primary device (the smartphone) is found in the device status list. - final primaryDeviceRoleName = status.deviceStatusList - .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) - .device - .roleName; - - // Save the participant and study on the phone for use across app restart. - var participant = Participant(studyDeploymentId: status.studyDeploymentId, deviceRoleName: primaryDeviceRoleName); - LocalSettings().participant = participant; - - bloc.study = SmartphoneStudy(studyDeploymentId: status.studyDeploymentId, deviceRoleName: primaryDeviceRoleName); - } + debug('$runtimeType initialized - deployment mode: ${config.deploymentMode.name}'); } /// Set the active study in the app based on an [invitation]. @@ -253,193 +110,59 @@ class StudyAppBLoC extends ChangeNotifier { /// and applied in the app. void setStudyInvitation(ActiveParticipationInvitation invitation, [BuildContext? context]) { // create and save the participant info based on this invitation - var participant = Participant.fromParticipationInvitation(invitation); - LocalSettings().participant = participant; - - LocalSettings().study = SmartphoneStudy.fromInvitation(invitation); + LocalSettings().participant = Participant.fromParticipationInvitation(invitation); - // make sure that the CAWS backend services are configured with the study + // save the study; this also seeds the CAWS backend services with it // in order to access the correct resources (like translations etc.). - backend.study = study!; + study.study = SmartphoneStudy.fromInvitation(invitation); - // And the re-initialize the resource manager. + // And then re-initialize the resource manager. CarpResourceManager().initialize(); notifyListeners(); - info('Invitation received - study: $study'); + info('Invitation received - study: ${study.study}'); if (context != null) CarpStudyApp.reloadLocale(context); } - /// This methods is used to configure the [study] deployment. + /// Configure the study deployment and start sensing. /// /// This includes: - /// * initialize sensing - /// * adding the CAMS study - /// * setting up messaging + /// * initialize sensing and deploy the study /// * initializing the data visualization pages + /// * setting up messaging + /// * starting sensing (only if the deployment succeeded) + /// + /// If configuration fails (e.g., no network), the state is reset so this + /// method can be called again, and the error is rethrown for the caller + /// to surface. Future configureStudy() async { - // early out if already configured - if (isConfiguring) return; + // early out if already configuring or configured + if (_state == StudyAppState.configuring || isConfigured) return; _state = StudyAppState.configuring; - await Sensing().initialize(); - - // backend.study is set in backend.initialize() (cached study on cold start) - // and in setStudyInvitation() (new invitation flow). No need to re-assign. - - await Sensing().addStudy(); + try { + await study.configure(); + } catch (error) { + _state = StudyAppState.initialized; + warning('$runtimeType - Study configuration failed - $error'); + rethrow; + } appViewModel.init(Sensing().controller!); - messageManager.initialize(); - refreshMessages(); - - Timer.periodic(const Duration(minutes: 30), (_) => refreshMessages()); + messages.start(); info('Study configuration done.'); _state = StudyAppState.configured; notifyListeners(); - } - - Future> getParticipantDataListFromDeployment() async => - (deployment == null) ? [] : await participationService.getParticipantDataList([deployment!.studyDeploymentId]); - - /// Set the participant data for this study. - void setParticipantData(String studyDeploymentId, Map data, [String? inputByParticipantRole]) => - participationService.setParticipantData(studyDeploymentId, data, inputByParticipantRole); - - /// Does this app use location permissions? - bool get usingLocationPermissions => true; - - /// Get the informed consent for this study. - Future getInformedConsent({bool refresh = false}) => - informedConsentManager.getConsentDocument(refresh: refresh); - - /// Has the informed consent been accepted by the user? - /// - /// 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 get hasInformedConsentBeenAccepted async { - if (deploymentMode == DeploymentMode.local || study == null) { - return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? 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'); - 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 informedConsentHasBeenAccepted([RPTaskResult? result]) async { - info('Informed consent has been accepted by user.'); - var participant = LocalSettings().participant; - participant?.hasInformedConsentBeenAccepted = true; - LocalSettings().participant = participant; - if (result != null && deploymentMode != DeploymentMode.local) { - await backend.uploadInformedConsent(result); - } - notifyListeners(); - } - - /// Refresh the list of messages (news, announcements, articles) to be shown in - /// the Study Page of the app. - Future refreshMessages() async { - try { - _messages.clear(); - _messages = await messageManager.getMessages(); - _messages.sort((m1, m2) => m2.timestamp.compareTo(m1.timestamp)); - info('Message list refreshed - count: ${_messages.length}'); - } catch (error) { - warning('Error getting messages - $error'); - } - _messageStreamController.add(_messages.length); - } - - /// The signed in user. Returns null if no user is signed in. - CarpUser? get user => backend.user; - /// The username of the user running this study. - /// Returns an empty string if no user logged in. - String? get username => user!.username; - - /// The name used for friendly greeting. - /// Returns an empty string if no user logged in. - String? get friendlyUsername => (user != null) ? user!.firstName : ''; - - /// Does this [deployment] have any measures? - bool hasMeasures() => (deployment == null) - ? false - : (deployment!.measures.any( - (measure) => - (measure.type != AppTask.VIDEO_TYPE && - measure.type != AppTask.IMAGE_TYPE && - measure.type != AppTask.AUDIO_TYPE && - measure.type != AppTask.SURVEY_TYPE), - )); - - /// Does this [deployment] have the measure of type [type]? - bool hasMeasure(String type) { - if (deployment == null) return false; - - try { - deployment?.measures.firstWhere((measure) => measure.type == type); - } catch (_) { - return false; - } - - return true; - } - - /// Does this [deployment] have any user tasks? - bool hasUserTasks() => (deployment == null) ? false : deployment!.tasks.whereType().isNotEmpty; - - /// Does this [deployment] have any connected devices? - bool hasDevices() => (deployment == null) ? false : deployment!.connectedDevices.isNotEmpty; - - /// Is sensing running, i.e. has the study executor been resumed? - bool get isRunning => Sensing().isRunning; - - /// the list of running - i.e. used - probes in this study. - List get runningProbes => (Sensing().controller != null) ? Sensing().controller!.executor.probes : []; - - DeploymentService get deploymentService => Sensing().deploymentService; - - /// The list of all devices in this deployment. - Iterable get deploymentDevices => - Sensing().deploymentDevices.map((device) => DeviceViewModel(device)); - - /// Start sensing. - Future start() async { - assert(Sensing().controller != null, 'No Study Controller - the study has not been deployed.'); - if (!Sensing().isRunning) Sensing().controller?.resume(); - } - - /// Stop sensing. - void stop() => Sensing().controller?.pause(); - - /// Dispose the entire sensing. - @override - void dispose() { - super.dispose(); - Sensing().controller?.dispose(); + await study.start(); } - /// Add [measurement] to the stream of collected measurements. - void addMeasurement(Measurement measurement) => Sensing().controller?.executor.addMeasurement(measurement); - - /// Add [error] to the stream of measurements. - void addError(Object error, [StackTrace? stacktrace]) => Sensing().controller?.executor.addError(error, stacktrace); - /// Leave the study deployed on this phone. /// /// This entails @@ -452,14 +175,14 @@ class StudyAppBLoC extends ChangeNotifier { /// phone. If the same deployment is re-deployed on the phone, data from the /// previous deployment will NOT be available. Future leaveStudy() async { - info('Leaving study $study'); + info('Leaving study ${study.study}'); - // clear the UI data models + // clear the UI data models and stop message polling appViewModel.clear(); + messages.stop(); // stop sensing and remove all deployment info - await Sensing().removeStudy(); - await LocalSettings().eraseStudyDeployment(); + await study.remove(); _state = StudyAppState.initialized; notifyListeners(); @@ -471,7 +194,15 @@ class StudyAppBLoC extends ChangeNotifier { /// deleting all user authentication information from this phone, including /// the authentication and refresh tokens. Future signOutAndLeaveStudy() async { - await backend.signOut(); + await auth.signOut(); await leaveStudy(); } + + /// Dispose the entire sensing. + @override + void dispose() { + messages.dispose(); + Sensing().controller?.dispose(); + super.dispose(); + } } diff --git a/lib/blocs/app_config.dart b/lib/blocs/app_config.dart new file mode 100644 index 00000000..f3f09e65 --- /dev/null +++ b/lib/blocs/app_config.dart @@ -0,0 +1,52 @@ +part of carp_study_app; + +/// How to deploy a study. +enum DeploymentMode { + /// Use a local study protocol & deployment and store data locally on the phone. + local, + + /// Use the CAWS production server to get the study deployment and store data. + production, + + /// Use the CAWS test server to get the study deployment and store data. + test, + + /// Use the CAWS development server to get the study deployment and store data. + dev, +} + +/// App-wide configuration parsed from compile-time environment variables. +/// +/// Deliberately dependency-free so that lower layers ([Sensing], [CarpBackend]) +/// can read configuration without depending on the global [bloc]. +/// +/// The configuration is set using two environment variables: +/// +/// * `deployment-mode` sets the [DeploymentMode]. +/// * `debug-level` sets the [DebugLevel]. +/// +/// In Flutter these environment variables are set by specifying the `--dart-define` +/// option in `flutter run`. For example: +/// +/// `flutter run --dart-define=deployment-mode=local,debug-level=info` +class AppConfig { + static final AppConfig _instance = AppConfig._(); + factory AppConfig() => _instance; + + AppConfig._() { + const dep = String.fromEnvironment('deployment-mode', defaultValue: 'production'); + deploymentMode = DeploymentMode.values.firstWhere((mode) => mode.name == dep); + + const deb = String.fromEnvironment('debug-level', defaultValue: 'info'); + debugLevel = DebugLevel.values.firstWhere((level) => level.name == deb); + } + + /// What kind of deployment are we running? + late DeploymentMode deploymentMode; + + /// Debug level for the app and CAMS. + late DebugLevel debugLevel; + + /// The localization (language) of this app. + RPLocalizations? localization; +} diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 4747cc6c..bf1203e5 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -26,7 +26,7 @@ class Sensing { /// The deployment service used in this app. DeploymentService get deploymentService => - bloc.deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); + AppConfig().deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); /// The study running on this phone. /// Only available after [addStudy] is called. @@ -86,7 +86,9 @@ class Sensing { SamplingPackageRegistry().register(PolarSamplingPackage()); SamplingPackageRegistry().register(MovesenseSamplingPackage()); - // create and register external data managers + // Create and register external data managers. + // The CARP data manager is needed in both LOCAL and CARP deployments, + // since a local study protocol may still upload to CAWS. DataManagerRegistry().register(CarpDataManagerFactory()); // register the special-purpose audio user task factory @@ -95,16 +97,11 @@ class Sensing { /// Initialize and set up sensing. Future initialize() async { - info('Initializing $runtimeType - mode: ${bloc.deploymentMode}'); + info('Initializing $runtimeType - mode: ${AppConfig().deploymentMode}'); // Set up the devices available on this phone DeviceController().registerAllAvailableDevices(); - // Register the CARP data manager for uploading data back to CAWS. - // This is needed in both LOCAL and CARP deployments, since a local study - // protocol may still upload to CAWS - DataManagerRegistry().register(CarpDataManagerFactory()); - // Create and configure a client manager for this phone await SmartPhoneClientManager().configure( deploymentService: deploymentService, @@ -117,15 +114,14 @@ class Sensing { info('$runtimeType initialized'); } - /// Add the study to the client manager and deploy it. - Future addStudy() async { + /// Add the [study] to the client manager and deploy it. + Future addStudy(SmartphoneStudy study) async { assert( SmartPhoneClientManager().isConfigured, 'The client manager is not yet configured. Call SmartPhoneClientManager().configure() before adding a study.', ); - assert(bloc.study != null, 'No study is provided. Cannot start deployment w/o a study.'); - _study = await SmartPhoneClientManager().addStudy(bloc.study!); + _study = await SmartPhoneClientManager().addStudy(study); return await tryDeployment(); } @@ -167,23 +163,24 @@ class Sensing { /// Translate the title and description of all AppTask in the study protocol /// of the current master deployment. void translateStudyProtocol([RPLocalizations? localization]) { - bloc.localization ??= localization; + final config = AppConfig(); + config.localization ??= localization; - // Fast out is no localization - if (bloc.localization == null) return; + // Fast out if no localization + if (config.localization == null) return; - // Fast out, if not configured or no protocol. - if (study?.status != StudyStatus.Deployed || controller?.deployment == null) { + // Fast out, if not deployed or no protocol. + if (!(study?.isDeployed ?? false) || controller?.deployment == null) { return; } for (var task in controller!.deployment!.tasks) { if (task is AppTask) { - task.title = bloc.localization!.translate(task.title); - task.description = bloc.localization!.translate(task.description); + task.title = config.localization!.translate(task.title); + task.description = config.localization!.translate(task.description); } } - info("$runtimeType - Study protocol translated to locale '${bloc.localization!.locale}'"); + info("$runtimeType - Study protocol translated to locale '${config.localization!.locale}'"); } } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 602f4c02..8ae47c8e 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -35,13 +35,13 @@ class CarpStudyAppState extends State { final loc = state.matchedLocation; // 1) Not authenticated → login page. - if (bloc.deploymentMode != DeploymentMode.local && !bloc.backend.isAuthenticated) { + if (bloc.config.deploymentMode != DeploymentMode.local && !bloc.auth.isAuthenticated) { return LoginPage.route; } - // 2) No study deployed → user belongs on the invitation list (or its + // 2) No study selected → user belongs on the invitation list (or its // details page). Anywhere else gets bounced to the list. - if (!bloc.hasStudyBeenDeployed) { + if (!bloc.study.hasStudy) { if (loc == InvitationListPage.route || loc.startsWith('${InvitationDetailsPage.route}/')) { return null; } @@ -158,7 +158,7 @@ class CarpStudyAppState extends State { /// Research Package translations, incl. both local language assets plus /// translations of informed consent and surveys downloaded from CARP final RPLocalizationsDelegate rpLocalizationsDelegate = RPLocalizationsDelegate( - loaders: [const AssetLocalizationLoader(), bloc.localizationLoader], + loaders: [const AssetLocalizationLoader(), bloc.resources.localizationLoader], ); @override @@ -191,7 +191,7 @@ class CarpStudyAppState extends State { } return supportedLocales.first; // default to EN }, - locale: bloc.localization?.locale, + locale: bloc.config.localization?.locale, theme: carpTheme.copyWith( extensions: [carpTheme.extension()!.copyWith(primary: studyAppColors?.primary)], ), diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index 0371a9d0..5bbc117e 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -28,13 +28,14 @@ class CarpBackend { } /// The URI of the CAWS server - depending on deployment mode. - Uri get uri => Uri(scheme: 'https', host: uris[bloc.deploymentMode]); + Uri get uri => Uri(scheme: 'https', host: uris[AppConfig().deploymentMode]); /// The URI of the CAWS authentication service. /// /// Of the form: /// https://dev.carp.dk/auth/realms/Carp/ - Uri get authUri => Uri(scheme: 'https', host: uris[bloc.deploymentMode], pathSegments: ['auth', 'realms', 'Carp']); + Uri get authUri => + Uri(scheme: 'https', host: uris[AppConfig().deploymentMode], pathSegments: ['auth', 'realms', 'Carp']); /// The CAWS app configuration. late final CarpApp _app = CarpApp(name: "CAWS @ DTU", uri: uri); @@ -207,7 +208,7 @@ class CarpBackend { info( '$runtimeType - Informed consent document uploaded successfully for ' - 'deployment id: ${bloc.study?.studyDeploymentId}', + 'deployment id: ${LocalSettings().study?.studyDeploymentId}', ); } on Exception { warning('$runtimeType - Informed consent upload failed for username: $username'); diff --git a/lib/main.dart b/lib/main.dart index a09457f5..dd38a392 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,9 +60,17 @@ 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/util.dart'; part 'blocs/sensing.dart'; +part 'services/resource_manager_factory.dart'; +part 'services/system_info_service.dart'; +part 'services/auth_service.dart'; +part 'services/study_service.dart'; +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'; diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 00000000..4ac2bf2f --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,44 @@ +part of carp_study_app; + +/// User identity and authentication, wrapping the [CarpBackend] so the rest +/// of the app does not depend on the CAWS SDK types directly. +class AuthService { + AuthService({CarpBackend? backend}) : _backend = backend ?? CarpBackend(); + + final CarpBackend _backend; + + /// Initialize the CAWS backend. Must be called before authentication. + Future initialize() => _backend.initialize(); + + /// Has the user been authenticated? + bool get isAuthenticated => _backend.isAuthenticated; + + /// The signed in user. Returns null if no user is signed in. + CarpUser? get user => _backend.user; + + /// The username of the signed in user. + /// Returns an empty string if no user is signed in. + String get username => user?.username ?? ''; + + /// The name used for friendly greeting. + /// Returns an empty string if no user is signed in. + String get friendlyUsername => user?.firstName ?? ''; + + /// The URI of the CAWS server used in this deployment. + Uri get serverUri => _backend.uri; + + /// The list of invitations for this user, as last fetched by [getInvitations]. + List get invitations => _backend.invitations; + + /// Get / refresh the list of active invitations for this user from CAWS. + Future> getInvitations() => _backend.getInvitations(); + + /// Authenticate using a web view. + Future authenticate() => _backend.authenticate(); + + /// Authenticate anonymously using a magic link. + Future authenticateWithMagicLink(String uri) => _backend.authenticateWithMagicLink(uri); + + /// Sign out from CAWS and erase all local authentication information. + Future signOut() => _backend.signOut(); +} diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart new file mode 100644 index 00000000..e112bda6 --- /dev/null +++ b/lib/services/consent_service.dart @@ -0,0 +1,53 @@ +part of carp_study_app; + +/// Manages the informed consent flow: fetching the consent document, checking +/// whether consent has been given, and accepting consent. +/// +/// Notifies its listeners when consent is accepted. Listen via the global +/// [bloc], which chains service notifications to the router. +class ConsentService extends ChangeNotifier { + ConsentService(this._manager, this._studyService, {AppConfig? config, CarpBackend? backend}) + : _config = config ?? AppConfig(), + _backend = backend ?? CarpBackend(); + + final InformedConsentManager _manager; + final StudyService _studyService; + final AppConfig _config; + final CarpBackend _backend; + + /// Get the informed consent document for this study. + Future getDocument({bool refresh = false}) => _manager.getConsentDocument(refresh: refresh); + + /// Has the informed consent been accepted by the user? + /// + /// 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 get hasBeenAccepted async { + final study = _studyService.study; + if (_config.deploymentMode == DeploymentMode.local || study == null) { + return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? 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'); + 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 && _config.deploymentMode != DeploymentMode.local) { + await _backend.uploadInformedConsent(result); + } + notifyListeners(); + } +} diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart new file mode 100644 index 00000000..0ce3fb4e --- /dev/null +++ b/lib/services/message_service.dart @@ -0,0 +1,56 @@ +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; + + final MessageManager _manager; + final Duration _pollingInterval; + final StreamController _streamController = StreamController.broadcast(); + List _messages = []; + Timer? _pollingTimer; + + /// The list of currently available messages, newest first. + List get messages => _messages; + + /// A stream of events when the list of [messages] is updated. + /// The data sent on the stream is the number of available messages. + Stream get stream => _streamController.stream; + + /// The message with the given [id], or null if not found. + Message? byId(String id) => _messages.where((message) => message.id == id).firstOrNull; + + /// Initialize the message manager, fetch messages, and start polling for + /// new messages on a regular basis. Safe to call more than once. + void start() { + _manager.initialize(); + refresh(); + _pollingTimer?.cancel(); + _pollingTimer = Timer.periodic(_pollingInterval, (_) => refresh()); + } + + /// Stop polling for new messages. + void stop() { + _pollingTimer?.cancel(); + _pollingTimer = null; + } + + /// Refresh the list of messages. + Future refresh() async { + try { + _messages = await _manager.getMessages(); + _messages.sort((m1, m2) => m2.timestamp.compareTo(m1.timestamp)); + info('Message list refreshed - count: ${_messages.length}'); + } catch (error) { + warning('Error getting messages - $error'); + } + if (!_streamController.isClosed) _streamController.add(_messages.length); + } + + void dispose() { + stop(); + _streamController.close(); + } +} diff --git a/lib/services/resource_manager_factory.dart b/lib/services/resource_manager_factory.dart new file mode 100644 index 00000000..d905f0f1 --- /dev/null +++ b/lib/services/resource_manager_factory.dart @@ -0,0 +1,28 @@ +part of carp_study_app; + +/// Provides the resource managers matching the current [DeploymentMode]: +/// local resources in [DeploymentMode.local], CAWS-backed resources otherwise. +/// +/// Instances are created once and cached. +class ResourceManagerFactory { + ResourceManagerFactory({AppConfig? config}) : _config = config ?? AppConfig(); + + final AppConfig _config; + + bool get _local => _config.deploymentMode == DeploymentMode.local; + + late final LocalizationManager localizationManager = + (_local ? LocalResourceManager() : CarpResourceManager()) as LocalizationManager; + + late final LocalizationLoader localizationLoader = ResourceLocalizationLoader(localizationManager); + + late final MessageManager messageManager = + (_local ? LocalResourceManager() : CarpResourceManager()) as MessageManager; + + late final InformedConsentManager informedConsentManager = + (_local ? LocalResourceManager() : CarpResourceManager()) as InformedConsentManager; + + late final ParticipationService participationService = _local + ? LocalParticipationService() + : CarpParticipationService(); +} diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart new file mode 100644 index 00000000..5a4cc550 --- /dev/null +++ b/lib/services/study_service.dart @@ -0,0 +1,169 @@ +part of carp_study_app; + +/// Manages the study running on this phone: the study descriptor, its +/// deployment, and the sensing runtime for it. +/// +/// This service is the single owner of the active study. The persisted copy +/// in [LocalSettings] and the CAWS service copies are seeded from the [study] +/// setter - do not set them directly. +class StudyService { + StudyService({AppConfig? config, ResourceManagerFactory? resources}) + : _config = config ?? AppConfig(), + _resources = resources ?? ResourceManagerFactory(); + + final AppConfig _config; + final ResourceManagerFactory _resources; + + /// The study running on this phone, typically set based on an invitation. + /// Returns null if no study has been selected (yet). + SmartphoneStudy? get study => LocalSettings().study; + + /// Set the active [study], persisting it locally and seeding the CAWS + /// services with it (in non-local deployments). + set study(SmartphoneStudy? study) { + if (study == null) return; + LocalSettings().study = study; + if (_config.deploymentMode != DeploymentMode.local) CarpBackend().study = study; + } + + /// Has a study been selected on this phone (i.e., an invitation accepted)? + /// + /// Note that this does NOT imply that the study deployment has succeeded - + /// see [isDeployed] for that. + bool get hasStudy => study != null; + + /// Has the study deployment succeeded on this phone, i.e. has the device + /// deployment been received and validated? + bool get isDeployed => deployment != null; + + /// The deployment running on this phone. + /// Returns null if the study has not (yet) been deployed. + SmartphoneDeployment? get deployment => Sensing().controller?.deployment; + + /// When was this study deployed on this phone. + DateTime? get studyStartTimestamp => deployment?.deployed; + + Set get expectedParticipantData => deployment?.expectedParticipantData ?? {}; + + /// Refresh and return the status of the current study deployment from the + /// deployment service. Returns null if no study has been deployed. + Future refreshDeploymentStatus() => Sensing().getStudyDeploymentStatus(); + + /// The last known status of the study deployment, without contacting the + /// deployment service. Use [refreshDeploymentStatus] to refresh it. + StudyDeploymentStatus? get cachedDeploymentStatus => Sensing().studyDeploymentStatus; + + /// Initialize sensing and deploy the [study] on this phone. + /// + /// Throws if no study is set, or if deployment does not succeed - in which + /// 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.'); + + await Sensing().initialize(); + final status = await Sensing().addStudy(study!); + + if (!isDeployed) throw StateError('Study deployment did not succeed - status: $status.'); + } + + /// Re-attempt deployment of the current study, e.g. on a pull-to-refresh. + /// Returns null if the study has not been added to the sensing runtime yet. + Future tryDeployment() async => Sensing().study == null ? null : await Sensing().tryDeployment(); + + /// Is sensing running, i.e. has the study executor been resumed? + bool get isRunning => Sensing().isRunning; + + /// Start sensing, if the study is deployed and not permanently stopped. + Future start() async { + final controller = Sensing().controller; + if (controller == null || !isDeployed || controller.study.status == StudyStatus.Stopped) { + warning( + '$runtimeType - Cannot start sensing - the study is not deployed ' + '(status: ${controller?.study.status}).', + ); + return; + } + if (!isRunning) controller.resume(); + } + + /// Add [measurement] to the stream of collected measurements. + void addMeasurement(Measurement measurement) => Sensing().controller?.executor.addMeasurement(measurement); + + /// The list of all devices in this deployment. + Iterable get deploymentDevices => + Sensing().deploymentDevices.map((device) => DeviceViewModel(device)); + + /// Does this [deployment] have any measures (besides app tasks)? + bool hasMeasures() => (deployment == null) + ? false + : (deployment!.measures.any( + (measure) => + (measure.type != AppTask.VIDEO_TYPE && + measure.type != AppTask.IMAGE_TYPE && + measure.type != AppTask.AUDIO_TYPE && + measure.type != AppTask.SURVEY_TYPE), + )); + + /// Does this [deployment] have the measure of type [type]? + bool hasMeasure(String type) => deployment?.measures.any((measure) => measure.type == type) ?? false; + + /// Does this [deployment] have any user tasks? + bool hasUserTasks() => (deployment == null) ? false : deployment!.tasks.whereType().isNotEmpty; + + /// Get the participant data for the current deployment. + Future> getParticipantDataListFromDeployment() async => (deployment == null) + ? [] + : await _resources.participationService.getParticipantDataList([deployment!.studyDeploymentId]); + + /// Set the participant [data] for the current study. + void setParticipantData(Map data) => + _resources.participationService.setParticipantData(study!.studyDeploymentId, data, study!.participantRoleName); + + /// Deploy the local protocol if running in local mode. + /// + /// We can run the app in local mode to debug a local protocol stored in + /// assets/carp/resources/protocol.json + /// + /// This method will deploy the protocol in the local SmartphoneDeploymentService + /// which later will be used for deployment. See [Sensing.deploymentService]. + Future deployLocalProtocol() async { + if (_config.deploymentMode != DeploymentMode.local) return; + + if (hasStudy) { + info( + 'Running in local deployment mode. Note that the local protocol has ' + 'already been deployed and the cached version will be loaded and used. ' + 'If you want to reload a modified protocol, delete the app with the ' + 'cached protocol from the phone before running it.', + ); + } else { + debug('$runtimeType - deploying local protocol'); + + // Get the protocol from the local study protocol manager. + // Note that the study id is not used since it always returns the same protocol. + var protocol = await LocalResourceManager().getStudyProtocol(''); + + // Deploy this protocol using the on-phone deployment service. + final status = await SmartphoneDeploymentService().createStudyDeployment(protocol!); + + // The primary device (the smartphone) is found in the device status list. + final primaryDeviceRoleName = status.deviceStatusList + .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) + .device + .roleName; + + // Save the participant and study on the phone for use across app restart. + LocalSettings().participant = Participant( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, + ); + study = SmartphoneStudy(studyDeploymentId: status.studyDeploymentId, deviceRoleName: primaryDeviceRoleName); + } + } + + /// Stop sensing and remove all study deployment information from this phone. + Future remove() async { + await Sensing().removeStudy(); + await LocalSettings().eraseStudyDeployment(); + } +} diff --git a/lib/services/system_info_service.dart b/lib/services/system_info_service.dart new file mode 100644 index 00000000..5b8ed606 --- /dev/null +++ b/lib/services/system_info_service.dart @@ -0,0 +1,44 @@ +part of carp_study_app; + +/// Device- and platform-level checks: connectivity, installed apps, and +/// app-store updates. +class SystemInfoService { + SystemInfoService({AppCheck? appCheck, Connectivity? connectivity}) + : _appCheck = appCheck ?? AppCheck(), + _connectivity = connectivity ?? Connectivity(); + + final AppCheck _appCheck; + final Connectivity _connectivity; + + /// Is the phone connected to the internet either via wifi or mobile network? + Future checkConnectivity() async { + final results = await _connectivity.checkConnectivity(); + return results.any((result) => result == ConnectivityResult.mobile || result == ConnectivityResult.wifi); + } + + /// Check if the Health database is installed on this phone. + /// + /// Always returns true on iOS, since Health is part of the OS and hence always installed. + /// On Android, returns true if Google Health Connect is installed, false otherwise. + Future isHealthInstalled() async { + if (Platform.isIOS) return true; + + try { + return await _appCheck.isAppInstalled(LocalSettings.healthConnectPackageName); + } catch (e) { + debug("$runtimeType - Error checking Health Connect installation: $e"); + return false; + } + } + + /// Is a newer version of this app available in the app store? + Future getAppHasUpdate() async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + AppVersionResult result = await AppVersionUpdate.checkForUpdates( + playStoreId: packageInfo.packageName, + appleId: '1569798025', + country: 'dk', + ); + return result.canUpdate; + } +} diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart index ae440c96..337d700e 100644 --- a/lib/ui/pages/data_visualization_page.dart +++ b/lib/ui/pages/data_visualization_page.dart @@ -77,43 +77,43 @@ class _DataVisualizationPageState extends State { final List widgets = []; // Show user task progress, if study has any tasks. - if (bloc.hasUserTasks()) { + if (bloc.study.hasUserTasks()) { widgets.add(StudyProgressCardWidget(widget.model.studyProgressCardDataModel)); } // Show HR if there is a POLAR or MOVESENSE device in the study - if (bloc.hasMeasure(PolarSamplingPackage.HR) || bloc.hasMeasure(MovesenseSamplingPackage.HR)) { + if (bloc.study.hasMeasure(PolarSamplingPackage.HR) || bloc.study.hasMeasure(MovesenseSamplingPackage.HR)) { widgets.add(HeartRateOuterStatefulWidget(widget.model.heartRateCardDataModel)); } // check to show surveys stats - if (bloc.hasUserTasks()) { + if (bloc.study.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 (bloc.hasMeasure(MediaSamplingPackage.AUDIO)) { + if (bloc.study.hasMeasure(MediaSamplingPackage.AUDIO)) { mediaModelsList.add(widget.model.audioCardDataModel); } - if (bloc.hasMeasure(MediaSamplingPackage.VIDEO)) { + if (bloc.study.hasMeasure(MediaSamplingPackage.VIDEO)) { mediaModelsList.add(widget.model.videoCardDataModel); } - if (bloc.hasMeasure(MediaSamplingPackage.IMAGE)) { + if (bloc.study.hasMeasure(MediaSamplingPackage.IMAGE)) { mediaModelsList.add(widget.model.imageCardDataModel); } if (mediaModelsList.isNotEmpty) { widgets.add(MediaCardWidget(mediaModelsList)); } - if (bloc.hasMeasure(CarpDataTypes.STEP_COUNT)) { + if (bloc.study.hasMeasure(CarpDataTypes.STEP_COUNT)) { widgets.add(StepsCardWidget(widget.model.stepsCardDataModel)); } - if (bloc.hasMeasure(ContextSamplingPackage.ACTIVITY)) { + if (bloc.study.hasMeasure(ContextSamplingPackage.ACTIVITY)) { widgets.add(ActivityCard(widget.model.activityCardDataModel)); } - if (bloc.hasMeasure(ContextSamplingPackage.MOBILITY)) { + if (bloc.study.hasMeasure(ContextSamplingPackage.MOBILITY)) { widgets.add(MobilityCard(widget.model.mobilityCardDataModel)); widgets.add(DistanceCard(widget.model.mobilityCardDataModel)); } diff --git a/lib/ui/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 661d5a12..7d333722 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -16,18 +16,18 @@ class DeviceListPageState extends State { StreamSubscription? bluetoothStateStream; BluetoothAdapterState? bluetoothAdapterState; - final List _smartphoneDevice = bloc.deploymentDevices + final List _smartphoneDevice = bloc.study.deploymentDevices .where((element) => element.deviceManager is SmartphoneDeviceManager) .toList(); - final List _hardwareDevices = bloc.deploymentDevices + final List _hardwareDevices = bloc.study.deploymentDevices .where( (element) => element.deviceManager is HardwareDeviceManager && element.deviceManager is! SmartphoneDeviceManager, ) .toList(); - final List _onlineServices = bloc.deploymentDevices + final List _onlineServices = bloc.study.deploymentDevices .where((element) => element.deviceManager is ServiceManager) .toList(); diff --git a/lib/ui/pages/devices_page.health_service_connect.dart b/lib/ui/pages/devices_page.health_service_connect.dart index 03ac809c..763af415 100644 --- a/lib/ui/pages/devices_page.health_service_connect.dart +++ b/lib/ui/pages/devices_page.health_service_connect.dart @@ -7,7 +7,7 @@ class HealthServiceConnectPage extends StatelessWidget { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - DeviceViewModel healthServive = bloc.deploymentDevices + DeviceViewModel healthServive = bloc.study.deploymentDevices .where((element) => element.deviceManager is ServiceManager && element.type == HealthService.DEVICE_TYPE) .first; diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 6b112e7a..c7eac66b 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -40,12 +40,12 @@ class HomePageState extends State { Future _maybeRunSetup() async { if (_setupDone || _setupInFlight || !mounted) return; - if (!bloc.hasStudyBeenDeployed) return; + if (!bloc.study.hasStudy) return; _setupInFlight = true; try { // Defer configureStudy (and its OS permission prompts) until consent. - if (!await bloc.hasInformedConsentBeenAccepted) { + if (!await bloc.consent.hasBeenAccepted) { if (!mounted) return; final loc = GoRouterState.of(context).matchedLocation; if (loc != InformedConsentPage.route) { @@ -54,23 +54,34 @@ class HomePageState extends State { return; } - _setupDone = true; - // Run setup and HC check in parallel so the HC dialog doesn't gate sensing. await Future.wait([ - bloc.configureStudy().whenComplete(() { + bloc.configureStudy().then((_) { if (mounted) CarpStudyApp.reloadLocale(context); - bloc.start(); }), if (Platform.isAndroid) _checkHealthConnectInstallation(), ]); + _setupDone = true; + } catch (error) { + // Setup stays retryable - surface the failure and try again on the + // next trigger. + warning('$runtimeType - Study setup failed - $error'); + _showSetupFailedSnackBar(); } finally { _setupInFlight = false; } } + void _showSetupFailedSnackBar() { + if (!mounted) return; + final locale = RPLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locale?.translate('pages.home.setup_failed') ?? 'Could not set up the study.')), + ); + } + Future _checkHealthConnectInstallation() async { - bool isInstalled = await bloc.isHealthInstalled(); + bool isInstalled = await bloc.system.isHealthInstalled(); if (!isInstalled && mounted) { await showDialog( context: context, @@ -85,7 +96,7 @@ class HomePageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; // Save the localization for the app - bloc.localization = locale; + bloc.config.localization = locale; // Listen for user task notification clicked in the OS AppTaskController().userTaskEvents.listen((userTask) { diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index a0f69107..e4dce5a8 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -48,7 +48,7 @@ class InformedConsentState extends State { // the study back down. if (document == null && !_submitted) { _submitted = true; - await bloc.informedConsentHasBeenAccepted(); + await bloc.consent.accept(); if (mounted) context.go(CarpStudyAppState.homeRoute); } return document; diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index 9838b1f8..fc666e3c 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -15,11 +15,11 @@ class _InvitationListPageState extends State { @override void initState() { super.initState(); - _invitationsFuture = bloc.backend.getInvitations(); + _invitationsFuture = bloc.auth.getInvitations(); } Future _refresh() async { - final next = bloc.backend.getInvitations(); + final next = bloc.auth.getInvitations(); setState(() { _invitationsFuture = next; }); diff --git a/lib/ui/pages/login_page.dart b/lib/ui/pages/login_page.dart index 1de073cb..7b8e0b22 100644 --- a/lib/ui/pages/login_page.dart +++ b/lib/ui/pages/login_page.dart @@ -55,10 +55,10 @@ class _LoginPageState extends State { decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), child: TextButton( onPressed: () async { - bool isConnected = await bloc.checkConnectivity(); + bool isConnected = await bloc.system.checkConnectivity(); if (isConnected) { - await bloc.backend.initialize(); - await bloc.backend.authenticate(); + await bloc.auth.initialize(); + await bloc.auth.authenticate(); if (context.mounted) context.go(CarpStudyAppState.homeRoute); } else { showDialog( @@ -83,12 +83,12 @@ class _LoginPageState extends State { ), ), ), - if (bloc.backend.isAuthenticated) + if (bloc.auth.isAuthenticated) TextButton( onPressed: () { showDialog(context: context, builder: (context) => const LogoutMessage()).then((value) async { if (value == true) { - await bloc.backend.signOut(); + await bloc.auth.signOut(); setState(() {}); } }); diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 61ed4dbd..7b15b0d5 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -10,7 +10,7 @@ class MessageDetailsPage extends StatelessWidget { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - Message message = bloc.messages.firstWhere( + Message message = bloc.messages.messages.firstWhere( (element) => element.id == messageId, orElse: () { return Message( diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 44365339..fff26e09 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -146,7 +146,7 @@ class ProfilePageState extends State { leading: const Icon(Icons.power_settings_new, color: CACHET.RED_1), title: locale.translate('pages.profile.log_out'), onTap: () async { - bool isConnected = await bloc.checkConnectivity(); + bool isConnected = await bloc.system.checkConnectivity(); if (isConnected) { _showLogoutConfirmationDialog(); } else { diff --git a/lib/ui/pages/qr_scanner.dart b/lib/ui/pages/qr_scanner.dart index e589bf1b..228f5511 100644 --- a/lib/ui/pages/qr_scanner.dart +++ b/lib/ui/pages/qr_scanner.dart @@ -118,7 +118,7 @@ class _QRViewExampleState extends State { final qrcode = scanData.code; if (qrcode != null && Uri.tryParse(qrcode)?.hasAbsolutePath == true) { - await bloc.backend.authenticateWithMagicLink(qrcode).then((_) { + await bloc.auth.authenticateWithMagicLink(qrcode).then((_) { context.go('/'); Navigator.of(context).pop(); }); diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 16791014..36146150 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -37,12 +37,10 @@ class StudyPageState extends State { final cards = _buildCards(context); return RefreshIndicator( onRefresh: () async { - await bloc.refreshMessages(); - final status = await Sensing().tryDeployment(); - if (status == StudyStatus.Deployed) { - bloc.start(); - } - bloc.deploymentService.getStudyDeploymentStatus(widget.model.studyDeploymentId); + await bloc.messages.refresh(); + await bloc.study.tryDeployment(); + await bloc.study.start(); + await bloc.study.refreshDeploymentStatus(); }, child: ListView.builder(itemCount: cards.length, itemBuilder: (context, index) => cards[index]), ); @@ -90,7 +88,7 @@ class StudyPageState extends State { Widget _hasUpdateCard() { RPLocalizations locale = RPLocalizations.of(context)!; return FutureBuilder( - future: bloc.getAppHasUpdate(), + future: bloc.system.getAppHasUpdate(), builder: (context, snapshot) { if (snapshot.data == true) { return StudiesMaterial( @@ -205,7 +203,7 @@ class StudyPageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return FutureBuilder( - future: bloc.studyDeploymentStatus, + future: bloc.study.refreshDeploymentStatus(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return StudiesMaterial( diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index 3c0ab44e..b0b2a40f 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -49,7 +49,7 @@ class TaskListPageState extends State with TickerProviderStateMixi void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - bloc.getParticipantDataListFromDeployment().then((value) { + bloc.study.getParticipantDataListFromDeployment().then((value) { setState(() { showParticipantDataCard = value.isEmpty; }); diff --git a/lib/ui/tasks/participant_data_page.dart b/lib/ui/tasks/participant_data_page.dart index 1d94dde8..800b864e 100644 --- a/lib/ui/tasks/participant_data_page.dart +++ b/lib/ui/tasks/participant_data_page.dart @@ -702,7 +702,7 @@ class ParticipantDataPageState extends State { } } - bloc.setParticipantData(bloc.study!.studyDeploymentId, participantData, bloc.study!.participantRoleName); + bloc.study.setParticipantData(participantData); } Future _showCancelConfirmationDialog() { diff --git a/lib/view_models/data_visualization_page_model.dart b/lib/view_models/data_visualization_page_model.dart index 047867b7..c2bc0117 100644 --- a/lib/view_models/data_visualization_page_model.dart +++ b/lib/view_models/data_visualization_page_model.dart @@ -28,8 +28,9 @@ class DataVisualizationPageViewModel extends ViewModel { Stream get userTaskEvents => AppTaskController().userTaskEvents; /// The number of days the user has been part of this study. - int get daysInStudy => - (bloc.studyStartTimestamp != null) ? DateTime.now().difference(bloc.studyStartTimestamp!).inDays + 1 : 0; + int get daysInStudy => (bloc.study.studyStartTimestamp != null) + ? DateTime.now().difference(bloc.study.studyStartTimestamp!).inDays + 1 + : 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/informed_consent_page_model.dart b/lib/view_models/informed_consent_page_model.dart index 2b84ccc7..41974450 100644 --- a/lib/view_models/informed_consent_page_model.dart +++ b/lib/view_models/informed_consent_page_model.dart @@ -15,8 +15,8 @@ class InformedConsentViewModel extends ViewModel { /// local [locale]. Future getInformedConsent(Locale locale) async { if (_informedConsent == null) { - await bloc.localizationLoader.load(locale); - _informedConsent = await bloc.getInformedConsent(); + await bloc.resources.localizationLoader.load(locale); + _informedConsent = await bloc.consent.getDocument(); } return _informedConsent; } @@ -25,5 +25,5 @@ class InformedConsentViewModel extends ViewModel { /// 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.informedConsentHasBeenAccepted(informedConsentResult); + bloc.consent.accept(informedConsentResult); } diff --git a/lib/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index 7fdc2325..44594365 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -1,7 +1,7 @@ part of carp_study_app; class InvitationsViewModel extends ViewModel { - List get invitations => bloc.backend.invitations; + List get invitations => bloc.auth.invitations; ActiveParticipationInvitation getInvitation(String invitationId) => invitations.firstWhere((invitation) => invitation.participation.participantId == invitationId); diff --git a/lib/view_models/participant_data_page_model.dart b/lib/view_models/participant_data_page_model.dart index b3a33c5b..33c59931 100644 --- a/lib/view_models/participant_data_page_model.dart +++ b/lib/view_models/participant_data_page_model.dart @@ -1,7 +1,7 @@ part of carp_study_app; class ParticipantDataPageViewModel extends ViewModel { - Set get expectedData => bloc.expectedParticipantData; + Set get expectedData => bloc.study.expectedParticipantData; late TextEditingController _address1Controller; late TextEditingController _address2Controller; diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_page_model.dart index 22d185b3..4d04946b 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -1,26 +1,34 @@ part of carp_study_app; class ProfilePageViewModel extends ViewModel { - String get userId => bloc.user?.id ?? bloc.study?.participantId ?? ''; - String get username => bloc.user?.username ?? ''; - String get firstName => bloc.user?.firstName ?? ''; - String get lastName => bloc.user?.lastName ?? ''; + ProfilePageViewModel({AuthService? authService, StudyService? studyService}) + : _authService = authService, + _studyService = studyService; + + final AuthService? _authService; + final StudyService? _studyService; + + AuthService get _auth => _authService ?? bloc.auth; + StudyService get _study => _studyService ?? bloc.study; + + String get userId => _auth.user?.id ?? _study.study?.participantId ?? ''; + String get username => _auth.username; + String get firstName => _auth.user?.firstName ?? ''; + String get lastName => _auth.user?.lastName ?? ''; String get fullName => '$firstName $lastName'; - String get email => bloc.user?.email ?? ''; + String get email => _auth.user?.email ?? ''; - String get studyId => bloc.deployment?.studyId ?? ''; - String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; - String get studyDeploymentTitle => bloc.deployment?.studyDescription?.title ?? ''; - String get participantId => bloc.study?.participantId ?? ''; - String get participantRole => bloc.study?.participantRoleName ?? ''; - String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; + String get studyId => _study.deployment?.studyId ?? ''; + String get studyDeploymentId => _study.deployment?.studyDeploymentId ?? ''; + String get studyDeploymentTitle => _study.deployment?.studyDescription?.title ?? ''; + String get participantId => _study.study?.participantId ?? ''; + String get participantRole => _study.study?.participantRoleName ?? ''; + String get deviceRole => _study.deployment?.deviceRoleName ?? ''; - String get responsibleEmail => bloc.deployment?.studyDescription?.responsible?.email ?? 'study@carp.dk'; + String get responsibleEmail => _study.deployment?.studyDescription?.responsible?.email ?? 'study@carp.dk'; String get privacyPolicyUrl => - bloc.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; - String get studyDescriptionUrl => bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; + _study.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; + String get studyDescriptionUrl => _study.deployment?.studyDescription?.studyDescriptionUrl ?? ''; String get deviceID => DeviceInfoService().deviceID ?? ''; - String get currentServer => bloc.backend.uri.toString(); - - ProfilePageViewModel(); + String get currentServer => _auth.serverUri.toString(); } diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index 29c2769a..732f6f18 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -3,35 +3,45 @@ 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. class StudyPageViewModel extends ViewModel { - String get title => bloc.deployment?.studyDescription?.title ?? 'Unnamed'; - String get description => bloc.deployment?.studyDescription?.description ?? ''; - String get purpose => bloc.deployment?.studyDescription?.purpose ?? ''; + StudyPageViewModel({StudyService? studyService, MessageService? messageService}) + : _studyService = studyService, + _messageService = messageService; + + final StudyService? _studyService; + final MessageService? _messageService; + + StudyService get _study => _studyService ?? bloc.study; + MessageService get _messages => _messageService ?? bloc.messages; + + String get title => _study.deployment?.studyDescription?.title ?? 'Unnamed'; + String get description => _study.deployment?.studyDescription?.description ?? ''; + String get purpose => _study.deployment?.studyDescription?.purpose ?? ''; Image get image => Image.asset('assets/images/exercise.png'); - String? get userID => bloc.study?.participantId; - String get studyDeploymentId => bloc.deployment?.studyDeploymentId ?? ''; - String get responsibleName => bloc.deployment?.studyDescription?.responsible?.name ?? ''; - String get responsibleEmail => bloc.deployment?.studyDescription?.responsible?.email ?? ''; - String get studyDescriptionUrl => bloc.deployment?.studyDescription?.studyDescriptionUrl ?? ''; + String? get userID => _study.study?.participantId; + String get studyDeploymentId => _study.deployment?.studyDeploymentId ?? ''; + String get responsibleName => _study.deployment?.studyDescription?.responsible?.name ?? ''; + String get responsibleEmail => _study.deployment?.studyDescription?.responsible?.email ?? ''; + String get studyDescriptionUrl => _study.deployment?.studyDescription?.studyDescriptionUrl ?? ''; String get privacyPolicyUrl => - bloc.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; + _study.deployment?.studyDescription?.privacyPolicyUrl ?? 'https://carp.dk/privacy-policy-app/'; - String get piTitle => bloc.deployment?.responsible?.title ?? ''; - String get piName => bloc.deployment?.responsible?.name ?? ''; - String get piAddress => bloc.deployment?.responsible?.address ?? ''; - String get piEmail => bloc.deployment?.responsible?.email ?? ''; + String get piTitle => _study.deployment?.responsible?.title ?? ''; + String get piName => _study.deployment?.responsible?.name ?? ''; + String get piAddress => _study.deployment?.responsible?.address ?? ''; + String get piEmail => _study.deployment?.responsible?.email ?? ''; String get piAffiliation => - bloc.deployment?.responsible?.affiliation ?? 'Department of Health Technology, Technical University of Denmark'; + _study.deployment?.responsible?.affiliation ?? 'Department of Health Technology, Technical University of Denmark'; - String get participantRole => bloc.study?.participantRoleName ?? ''; - String get deviceRole => bloc.deployment?.deviceRoleName ?? ''; + String get participantRole => _study.study?.participantRoleName ?? ''; + String get deviceRole => _study.deployment?.deviceRoleName ?? ''; - Future get studyDeploymentStatus => bloc.studyDeploymentStatus; + Future get studyDeploymentStatus => _study.refreshDeploymentStatus(); /// The stream of messages (count) - Stream get messageStream => bloc.messageStream; + Stream get messageStream => _messages.stream; /// The list of messages to be displayed. - List get messages => bloc.messages.reversed.toList(); + List get messages => _messages.messages.reversed.toList(); /// Get the image based on [imagePath]. Can be both an asset and a network /// image. See [Message.imagePath]. @@ -55,7 +65,7 @@ class StudyPageViewModel extends ViewModel { title: title, message: description, type: MessageType.announcement, - timestamp: bloc.studyStartTimestamp ?? DateTime.now(), + timestamp: _study.studyStartTimestamp ?? DateTime.now(), image: 'assets/images/kids.png', ); } diff --git a/lib/view_models/tasklist_page_model.dart b/lib/view_models/tasklist_page_model.dart index 54986e7d..741880f9 100644 --- a/lib/view_models/tasklist_page_model.dart +++ b/lib/view_models/tasklist_page_model.dart @@ -38,8 +38,8 @@ class TaskListPageViewModel extends ViewModel { /// 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 => (Sensing().studyDeploymentStatus != null) - ? DateTime.now().difference(Sensing().studyDeploymentStatus!.createdOn).inDays + int get daysInStudy => (bloc.study.cachedDeploymentStatus != null) + ? DateTime.now().difference(bloc.study.cachedDeploymentStatus!.createdOn).inDays : 0; /// The number of tasks completed so far. diff --git a/lib/view_models/user_tasks.dart b/lib/view_models/user_tasks.dart index 2e09757c..82abb869 100644 --- a/lib/view_models/user_tasks.dart +++ b/lib/view_models/user_tasks.dart @@ -150,7 +150,7 @@ class VideoUserTask extends UserTask { }; // ... and add it to the sensing controller - if (media != null) bloc.addMeasurement(Measurement.fromData(media)); + if (media != null) bloc.study.addMeasurement(Measurement.fromData(media)); } super.onDone(result: media); } diff --git a/test/services_test.dart b/test/services_test.dart new file mode 100644 index 00000000..61179689 --- /dev/null +++ b/test/services_test.dart @@ -0,0 +1,273 @@ +import 'package:carp_backend/carp_backend.dart'; +import 'package:carp_webservices/carp_auth/carp_auth.dart'; +import 'package:cognition_package/cognition_package.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:research_package/research_package.dart'; + +import 'exports.dart'; +import 'test_utils.dart'; +import 'services_test.mocks.dart'; + +class _FakeMessageManager extends MessageManager { + List toReturn = []; + bool throwOnGet = false; + int initializeCount = 0; + int getCount = 0; + + @override + void initialize() => initializeCount++; + + @override + Future getMessage(String messageId) async => null; + + @override + Future> getMessages({DateTime? start, DateTime? end, int? count = 20}) async { + getCount++; + if (throwOnGet) throw Exception('offline'); + return List.of(toReturn); + } + + @override + Future setMessage(Message message) async {} + + @override + Future deleteMessage(String messageId) async {} + + @override + Future deleteAllMessages() async {} +} + +class _FakeConsentManager extends InformedConsentManager { + RPOrderedTask? document; + bool? lastRefresh; + + @override + RPOrderedTask? get informedConsent => document; + + @override + Future getConsentDocument({bool refresh = false}) async { + lastRefresh = refresh; + return document; + } + + @override + Future setConsentDocument(RPOrderedTask informedConsent) async => true; + + @override + Future deleteConsentDocument() async => true; +} + +@GenerateNiceMocks([MockSpec()]) +void main() { + setUpAll(() async { + CarpMobileSensing.ensureInitialized(); + ResearchPackage.ensureInitialized(); + CognitionPackage.ensureInitialized(); + await initTestSettings(); + }); + + setUp(() { + AppConfig().deploymentMode = DeploymentMode.local; + }); + + group('AppConfig', () { + test('defaults to production/info when no environment is given', () { + // The singleton was created without --dart-define values in tests. + expect(AppConfig(), same(AppConfig())); + expect(AppConfig().debugLevel, DebugLevel.info); + expect(AppConfig().localization, isNull); + }); + }); + + group('MessageService', () { + Message message(String id, DateTime timestamp) => Message(id: id, title: 'message-$id', timestamp: timestamp); + + test('refresh sorts messages newest first and emits the count', () async { + final manager = _FakeMessageManager() + ..toReturn = [ + message('old', DateTime(2024, 1, 1)), + message('new', DateTime(2025, 1, 1)), + message('mid', DateTime(2024, 6, 1)), + ]; + final service = MessageService(manager); + + final emitted = expectLater(service.stream, emits(3)); + await service.refresh(); + await emitted; + + expect(service.messages.map((m) => m.id), ['new', 'mid', 'old']); + expect(service.byId('mid')?.id, 'mid'); + expect(service.byId('unknown'), isNull); + }); + + test('refresh keeps the old list and does not throw when the manager fails', () async { + final manager = _FakeMessageManager()..toReturn = [message('a', DateTime(2024, 1, 1))]; + final service = MessageService(manager); + await service.refresh(); + + manager.throwOnGet = true; + await service.refresh(); + + expect(service.messages.length, 1); + }); + + test('start polls periodically, is idempotent, and stop cancels the timer', () { + fakeAsync((async) { + final manager = _FakeMessageManager(); + final service = MessageService(manager, pollingInterval: const Duration(minutes: 30)); + + service.start(); + service.start(); // no duplicate timer + async.flushMicrotasks(); + expect(manager.initializeCount, 2); + expect(manager.getCount, 2); // one refresh per start() call + + async.elapse(const Duration(minutes: 61)); + expect(manager.getCount, 4); // two polls from a single timer + + service.stop(); + async.elapse(const Duration(hours: 2)); + expect(manager.getCount, 4); + + service.dispose(); + }); + }); + + test('dispose closes the stream and refresh is still safe', () async { + final service = MessageService(_FakeMessageManager()); + service.dispose(); + await expectLater(service.stream, emitsDone); + await service.refresh(); // must not throw on the closed controller + }); + }); + + group('AuthService', () { + late MockCarpBackend backend; + late AuthService auth; + + setUp(() { + backend = MockCarpBackend(); + auth = AuthService(backend: backend); + }); + + test('username and friendlyUsername are empty when signed out', () { + when(backend.user).thenReturn(null); + + expect(auth.user, isNull); + expect(auth.username, ''); + expect(auth.friendlyUsername, ''); + }); + + test('username and friendlyUsername come from the signed-in user', () { + when(backend.user).thenReturn(CarpUser(username: 'jdoe', id: '42', firstName: 'John')); + + expect(auth.username, 'jdoe'); + expect(auth.friendlyUsername, 'John'); + }); + + test('delegates authentication state and invitations to the backend', () { + when(backend.isAuthenticated).thenReturn(true); + when(backend.invitations).thenReturn([]); + + expect(auth.isAuthenticated, isTrue); + expect(auth.invitations, isEmpty); + }); + }); + + group('ConsentService', () { + late _FakeConsentManager manager; + late MockCarpBackend backend; + late StudyService studyService; + late ConsentService consent; + + setUp(() { + manager = _FakeConsentManager(); + backend = MockCarpBackend(); + studyService = StudyService(); + consent = ConsentService(manager, studyService, backend: backend); + }); + + test('getDocument delegates to the consent manager', () async { + expect(await consent.getDocument(refresh: true), isNull); + 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, isFalse); + + await consent.accept(); + expect(await consent.hasBeenAccepted, isTrue); + }); + + 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('non-local mode asks the backend and is false when it fails', () async { + LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); + AppConfig().deploymentMode = DeploymentMode.test; + when(backend.getInformedConsentByRole('dep-1', null)).thenAnswer((_) async => throw Exception('offline')); + + expect(await consent.hasBeenAccepted, isFalse); + }); + + test('non-local mode is true when the backend has a consent document', () async { + LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); + AppConfig().deploymentMode = DeploymentMode.test; + when( + backend.getInformedConsentByRole('dep-1', null), + ).thenAnswer((_) async => InformedConsentInput(userId: '42', name: 'jdoe', consent: '{}', signatureImage: '')); + + expect(await consent.hasBeenAccepted, isTrue); + }); + }); + + group('StudyService', () { + late StudyService service; + + setUp(() { + service = StudyService(); + }); + + test('capability queries are false without a deployment', () { + expect(service.isDeployed, isFalse); + expect(service.deployment, isNull); + expect(service.studyStartTimestamp, isNull); + expect(service.hasMeasures(), isFalse); + expect(service.hasMeasure(AppTask.SURVEY_TYPE), isFalse); + expect(service.hasUserTasks(), isFalse); + expect(service.expectedParticipantData, isEmpty); + }); + + test('start is a safe no-op when the study is not deployed', () async { + await service.start(); // no controller - must not throw + expect(service.isRunning, isFalse); + }); + + test('tryDeployment is a safe no-op before the study is added', () async { + expect(await service.tryDeployment(), isNull); + }); + + test('setting the study persists it and hasStudy reflects it', () { + service.study = SmartphoneStudy(studyDeploymentId: 'dep-2', deviceRoleName: 'phone'); + + expect(service.hasStudy, isTrue); + expect(service.study?.studyDeploymentId, 'dep-2'); + }); + + test('configure throws and stays retryable when deployment cannot succeed', () async { + service.study = SmartphoneStudy(studyDeploymentId: 'dep-2', deviceRoleName: 'phone'); + + // The client manager is not configured in unit tests, so configure() + // must fail - and fail again on retry rather than being stuck. + await expectLater(service.configure(), throwsA(anything)); + await expectLater(service.configure(), throwsA(anything)); + }); + }); +} diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart new file mode 100644 index 00000000..a9d8781d --- /dev/null +++ b/test/services_test.mocks.dart @@ -0,0 +1,188 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in carp_study_app/test/services_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i7; + +import 'package:carp_core/carp_core.dart' as _i5; +import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i6; +import 'package:carp_study_app/main.dart' as _i4; +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; +import 'package:research_package/research_package.dart' as _i8; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// 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); +} + +class _FakeCarpApp_1 extends _i1.SmartFake implements _i2.CarpApp { + _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 _FakeCarpUser_3 extends _i1.SmartFake implements _i3.CarpUser { + _FakeCarpUser_3(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 _i4.CarpBackend { + @override + Uri get uri => + (super.noSuchMethod( + Invocation.getter(#uri), + returnValue: _FakeUri_0(this, Invocation.getter(#uri)), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#uri)), + ) + as Uri); + + @override + Uri get authUri => + (super.noSuchMethod( + Invocation.getter(#authUri), + returnValue: _FakeUri_0(this, Invocation.getter(#authUri)), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#authUri)), + ) + as Uri); + + @override + _i2.CarpApp get app => + (super.noSuchMethod( + Invocation.getter(#app), + returnValue: _FakeCarpApp_1(this, Invocation.getter(#app)), + returnValueForMissingStub: _FakeCarpApp_1(this, Invocation.getter(#app)), + ) + as _i2.CarpApp); + + @override + _i3.CarpAuthProperties get authProperties => + (super.noSuchMethod( + 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) + as bool); + + @override + List<_i5.ActiveParticipationInvitation> get invitations => + (super.noSuchMethod( + Invocation.getter(#invitations), + returnValue: <_i5.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i5.ActiveParticipationInvitation>[], + ) + as List<_i5.ActiveParticipationInvitation>); + + @override + 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); + + @override + set study(_i6.SmartphoneStudy? study) => + super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); + + @override + _i7.Future initialize() => + (super.noSuchMethod( + Invocation.method(#initialize, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future authenticate() => + (super.noSuchMethod( + Invocation.method(#authenticate, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future authenticateWithMagicLink(String? uri) => + (super.noSuchMethod( + Invocation.method(#authenticateWithMagicLink, [uri]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i3.CarpUser> refresh() => + (super.noSuchMethod( + 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, [])), + ), + ) + as _i7.Future<_i3.CarpUser>); + + @override + _i7.Future signOut() => + (super.noSuchMethod( + Invocation.method(#signOut, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future> getInvitations() => + (super.noSuchMethod( + Invocation.method(#getInvitations, []), + returnValue: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), + ) + as _i7.Future>); + + @override + _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(), + ) + as _i7.Future<_i5.InformedConsentInput?>); + + @override + _i7.Future<_i5.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => + (super.noSuchMethod( + Invocation.method(#getInformedConsentByRole, [studyDeploymentId, role]), + returnValueForMissingStub: null, + ) + as _i7.Future<_i5.InformedConsentInput?>?); +} diff --git a/test/test_utils.dart b/test/test_utils.dart new file mode 100644 index 00000000..b1da9f71 --- /dev/null +++ b/test/test_utils.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'exports.dart'; + +/// Initialize CAMS [Settings] for unit tests by mocking the platform +/// channels it depends on (shared preferences, package info, paths). +Future initTestSettings() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + SharedPreferences.setMockInitialValues({}); + PackageInfo.setMockInitialValues( + appName: 'carp_study_app_test', + packageName: 'dk.cachet.carp_study_app.test', + version: '0.0.0', + buildNumber: '0', + buildSignature: '', + ); + PathProviderPlatform.instance = FakePathProviderPlatform(); + + await Settings().init(); +} + +class FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + static final String _tempPath = Directory.systemTemp.createTempSync('carp_study_app_test').path; + + @override + Future getApplicationDocumentsPath() async => _tempPath; + + @override + Future getApplicationSupportPath() async => _tempPath; + + @override + Future getTemporaryPath() async => _tempPath; + + @override + Future getLibraryPath() async => _tempPath; +} diff --git a/test/view_models_test.dart b/test/view_models_test.dart new file mode 100644 index 00000000..3a7dadd6 --- /dev/null +++ b/test/view_models_test.dart @@ -0,0 +1,86 @@ +import 'package:carp_backend/carp_backend.dart'; +import 'package:cognition_package/cognition_package.dart'; +import 'package:research_package/research_package.dart'; + +import 'exports.dart'; +import 'test_utils.dart'; +import 'services_test.mocks.dart'; + +class _FakeMessageManager extends MessageManager { + List toReturn = []; + + @override + void initialize() {} + + @override + Future getMessage(String messageId) async => null; + + @override + Future> getMessages({DateTime? start, DateTime? end, int? count = 20}) async => List.of(toReturn); + + @override + Future setMessage(Message message) async {} + + @override + Future deleteMessage(String messageId) async {} + + @override + Future deleteAllMessages() async {} +} + +void main() { + setUpAll(() async { + CarpMobileSensing.ensureInitialized(); + ResearchPackage.ensureInitialized(); + CognitionPackage.ensureInitialized(); + await initTestSettings(); + AppConfig().deploymentMode = DeploymentMode.local; + }); + + group('StudyPageViewModel', () { + test('falls back to defaults when nothing is deployed', () { + final model = StudyPageViewModel( + studyService: StudyService(), + messageService: MessageService(_FakeMessageManager()), + ); + + expect(model.title, 'Unnamed'); + expect(model.description, ''); + expect(model.privacyPolicyUrl, 'https://carp.dk/privacy-policy-app/'); + expect(model.studyDeploymentId, ''); + expect(model.messages, isEmpty); + }); + + test('shows messages from the message service, oldest first', () async { + final manager = _FakeMessageManager() + ..toReturn = [ + Message(id: 'new', timestamp: DateTime(2025, 1, 1)), + Message(id: 'old', timestamp: DateTime(2024, 1, 1)), + ]; + final messages = MessageService(manager); + await messages.refresh(); + final model = StudyPageViewModel(studyService: StudyService(), messageService: messages); + + // The service keeps them newest first; the page shows them reversed. + expect(model.messages.map((m) => m.id), ['old', 'new']); + }); + }); + + group('ProfilePageViewModel', () { + test('is empty-safe when signed out and nothing is deployed', () { + final backend = MockCarpBackend(); + when(backend.user).thenReturn(null); + when(backend.uri).thenReturn(Uri(scheme: 'https', host: 'test.carp.dk')); + + final model = ProfilePageViewModel( + authService: AuthService(backend: backend), + studyService: StudyService(), + ); + + expect(model.username, ''); + expect(model.email, ''); + expect(model.studyId, ''); + expect(model.currentServer, 'https://test.carp.dk'); + }); + }); +} From 24f996c43139c6fcc6c39e1a363b5c9d80385305 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 13:01:20 +0200 Subject: [PATCH 50/94] fix(study): wait for executor initialization before starting sensing In CAMS 2.x the study controller initializes its executor in an async event handler after tryDeployment() returns, and executor.resume() in the Created state is silently ignored. Starting sensing right after configuration therefore lost the resume on first join, leaving the study deployed but not sampling. StudyService.start() now waits for the executor to leave the Created state before resuming. Also roll configureStudy back to the previous state (not hardcoded initialized) when configuration fails. Found by the join-study-flow integration test; the same race exists in the pre-refactor 2.x code, which called bloc.start() immediately after configureStudy completed. Refs #599 --- lib/blocs/app_bloc.dart | 3 ++- lib/services/study_service.dart | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index ec1fa906..00bef408 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -141,12 +141,13 @@ class StudyAppBLoC extends ChangeNotifier { // early out if already configuring or configured if (_state == StudyAppState.configuring || isConfigured) return; + final previousState = _state; _state = StudyAppState.configuring; try { await study.configure(); } catch (error) { - _state = StudyAppState.initialized; + _state = previousState; warning('$runtimeType - Study configuration failed - $error'); rethrow; } diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart index 5a4cc550..905bb0b0 100644 --- a/lib/services/study_service.dart +++ b/lib/services/study_service.dart @@ -83,6 +83,21 @@ class StudyService { ); return; } + + // The controller initializes its executor asynchronously after the device + // deployment is received, and resume() before that is silently ignored - + // so wait for the executor to be ready. + const retryDelay = Duration(milliseconds: 100); + var waited = Duration.zero; + while (controller.executor.state == ExecutorState.Created && waited < const Duration(seconds: 30)) { + await Future.delayed(retryDelay); + waited += retryDelay; + } + if (controller.executor.state == ExecutorState.Created) { + warning('$runtimeType - Cannot start sensing - the study executor was never initialized.'); + return; + } + if (!isRunning) controller.resume(); } From 34d491f73553652a1f2ef23f7ab16841bb766bd7 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 13:01:20 +0200 Subject: [PATCH 51/94] test: add join-study-flow integration test and bloc state tests The integration test runs the real app in local deployment mode on a simulator/device and verifies the riskiest path of the refactor end-to-end: local protocol deployment, the informed-consent gate, configureStudy (sensing initialize -> addStudy -> tryDeployment -> verify deployed), guarded sensing start, and landing on the study page. Only the platform channels a simulator cannot serve are mocked (permission dialogs, battery hardware); deployment, sensing runtime, routing, and consent all run for real. Run with: flutter test integration_test --dart-define=deployment-mode=local The bloc unit tests cover the configureStudy state machine: a failed configuration rolls the state back and stays retryable instead of being stuck in configuring. Refs #597, #599 --- integration_test/join_study_flow_test.dart | 171 +++++++++++++++++++++ ios/Podfile.lock | 6 + pubspec.lock | 39 +++++ pubspec.yaml | 2 + test/app_bloc_test.dart | 41 +++++ 5 files changed, 259 insertions(+) create mode 100644 integration_test/join_study_flow_test.dart create mode 100644 test/app_bloc_test.dart diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart new file mode 100644 index 00000000..c164284c --- /dev/null +++ b/integration_test/join_study_flow_test.dart @@ -0,0 +1,171 @@ +// End-to-end test of the join-study flow in LOCAL deployment mode: +// deploy a protocol locally, gate on informed consent, configure the study +// (Sensing initialize -> addStudy -> tryDeployment -> verify deployed), and +// start sensing. +// +// Run on a simulator or device with: +// +// flutter test integration_test --dart-define=deployment-mode=local +import 'package:carp_core/carp_core.dart' hide Smartphone; +import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; +import 'package:carp_backend/carp_backend.dart'; +import 'package:cognition_package/cognition_package.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:carp_study_app/main.dart'; + +/// Mock the platform channels a simulator cannot serve: OS permission +/// dialogs (which a test cannot answer) and battery hardware (which a +/// simulator does not have). Everything else - deployment, sensing runtime, +/// routing, consent - runs for real. +void mockSimulatorPlatformChannels() { + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler(const MethodChannel('flutter.baseflow.com/permissions/methods'), (call) async { + const granted = 1; + switch (call.method) { + case 'requestPermissions': + return {for (final permission in (call.arguments as List).cast()) permission: granted}; + case 'checkPermissionStatus': + case 'checkServiceStatus': + return granted; + default: + return null; + } + }); + + messenger.setMockMethodCallHandler(const MethodChannel('dexterous.com/flutter/local_notifications'), (call) async { + switch (call.method) { + case 'initialize': + case 'requestPermissions': + return true; + case 'pendingNotificationRequests': + return >[]; + default: + return null; + } + }); + + messenger.setMockMethodCallHandler(const MethodChannel('dev.fluttercommunity.plus/battery'), (call) async { + switch (call.method) { + case 'getBatteryLevel': + return 100; + case 'getBatteryState': + return 'full'; + case 'isInBatterySaveMode': + return false; + default: + return null; + } + }); + + messenger.setMockStreamHandler( + const EventChannel('dev.fluttercommunity.plus/charging'), + MockStreamHandler.inline(onListen: (arguments, events) => events.success('full')), + ); +} + +/// Pump frames until [condition] is true. Used instead of [WidgetTester.pumpAndSettle] +/// since loaders with endless animations never settle. +Future pumpUntil( + WidgetTester tester, + bool Function() condition, { + Duration timeout = const Duration(minutes: 2), + String? reason, +}) async { + final end = DateTime.now().add(timeout); + var lastDump = DateTime.now(); + while (!condition()) { + if (DateTime.now().isAfter(end)) { + fail('pumpUntil timed out${reason != null ? ' waiting for: $reason' : ''} - ${_diagnostics()}'); + } + if (DateTime.now().difference(lastDump) > const Duration(seconds: 5)) { + lastDump = DateTime.now(); + debugPrint('pumpUntil${reason != null ? ' [$reason]' : ''} - ${_diagnostics()}'); + } + await tester.pump(const Duration(milliseconds: 250)); + } +} + +String _diagnostics() => + 'bloc.state=${bloc.state}, hasStudy=${bloc.study.hasStudy}, isDeployed=${bloc.study.isDeployed}, ' + 'isRunning=${bloc.study.isRunning}, ' + 'consentPage=${find.byType(InformedConsentPage).evaluate().isNotEmpty}, ' + 'studyPage=${find.byType(StudyPage).evaluate().isNotEmpty}'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('join study flow: deploy, consent gate, configure, start sensing', (tester) async { + if (AppConfig().deploymentMode != DeploymentMode.local) { + markTestSkipped('This test must run with --dart-define=deployment-mode=local'); + return; + } + + mockSimulatorPlatformChannels(); + + // Same package initialization as main(). + CarpMobileSensing.ensureInitialized(); + CognitionPackage.ensureInitialized(); + CarpDataManager.ensureInitialized(); + + // Deploy a minimal study protocol on the local (on-phone) deployment + // service - the in-test equivalent of placing a protocol.json in + // assets/carp/resources/ for local mode. + await Settings().init(); + final phone = Smartphone(); + final protocol = SmartphoneStudyProtocol(name: 'Join flow integration test', ownerId: 'test') + ..addPrimaryDevice(phone) + ..addTaskControl( + ImmediateTrigger(), + BackgroundTask(measures: [Measure(type: DeviceSamplingPackage.BATTERY_STATE)]), + phone, + ); + final status = await SmartphoneDeploymentService().createStudyDeployment(protocol); + final primaryDeviceRoleName = status.deviceStatusList + .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) + .device + .roleName; + LocalSettings().participant = Participant( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, + ); + bloc.study.study = SmartphoneStudy( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, + ); + + await bloc.initialize(); + + // The study descriptor exists, but it is not deployed in the sensing + // runtime and consent has not been given yet. + expect(bloc.study.hasStudy, isTrue); + expect(bloc.study.isDeployed, isFalse); + expect(await bloc.consent.hasBeenAccepted, isFalse); + + await tester.pumpWidget(const CarpStudyApp()); + + // The home page must gate on consent before configuring the study. With + // no local consent document, the consent page auto-accepts, which lets + // setup proceed: configure -> deploy -> verify -> start. + await pumpUntil( + tester, + () => LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false, + reason: 'consent gate to accept', + ); + await pumpUntil(tester, () => bloc.isConfigured, reason: 'study configuration to complete'); + + expect(await bloc.consent.hasBeenAccepted, isTrue); + expect(bloc.study.isDeployed, isTrue); + expect(bloc.study.deployment?.studyDeploymentId, status.studyDeploymentId); + + // Sensing must actually have been started - the executor is resumed. + await pumpUntil(tester, () => bloc.study.isRunning, reason: 'sensing to start'); + + // And the user lands on the study page. + await pumpUntil(tester, () => find.byType(StudyPage).evaluate().isNotEmpty, reason: 'study page to be shown'); + }); +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2089e041..ba5ffeb2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -47,6 +47,8 @@ PODS: - Flutter - health (13.3.1): - Flutter + - integration_test (0.0.1): + - Flutter - just_audio (0.0.1): - Flutter - FlutterMacOS @@ -119,6 +121,7 @@ DEPENDENCIES: - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - health (from `.symlinks/plugins/health/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - just_audio (from `.symlinks/plugins/just_audio/darwin`) - light (from `.symlinks/plugins/light/ios`) - location (from `.symlinks/plugins/location/ios`) @@ -187,6 +190,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_web_auth_2/ios" health: :path: ".symlinks/plugins/health/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" just_audio: :path: ".symlinks/plugins/just_audio/darwin" light: @@ -253,6 +258,7 @@ SPEC CHECKSUMS: flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed light: 6490592116da81837a414ef49387bbea7a5ed375 location: 155caecf9da4f280ab5fe4a55f94ceccfab838f8 diff --git a/pubspec.lock b/pubspec.lock index af1159ef..68373906 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -758,6 +758,11 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -933,6 +938,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -1029,6 +1039,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1677,6 +1692,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" protobuf: dependency: transitive description: @@ -2026,6 +2049,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -2314,6 +2345,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3cceb515..7a9f302c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -108,6 +108,8 @@ dev_dependencies: build_runner: any flutter_test: sdk: flutter + integration_test: + sdk: flutter mockito: any flutter_lints: any diff --git a/test/app_bloc_test.dart b/test/app_bloc_test.dart new file mode 100644 index 00000000..462eab10 --- /dev/null +++ b/test/app_bloc_test.dart @@ -0,0 +1,41 @@ +import 'package:cognition_package/cognition_package.dart'; +import 'package:research_package/research_package.dart'; + +import 'exports.dart'; +import 'test_utils.dart'; + +void main() { + setUpAll(() async { + CarpMobileSensing.ensureInitialized(); + ResearchPackage.ensureInitialized(); + CognitionPackage.ensureInitialized(); + await initTestSettings(); + AppConfig().deploymentMode = DeploymentMode.local; + }); + + group('StudyAppBLoC.configureStudy', () { + test('is atomic and retryable - a failed configuration resets the state', () async { + final bloc = StudyAppBLoC(); + LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-bloc-1', deviceRoleName: 'phone'); + + // Sensing cannot be set up in a unit test environment, so the + // configuration fails - the state must roll back instead of being + // stuck in `configuring` (which would block any retry forever). + await expectLater(bloc.configureStudy(), throwsA(anything)); + expect(bloc.state, StudyAppState.created); + expect(bloc.isConfiguring, isFalse); + + // A retry must run (and fail) again - not silently return. + await expectLater(bloc.configureStudy(), throwsA(anything)); + expect(bloc.state, StudyAppState.created); + }); + + test('throws when no study has been set', () async { + final bloc = StudyAppBLoC(); + await LocalSettings().eraseStudyDeployment(); + + await expectLater(bloc.configureStudy(), throwsStateError); + expect(bloc.isConfiguring, isFalse); + }); + }); +} From f5f6e02e6528cb9d05247ee29ead67f6669a1beb Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 14:50:42 +0200 Subject: [PATCH 52/94] fix(sensing): register old-namespace device types for CAWS data CAMS 2.x moved its device configuration types from the 'dk.cachet.carp.common.application.devices' namespace to 'dk.carp.cams.devices', and the 2.x sampling packages only register the new type names. Deployment statuses and device deployments for studies created before the migration therefore fail to deserialize (SerializationException on e.g. LocationService), which leaves the study deployment status unavailable and configuration failing for every existing CAWS study. Register the device types under the old namespace as well, so both old and new payloads deserialize - into the CAMS classes, whose type string matches the registered device managers. The proper fix belongs upstream in each CAMS package's onRegister; this unblocks the app against existing CAWS deployments until then. --- lib/blocs/sensing.dart | 15 ++++++++++++ test/services_test.dart | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index bf1203e5..b269cb0f 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -86,6 +86,21 @@ class Sensing { SamplingPackageRegistry().register(PolarSamplingPackage()); SamplingPackageRegistry().register(MovesenseSamplingPackage()); + // CAWS deployments created before CAMS 2.x serialize device types in the + // old namespace - register the device types under it as well. + const oldDeviceNamespace = 'dk.cachet.carp.common.application.devices'; + for (final device in [ + Smartphone(), + LocationService(), + WeatherService(apiKey: ''), + AirQualityService(apiKey: ''), + HealthService(), + PolarDevice(), + MovesenseDevice(), + ]) { + FromJsonFactory().register(device, type: '$oldDeviceNamespace.${device.runtimeType}'); + } + // Create and register external data managers. // The CARP data manager is needed in both LOCAL and CARP deployments, // since a local study protocol may still upload to CAWS. diff --git a/test/services_test.dart b/test/services_test.dart index 61179689..37f052ce 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -1,4 +1,5 @@ import 'package:carp_backend/carp_backend.dart'; +import 'package:carp_context_package/carp_context_package.dart'; import 'package:carp_webservices/carp_auth/carp_auth.dart'; import 'package:cognition_package/cognition_package.dart'; import 'package:fake_async/fake_async.dart'; @@ -228,6 +229,56 @@ void main() { }); }); + group('Old-namespace CAWS compatibility', () { + test('a pre-CAMS-2.x deployment status with CAMS device types deserializes', () { + Sensing(); // registers the sampling packages and old-namespace device aliases + + // The shape CAWS returns for deployments created before CAMS 2.x - + // device types are in the old 'dk.cachet.carp.common.application.devices' + // namespace, while CAMS 2.x registers them as 'dk.carp.cams.devices'. + final json = { + '__type': 'dk.cachet.carp.deployments.application.StudyDeploymentStatus.Running', + 'createdOn': '2026-06-10T12:46:37.103476598Z', + 'studyDeploymentId': '3014e5bc-7f79-45e9-afc3-02a38bfc888f', + 'deviceStatusList': [ + { + '__type': 'dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Deployed', + 'device': { + '__type': 'dk.cachet.carp.common.application.devices.Smartphone', + 'isPrimaryDevice': true, + 'roleName': 'Primary Phone', + }, + }, + { + '__type': 'dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered', + 'device': { + '__type': 'dk.cachet.carp.common.application.devices.LocationService', + 'accuracy': 'balanced', + 'distance': 10, + 'interval': 60000000, + 'roleName': 'Location Service', + 'isOptional': true, + 'defaultSamplingConfiguration': {}, + }, + 'canBeDeployed': false, + 'remainingDevicesToRegisterToObtainDeployment': [], + 'remainingDevicesToRegisterBeforeDeployment': [], + }, + ], + 'participantStatusList': >[], + }; + + final status = StudyDeploymentStatus.fromJson(json); + + expect(status.deviceStatusList.length, 2); + expect(status.deviceStatusList[0].device, isA()); + expect(status.deviceStatusList[1].device, isA()); + // The aliased instances carry the CAMS 2.x type, so device-manager + // lookups by type string work. + expect(status.deviceStatusList[1].device.type, 'dk.carp.cams.devices.LocationService'); + }); + }); + group('StudyService', () { late StudyService service; From b5a4afee820e227571515cbc58e0813d1ef137cf Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 16:01:38 +0200 Subject: [PATCH 53/94] refactor(ui): bloc-owned setup flow and login view-model Move the setup orchestration out of HomePage (carp-dk/carp_study_app#605): the bloc now owns the configure-once engine - consent status is cached in ConsentService, checked on initialize/invitation, and acceptance triggers configure-and-start; the consent-pending redirect lives in the router; study-translation reload happens on the configured transition; the userTaskEvents subscription is created once and cancelled on leaveStudy. HomePage is reduced to the nav bar plus a small view model for the Health Connect install prompt. Locale capture moves out of build() into the localization delegate. Extract LoginViewModel (carp-dk/carp_study_app#606): the sign-in flow (connectivity -> initialize -> authenticate -> typed result) and magic-link validation move out of the login and QR pages; the QR scan subscription is stored and cancelled on dispose. Refs #604, #605, #606 --- ios/Podfile.lock | 4 +- lib/blocs/app_bloc.dart | 50 +++++++++- lib/carp_study_app.dart | 51 +++++++++- lib/main.dart | 2 + lib/services/consent_service.dart | 17 ++++ lib/ui/pages/home_page.dart | 91 +++--------------- lib/ui/pages/login_page.dart | 32 +++---- lib/ui/pages/qr_scanner.dart | 23 +++-- lib/view_models/home_page_model.dart | 36 ++++++++ lib/view_models/login_page_model.dart | 48 ++++++++++ lib/view_models/view_model.dart | 7 ++ pubspec.lock | 128 +++++++++++++------------- pubspec.yaml | 2 +- test/services_test.dart | 16 +++- test/services_test.mocks.dart | 128 ++++++++++++++++++++++++++ test/view_models_test.dart | 46 +++++++++ 16 files changed, 502 insertions(+), 179 deletions(-) create mode 100644 lib/view_models/home_page_model.dart create mode 100644 lib/view_models/login_page_model.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ba5ffeb2..c30905c5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -71,7 +71,7 @@ PODS: - Flutter - pedometer (4.2.0): - Flutter - - permission_handler_apple (9.3.0): + - permission_handler_apple (9.4.8): - Flutter - polar (0.0.1): - Flutter @@ -269,7 +269,7 @@ SPEC CHECKSUMS: open_settings_plus: d19f91e8a04649358a51c19b484ce2e637149d70 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 pedometer: 6806036696085739f36b4e4e5374135e86220a59 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e polar: e6c15a5007d85ab02ce12973c152545bcba861cd PolarBleSdk: 1d56bb7a3ca0a6df04cb4f652fdccb26064df011 qr_code_scanner_plus: 1fb59fd4576edb53ec7560c3746f454dee54300c diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 00bef408..ae9d7d00 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -30,6 +30,7 @@ enum StudyAppState { class StudyAppBLoC extends ChangeNotifier { StudyAppState _state = StudyAppState.created; final CarpStudyAppViewModel _appViewModel = CarpStudyAppViewModel(); + StreamSubscription? _userTaskNotificationSubscription; /// App-wide configuration (deployment mode, debug level, localization). final AppConfig config = AppConfig(); @@ -75,7 +76,27 @@ class StudyAppBLoC extends ChangeNotifier { ); // The coordinator is the sole router-notifier - forward service changes. - consent.addListener(notifyListeners); + // Once consent is given, the study can be configured and started. + consent.addListener(_onConsentChanged); + } + + void _onConsentChanged() { + notifyListeners(); + if (consent.isAccepted == true) unawaited(_tryConfigureStudy()); + } + + /// Run [configureStudy], surfacing a failure to the user instead of + /// throwing. Used by the setup flow, where no caller can handle the error. + Future _tryConfigureStudy() async { + try { + await configureStudy(); + } catch (error) { + final context = scaffoldKey.currentContext; + final locale = context != null ? RPLocalizations.of(context) : null; + scaffoldKey.currentState?.showSnackBar( + SnackBar(content: Text(locale?.translate('pages.home.setup_failed') ?? 'Could not set up the study.')), + ); + } } /// Initialize this BLOC. Called before being used for anything. @@ -102,6 +123,10 @@ class StudyAppBLoC extends ChangeNotifier { _state = StudyAppState.initialized; notifyListeners(); debug('$runtimeType initialized - deployment mode: ${config.deploymentMode.name}'); + + // For a returning user, check consent - via [_onConsentChanged] this + // routes to the consent page or configures and starts the study. + if (study.hasStudy) unawaited(consent.refreshStatus()); } /// Set the active study in the app based on an [invitation]. @@ -124,6 +149,10 @@ class StudyAppBLoC extends ChangeNotifier { info('Invitation received - study: ${study.study}'); if (context != null) CarpStudyApp.reloadLocale(context); + + // 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()); } /// Configure the study deployment and start sensing. @@ -156,6 +185,8 @@ class StudyAppBLoC extends ChangeNotifier { messages.start(); + _listenToUserTaskNotifications(); + info('Study configuration done.'); _state = StudyAppState.configured; @@ -164,6 +195,17 @@ class StudyAppBLoC extends ChangeNotifier { await study.start(); } + /// Open the task page when the user taps a user-task notification from + /// the OS. The subscription is created once and cancelled in [leaveStudy]. + void _listenToUserTaskNotifications() { + _userTaskNotificationSubscription ??= AppTaskController().userTaskEvents.listen((userTask) { + if (userTask.state == UserTaskState.notified) { + userTask.onStart(); + if (userTask.hasWidget) _rootNavigatorKey.currentContext?.push('/task/${userTask.id}'); + } + }); + } + /// Leave the study deployed on this phone. /// /// This entails @@ -178,9 +220,12 @@ class StudyAppBLoC extends ChangeNotifier { Future leaveStudy() async { info('Leaving study ${study.study}'); - // clear the UI data models and stop message polling + // clear the UI data models, message polling, and notification handling appViewModel.clear(); messages.stop(); + await _userTaskNotificationSubscription?.cancel(); + _userTaskNotificationSubscription = null; + consent.reset(); // stop sensing and remove all deployment info await study.remove(); @@ -203,6 +248,7 @@ class StudyAppBLoC extends ChangeNotifier { @override void dispose() { messages.dispose(); + _userTaskNotificationSubscription?.cancel(); Sensing().controller?.dispose(); super.dispose(); } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 8ae47c8e..e4f5cc99 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -48,13 +48,20 @@ class CarpStudyAppState extends State { return InvitationListPage.route; } - // 3) Fully onboarded. + // 3) Study selected but consent known to be pending → consent page. + // (null means the consent status is still being checked - stay put.) + if (!bloc.isConfiguring && bloc.consent.isAccepted == false) { + return loc == InformedConsentPage.route ? null : InformedConsentPage.route; + } + + // 4) Fully onboarded. return null; }, routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, - builder: (BuildContext context, GoRouterState state, Widget child) => HomePage(child: child), + builder: (BuildContext context, GoRouterState state, Widget child) => + HomePage(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. @@ -131,7 +138,7 @@ class CarpStudyAppState extends State { GoRoute( path: LoginPage.route, parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => const LoginPage(), + builder: (context, state) => LoginPage(model: bloc.appViewModel.loginViewModel), ), GoRoute( path: '${MessageDetailsPage.route}/:messageId', @@ -157,10 +164,33 @@ class CarpStudyAppState extends State { /// Research Package translations, incl. both local language assets plus /// translations of informed consent and surveys downloaded from CARP - final RPLocalizationsDelegate rpLocalizationsDelegate = RPLocalizationsDelegate( + final RPLocalizationsDelegate rpLocalizationsDelegate = _AppLocalizationsDelegate( loaders: [const AssetLocalizationLoader(), bloc.resources.localizationLoader], ); + StudyAppState _previousBlocState = bloc.state; + + @override + void initState() { + super.initState(); + bloc.addListener(_onAppStateChanged); + } + + @override + void dispose() { + bloc.removeListener(_onAppStateChanged); + super.dispose(); + } + + /// Re-load translations once the study is configured, since configuration + /// downloads the study-specific translations. + void _onAppStateChanged() { + if (bloc.state == StudyAppState.configured && _previousBlocState != StudyAppState.configured && mounted) { + reloadLocale(); + } + _previousBlocState = bloc.state; + } + @override Widget build(BuildContext context) { final studyAppColors = Theme.of(context).extension(); @@ -202,6 +232,19 @@ class CarpStudyAppState extends State { } } +/// Loads the RP translations and captures the loaded localization in +/// [AppConfig], where non-UI layers (e.g. protocol translation) read it. +class _AppLocalizationsDelegate extends RPLocalizationsDelegate { + _AppLocalizationsDelegate({required super.loaders}); + + @override + Future load(Locale locale) async { + final localizations = await super.load(locale); + AppConfig().localization = localizations; + return localizations; + } +} + FadeTransition bottomNavigationBarAnimation( BuildContext context, Animation animation, diff --git a/lib/main.dart b/lib/main.dart index dd38a392..cdcc3f69 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -79,6 +79,8 @@ 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'; diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart index e112bda6..7619ac3f 100644 --- a/lib/services/consent_service.dart +++ b/lib/services/consent_service.dart @@ -14,10 +14,26 @@ class ConsentService extends ChangeNotifier { final StudyService _studyService; final AppConfig _config; final CarpBackend _backend; + bool? _accepted; /// Get the informed consent document for this study. 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], cache the answer in [isAccepted], and notify. + Future refreshStatus() async { + _accepted = await hasBeenAccepted; + 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? /// /// Consent is tied to the account, not the device, so the backend is the @@ -48,6 +64,7 @@ class ConsentService extends ChangeNotifier { if (result != null && _config.deploymentMode != DeploymentMode.local) { await _backend.uploadInformedConsent(result); } + _accepted = true; notifyListeners(); } } diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index c7eac66b..84caa65a 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -1,89 +1,36 @@ part of carp_study_app; -/// The home page of the app. +/// The home page of the app - the navigation bar around the shell pages. /// -/// Shown once the onboarding process is done. +/// Shown once the onboarding process is done. All setup orchestration +/// (consent gating, study configuration, starting sensing) is owned by the +/// [StudyAppBLoC] and the router redirect - not this page. class HomePage extends StatefulWidget { + final HomePageViewModel model; final Widget child; - const HomePage({required this.child, super.key}); + const HomePage({required this.model, required this.child, super.key}); @override HomePageState createState() => HomePageState(); } class HomePageState extends State { - bool _setupDone = false; - bool _setupInFlight = false; - @override void initState() { super.initState(); - // Re-run setup when bloc notifies (e.g. after consent submission). - bloc.addListener(_onBlocChanged); + widget.model.addListener(_onModelChanged); } @override void dispose() { - bloc.removeListener(_onBlocChanged); + widget.model.removeListener(_onModelChanged); super.dispose(); } - void _onBlocChanged() { - _maybeRunSetup(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _maybeRunSetup(); - } - - Future _maybeRunSetup() async { - if (_setupDone || _setupInFlight || !mounted) return; - if (!bloc.study.hasStudy) return; - - _setupInFlight = true; - try { - // Defer configureStudy (and its OS permission prompts) until consent. - if (!await bloc.consent.hasBeenAccepted) { - if (!mounted) return; - final loc = GoRouterState.of(context).matchedLocation; - if (loc != InformedConsentPage.route) { - context.go(InformedConsentPage.route); - } - return; - } - - // Run setup and HC check in parallel so the HC dialog doesn't gate sensing. - await Future.wait([ - bloc.configureStudy().then((_) { - if (mounted) CarpStudyApp.reloadLocale(context); - }), - if (Platform.isAndroid) _checkHealthConnectInstallation(), - ]); - _setupDone = true; - } catch (error) { - // Setup stays retryable - surface the failure and try again on the - // next trigger. - warning('$runtimeType - Study setup failed - $error'); - _showSetupFailedSnackBar(); - } finally { - _setupInFlight = false; - } - } - - void _showSetupFailedSnackBar() { - if (!mounted) return; - final locale = RPLocalizations.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locale?.translate('pages.home.setup_failed') ?? 'Could not set up the study.')), - ); - } - - Future _checkHealthConnectInstallation() async { - bool isInstalled = await bloc.system.isHealthInstalled(); - if (!isInstalled && mounted) { - await showDialog( + void _onModelChanged() { + if (widget.model.shouldPromptHealthConnectInstall && mounted) { + widget.model.healthConnectPromptShown(); + showDialog( context: context, barrierDismissible: true, builder: (context) => InstallHealthConnectDialog(context), @@ -95,19 +42,6 @@ class HomePageState extends State { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - // Save the localization for the app - bloc.config.localization = locale; - - // Listen for user task notification clicked in the OS - AppTaskController().userTaskEvents.listen((userTask) { - if (userTask.state == UserTaskState.notified) { - userTask.onStart(); - if (userTask.hasWidget) { - _rootNavigatorKey.currentContext?.push('/task/${userTask.id}'); - } - } - }); - return Scaffold( backgroundColor: Theme.of(context).extension()!.backgroundGray, body: SafeArea(child: widget.child), @@ -115,7 +49,6 @@ class HomePageState extends State { backgroundColor: Theme.of(context).extension()!.white, type: BottomNavigationBarType.fixed, selectedItemColor: Theme.of(context).extension()!.primary, - //unselectedItemColor: Theme.of(context).primaryColor.withOpacity(0.8), items: [ BottomNavigationBarItem( icon: const Icon(Icons.announcement), diff --git a/lib/ui/pages/login_page.dart b/lib/ui/pages/login_page.dart index 7b8e0b22..93c28011 100644 --- a/lib/ui/pages/login_page.dart +++ b/lib/ui/pages/login_page.dart @@ -2,20 +2,14 @@ part of carp_study_app; class LoginPage extends StatefulWidget { static const String route = '/login'; - const LoginPage({super.key}); + final LoginViewModel model; + const LoginPage({required this.model, super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State { - final GlobalKey webViewKey = GlobalKey(); - - @override - void initState() { - super.initState(); - } - @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; @@ -39,7 +33,10 @@ class _LoginPageState extends State { decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), child: TextButton( onPressed: () { - showDialog(context: context, builder: (context) => QRViewExample()); + showDialog( + context: context, + builder: (context) => QRViewExample(model: widget.model), + ); }, child: Text( locale.translate("scan"), @@ -55,12 +52,11 @@ class _LoginPageState extends State { decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), child: TextButton( onPressed: () async { - bool isConnected = await bloc.system.checkConnectivity(); - if (isConnected) { - await bloc.auth.initialize(); - await bloc.auth.authenticate(); - if (context.mounted) context.go(CarpStudyAppState.homeRoute); - } else { + final result = await widget.model.signIn(); + if (!context.mounted) return; + if (result == SignInResult.success) { + context.go(CarpStudyAppState.homeRoute); + } else if (result == SignInResult.offline) { showDialog( context: context, builder: (context) => PopScope( @@ -83,13 +79,13 @@ class _LoginPageState extends State { ), ), ), - if (bloc.auth.isAuthenticated) + if (widget.model.isAuthenticated) TextButton( onPressed: () { showDialog(context: context, builder: (context) => const LogoutMessage()).then((value) async { if (value == true) { - await bloc.auth.signOut(); - setState(() {}); + await widget.model.signOut(); + if (mounted) setState(() {}); } }); }, diff --git a/lib/ui/pages/qr_scanner.dart b/lib/ui/pages/qr_scanner.dart index 228f5511..8f10d7c2 100644 --- a/lib/ui/pages/qr_scanner.dart +++ b/lib/ui/pages/qr_scanner.dart @@ -1,7 +1,8 @@ part of carp_study_app; class QRViewExample extends StatefulWidget { - const QRViewExample({super.key}); + final LoginViewModel model; + const QRViewExample({required this.model, super.key}); @override State createState() => _QRViewExampleState(); @@ -10,6 +11,7 @@ class QRViewExample extends StatefulWidget { class _QRViewExampleState extends State { qr.Barcode? result; qr.QRViewController? controller; + StreamSubscription? _scanSubscription; final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); // In order to get hot reload to work we need to pause the camera if the platform @@ -23,6 +25,12 @@ class _QRViewExampleState extends State { controller!.resumeCamera(); } + @override + void dispose() { + _scanSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -108,7 +116,7 @@ class _QRViewExampleState extends State { setState(() { this.controller = controller; }); - controller.scannedDataStream.listen((scanData) async { + _scanSubscription = controller.scannedDataStream.listen((scanData) async { await controller.pauseCamera(); if (result != null) return; setState(() { @@ -116,13 +124,12 @@ class _QRViewExampleState extends State { }); final qrcode = scanData.code; + if (qrcode == null) return; - if (qrcode != null && Uri.tryParse(qrcode)?.hasAbsolutePath == true) { - await bloc.auth.authenticateWithMagicLink(qrcode).then((_) { - context.go('/'); - Navigator.of(context).pop(); - }); - } + await widget.model.signInWithMagicLink(qrcode); + if (!mounted) return; + context.go('/'); + Navigator.of(context).pop(); }); } diff --git a/lib/view_models/home_page_model.dart b/lib/view_models/home_page_model.dart new file mode 100644 index 00000000..3b3ab22c --- /dev/null +++ b/lib/view_models/home_page_model.dart @@ -0,0 +1,36 @@ +part of carp_study_app; + +/// The view model for the [HomePage]. +class HomePageViewModel extends ViewModel { + HomePageViewModel({SystemInfoService? systemInfoService}) : _systemInfoService = systemInfoService; + + final SystemInfoService? _systemInfoService; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + + bool _healthConnectPromptPending = false; + + /// Should the user be prompted to install Health Connect? + /// One-shot - the page calls [healthConnectPromptShown] once shown. + bool get shouldPromptHealthConnectInstall => _healthConnectPromptPending; + + void healthConnectPromptShown() => _healthConnectPromptPending = false; + + @override + void init(SmartphoneStudyController ctrl) { + super.init(ctrl); + unawaited(_checkHealthConnectInstallation()); + } + + Future _checkHealthConnectInstallation() async { + if (!Platform.isAndroid) return; + if (await _system.isHealthInstalled()) return; + _healthConnectPromptPending = true; + notifyListeners(); + } + + @override + void clear() { + _healthConnectPromptPending = false; + super.clear(); + } +} diff --git a/lib/view_models/login_page_model.dart b/lib/view_models/login_page_model.dart new file mode 100644 index 00000000..9f82c1d6 --- /dev/null +++ b/lib/view_models/login_page_model.dart @@ -0,0 +1,48 @@ +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. +class LoginViewModel extends ViewModel { + LoginViewModel({AuthService? authService, SystemInfoService? systemInfoService}) + : _authService = authService, + _systemInfoService = systemInfoService; + + final AuthService? _authService; + final SystemInfoService? _systemInfoService; + AuthService get _auth => _authService ?? bloc.auth; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + + /// Has the user been authenticated? + bool get isAuthenticated => _auth.isAuthenticated; + + /// Sign in via the CAWS web view. + Future signIn() async { + if (!await _system.checkConnectivity()) return SignInResult.offline; + + await _auth.initialize(); + await _auth.authenticate(); + + notifyListeners(); + return _auth.isAuthenticated ? SignInResult.success : SignInResult.failed; + } + + /// Sign in anonymously using a scanned magic link. + /// Returns false if [qrCode] is not a link, without attempting to sign in. + Future signInWithMagicLink(String qrCode) async { + if (Uri.tryParse(qrCode)?.hasAbsolutePath != true) return false; + + await _auth.authenticateWithMagicLink(qrCode); + + notifyListeners(); + return _auth.isAuthenticated; + } + + /// Sign out from CAWS, erasing all authentication information. + Future signOut() async { + await _auth.signOut(); + notifyListeners(); + } +} diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 3566e6c4..9820cdb2 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -187,6 +187,8 @@ class HourlyMeasure { /// The view model for the entire app. class CarpStudyAppViewModel extends ViewModel { + final HomePageViewModel _homePageViewModel = HomePageViewModel(); + final LoginViewModel _loginViewModel = LoginViewModel(); final DataVisualizationPageViewModel _dataVisualizationPageViewModel = DataVisualizationPageViewModel(); final StudyPageViewModel _studyPageViewModel = StudyPageViewModel(); final TaskListPageViewModel _taskListPageViewModel = TaskListPageViewModel(); @@ -198,6 +200,8 @@ class CarpStudyAppViewModel extends ViewModel { CarpStudyAppViewModel() : super(); + HomePageViewModel get homePageViewModel => _homePageViewModel; + LoginViewModel get loginViewModel => _loginViewModel; DataVisualizationPageViewModel get dataVisualizationPageViewModel => _dataVisualizationPageViewModel; StudyPageViewModel get studyPageViewModel => _studyPageViewModel; TaskListPageViewModel get taskListPageViewModel => _taskListPageViewModel; @@ -210,6 +214,7 @@ class CarpStudyAppViewModel extends ViewModel { @override void init(SmartphoneStudyController ctrl) { super.init(ctrl); + _homePageViewModel.init(ctrl); _taskListPageViewModel.init(ctrl); _studyPageViewModel.init(ctrl); _dataVisualizationPageViewModel.init(ctrl); @@ -223,6 +228,7 @@ class CarpStudyAppViewModel extends ViewModel { @override void clear() { + _homePageViewModel.clear(); _taskListPageViewModel.clear(); _studyPageViewModel.clear(); _dataVisualizationPageViewModel.clear(); @@ -237,6 +243,7 @@ class CarpStudyAppViewModel extends ViewModel { @override void dispose() { + _homePageViewModel.dispose(); _taskListPageViewModel.dispose(); _studyPageViewModel.dispose(); _dataVisualizationPageViewModel.dispose(); diff --git a/pubspec.lock b/pubspec.lock index 68373906..30bb8aa1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c url: "https://pub.dev" source: hosted - version: "93.0.0" + version: "99.0.0" air_quality: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "12.1.0" app_version_update: dependency: "direct main" description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: audioplayers - sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + sha256: f16640453cc47487b7de72a2b28d37c7df1ac97999849f4a46d92b1d2b0f093d url: "https://pub.dev" source: hosted - version: "6.6.0" + version: "6.7.1" audioplayers_android: dependency: transitive description: @@ -133,18 +133,18 @@ packages: dependency: transitive description: name: audioplayers_web - sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.2.1" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.3.1" base_codecs: dependency: transitive description: @@ -301,10 +301,10 @@ packages: dependency: "direct main" description: name: carp_context_package - sha256: "3a4d697c0632164667598bc4a08ecbabd16e8fba26294cbb04ff2dad13625ca5" + sha256: "1dfecc2816e2ccd0923c1620a9d1246fed61c2db448ea3730b6faabc5c7c73c7" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" carp_core: dependency: "direct main" description: @@ -317,34 +317,34 @@ packages: dependency: "direct main" description: name: carp_health_package - sha256: "0cf14b9813058c9ce8e1654da3d9dc6af5f0a4a512f6c1a08a939f764f234999" + sha256: "5c362e97fde54d7bc7282cc64ba58d63fda904719ab97400d62d13458508450f" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" carp_mobile_sensing: dependency: "direct main" description: name: carp_mobile_sensing - sha256: "715e2dda7db3cab8c966975b1ab69d755dee15076712b865f164955674d89456" + sha256: e2b77cdb968678e9ed713637930d4861932ac83594ec7a6c36026a15bc149347 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" carp_movesense_package: dependency: "direct main" description: name: carp_movesense_package - sha256: fc18d9a4c0585490dab47cb707a46a7b35c64f1d3f8fde25f8cca74b09058991 + sha256: "3947c29511725fcd360a5b3ee09be0efdca48e4ae12e6ce26a2cc226fc333347" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" carp_polar_package: dependency: "direct main" description: name: carp_polar_package - sha256: d8eeae5b729da833914f2a6cdbd38cca5e5557b71e24d3d7640630b323e07de4 + sha256: "9f73e858539c3567a917598189bcba4d93694d85da5609cb40c5e28849d07a72" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" carp_serializable: dependency: "direct main" description: @@ -421,10 +421,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" code_builder: dependency: transitive description: @@ -493,10 +493,10 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" cron: dependency: transitive description: @@ -541,10 +541,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 url: "https://pub.dev" source: hosted - version: "3.1.7" + version: "3.1.8" data_serializer: dependency: transitive description: @@ -557,10 +557,10 @@ packages: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" dchs_flutter_beacon: dependency: transitive description: @@ -812,10 +812,10 @@ packages: dependency: "direct main" description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.34" + version: "2.0.35" flutter_secure_storage: dependency: transitive description: @@ -995,10 +995,10 @@ packages: dependency: transitive description: name: hooks - sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.2" html: dependency: transitive description: @@ -1344,10 +1344,10 @@ packages: dependency: transitive description: name: noise_meter - sha256: "1eab67e5023c24c43757fa2b6b1b8bc43277e9cc6a7b0fb1c6f6537cc0e3116c" + sha256: a05ffa13ac533e4fcfb1dd772f43d7262c5130c80bbc1bf2842f7b15794f6264 url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.1.0" nonce: dependency: transitive description: @@ -1608,10 +1608,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.4.10" permission_handler_html: dependency: transitive description: @@ -1728,10 +1728,10 @@ packages: dependency: "direct main" description: name: qr_code_scanner_plus - sha256: dae0596b2763c2fd0294f5cfddb1d3a21577ae4dc7fc1449eb5aafc957872f61 + sha256: "2bcf9df32aa639fba07c183b1ab9c3f03eb6b1d688ab4f4f6bc868a75afd824d" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" recase: dependency: transitive description: @@ -1776,10 +1776,10 @@ packages: dependency: transitive description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" sample_statistics: dependency: transitive description: @@ -1824,10 +1824,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" url: "https://pub.dev" source: hosted - version: "2.4.23" + version: "2.4.26" shared_preferences_foundation: dependency: transitive description: @@ -1965,42 +1965,42 @@ packages: dependency: transitive description: name: sqflite - sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" url: "https://pub.dev" source: hosted - version: "2.4.2+1" + version: "2.4.3" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b url: "https://pub.dev" source: hosted - version: "2.4.2+3" + version: "2.4.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" url: "https://pub.dev" source: hosted - version: "2.5.8" + version: "2.5.11" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" sqflite_platform_interface: dependency: transitive description: name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" stack_trace: dependency: transitive description: @@ -2061,10 +2061,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" url: "https://pub.dev" source: hosted - version: "3.4.0+1" + version: "3.4.1" system_info2: dependency: transitive description: @@ -2165,10 +2165,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 url: "https://pub.dev" source: hosted - version: "6.3.30" + version: "6.3.32" url_launcher_ios: dependency: transitive description: @@ -2245,10 +2245,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e + sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.2.5" vector_math: dependency: transitive description: @@ -2269,10 +2269,10 @@ packages: dependency: transitive description: name: video_player_android - sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + sha256: "5d18d04084cc0cfc7afde39d0a308d4041e8ae6e9d5255bc086c263998dd1201" url: "https://pub.dev" source: hosted - version: "2.9.5" + version: "2.9.6" video_player_avfoundation: dependency: transitive description: @@ -2381,10 +2381,10 @@ packages: dependency: transitive description: name: window_to_front - sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.4" x509_plus: dependency: transitive description: @@ -2418,5 +2418,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7a9f302c..c226fee2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: carp_serializable: ^2.0.1 carp_core: ^2.1.2 - carp_mobile_sensing: ^2.1.1 + carp_mobile_sensing: ^2.1.2 carp_context_package: ^2.0.0 carp_connectivity_package: ^2.0.0 carp_survey_package: ^2.0.0 diff --git a/test/services_test.dart b/test/services_test.dart index 37f052ce..8f449e72 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -58,7 +58,7 @@ class _FakeConsentManager extends InformedConsentManager { Future deleteConsentDocument() async => true; } -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) void main() { setUpAll(() async { CarpMobileSensing.ensureInitialized(); @@ -201,6 +201,20 @@ void main() { expect(await consent.hasBeenAccepted, isTrue); }); + test('caches the consent status for synchronous reads', () async { + LocalSettings().participant = Participant(studyDeploymentId: 'dep-cache'); + + expect(consent.isAccepted, isNull); + expect(await consent.refreshStatus(), 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; diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart index a9d8781d..af0936bd 100644 --- a/test/services_test.mocks.dart +++ b/test/services_test.mocks.dart @@ -11,6 +11,7 @@ import 'package:carp_study_app/main.dart' as _i4; 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; +import 'package:mockito/src/dummies.dart' as _i9; import 'package:research_package/research_package.dart' as _i8; // ignore_for_file: type=lint @@ -186,3 +187,130 @@ class MockCarpBackend extends _i1.Mock implements _i4.CarpBackend { ) 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 _i4.AuthService { + @override + bool get isAuthenticated => + (super.noSuchMethod(Invocation.getter(#isAuthenticated), 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)), + ) + as String); + + @override + String get friendlyUsername => + (super.noSuchMethod( + Invocation.getter(#friendlyUsername), + returnValue: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#friendlyUsername)), + ) + as String); + + @override + Uri get serverUri => + (super.noSuchMethod( + Invocation.getter(#serverUri), + returnValue: _FakeUri_0(this, Invocation.getter(#serverUri)), + returnValueForMissingStub: _FakeUri_0(this, Invocation.getter(#serverUri)), + ) + as Uri); + + @override + List<_i5.ActiveParticipationInvitation> get invitations => + (super.noSuchMethod( + Invocation.getter(#invitations), + returnValue: <_i5.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i5.ActiveParticipationInvitation>[], + ) + as List<_i5.ActiveParticipationInvitation>); + + @override + _i7.Future initialize() => + (super.noSuchMethod( + Invocation.method(#initialize, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future> getInvitations() => + (super.noSuchMethod( + Invocation.method(#getInvitations, []), + returnValue: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i5.ActiveParticipationInvitation>[], + ), + ) + as _i7.Future>); + + @override + _i7.Future authenticate() => + (super.noSuchMethod( + Invocation.method(#authenticate, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future authenticateWithMagicLink(String? uri) => + (super.noSuchMethod( + Invocation.method(#authenticateWithMagicLink, [uri]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future signOut() => + (super.noSuchMethod( + Invocation.method(#signOut, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); +} + +/// A class which mocks [SystemInfoService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSystemInfoService extends _i1.Mock implements _i4.SystemInfoService { + @override + _i7.Future checkConnectivity() => + (super.noSuchMethod( + Invocation.method(#checkConnectivity, []), + returnValue: _i7.Future.value(false), + returnValueForMissingStub: _i7.Future.value(false), + ) + as _i7.Future); + + @override + _i7.Future isHealthInstalled() => + (super.noSuchMethod( + Invocation.method(#isHealthInstalled, []), + returnValue: _i7.Future.value(false), + returnValueForMissingStub: _i7.Future.value(false), + ) + as _i7.Future); + + @override + _i7.Future getAppHasUpdate() => + (super.noSuchMethod( + Invocation.method(#getAppHasUpdate, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); +} diff --git a/test/view_models_test.dart b/test/view_models_test.dart index 3a7dadd6..1192a203 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -66,6 +66,52 @@ void main() { }); }); + group('LoginViewModel', () { + late MockAuthService auth; + late MockSystemInfoService system; + late LoginViewModel model; + + setUp(() { + auth = MockAuthService(); + system = MockSystemInfoService(); + model = LoginViewModel(authService: auth, systemInfoService: system); + }); + + test('signIn is offline without authenticating when there is no connectivity', () async { + when(system.checkConnectivity()).thenAnswer((_) async => false); + + expect(await model.signIn(), SignInResult.offline); + verifyNever(auth.authenticate()); + }); + + test('signIn initializes, authenticates, and reports success', () async { + when(system.checkConnectivity()).thenAnswer((_) async => true); + when(auth.isAuthenticated).thenReturn(true); + + expect(await model.signIn(), SignInResult.success); + verifyInOrder([auth.initialize(), auth.authenticate()]); + }); + + test('signIn reports failure when authentication did not stick', () async { + when(system.checkConnectivity()).thenAnswer((_) async => true); + when(auth.isAuthenticated).thenReturn(false); + + expect(await model.signIn(), SignInResult.failed); + }); + + test('signInWithMagicLink rejects non-link codes without signing in', () async { + expect(await model.signInWithMagicLink('not a link'), isFalse); + verifyNever(auth.authenticateWithMagicLink(any)); + }); + + test('signInWithMagicLink authenticates with a valid link', () async { + when(auth.isAuthenticated).thenReturn(true); + + expect(await model.signInWithMagicLink('https://carp.dk/magic'), isTrue); + verify(auth.authenticateWithMagicLink('https://carp.dk/magic')).called(1); + }); + }); + group('ProfilePageViewModel', () { test('is empty-safe when signed out and nothing is deployed', () { final backend = MockCarpBackend(); From 809fa92f16c42dd0676c76c72bfd92fcb321d702 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 16:04:50 +0200 Subject: [PATCH 54/94] refactor(ui): invitations flow through InvitationsViewModel InvitationsViewModel owns loading/refresh state and the accept and sign-out intents; the invitation pages render and forward. The smartphone-only invitation filter moves from CarpBackend into AuthService, where it is unit-tested. setStudyInvitation loses its BuildContext parameter - the app reloads translations by listening for the study change instead. Refs #604, #607 --- lib/blocs/app_bloc.dart | 9 ++--- lib/carp_study_app.dart | 13 +++++-- lib/data/carp_backend.dart | 12 +------ lib/services/auth_service.dart | 17 ++++++--- lib/ui/pages/invitation_list_page.dart | 33 +++++++----------- lib/ui/pages/invitation_page.dart | 2 +- lib/view_models/invitations_view_model.dart | 35 ++++++++++++++++++- test/services_test.dart | 25 ++++++++++++-- test/view_models_test.dart | 38 +++++++++++++++++++++ 9 files changed, 135 insertions(+), 49 deletions(-) diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index ae9d7d00..79829f03 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -131,9 +131,9 @@ class StudyAppBLoC extends ChangeNotifier { /// Set the active study in the app based on an [invitation]. /// - /// If a [context] is provided, the translation for this study is re-loaded - /// and applied in the app. - void setStudyInvitation(ActiveParticipationInvitation invitation, [BuildContext? context]) { + /// The study translations are re-loaded by the app, which listens for the + /// study change. + void setStudyInvitation(ActiveParticipationInvitation invitation) { // create and save the participant info based on this invitation LocalSettings().participant = Participant.fromParticipationInvitation(invitation); @@ -148,8 +148,6 @@ class StudyAppBLoC extends ChangeNotifier { info('Invitation received - study: ${study.study}'); - if (context != null) CarpStudyApp.reloadLocale(context); - // 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()); @@ -248,7 +246,6 @@ class StudyAppBLoC extends ChangeNotifier { @override void dispose() { messages.dispose(); - _userTaskNotificationSubscription?.cancel(); Sensing().controller?.dispose(); super.dispose(); } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index e4f5cc99..d3e93cf2 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -169,6 +169,7 @@ class CarpStudyAppState extends State { ); StudyAppState _previousBlocState = bloc.state; + String? _previousDeploymentId = bloc.study.study?.studyDeploymentId; @override void initState() { @@ -182,13 +183,19 @@ class CarpStudyAppState extends State { super.dispose(); } - /// Re-load translations once the study is configured, since configuration - /// downloads the study-specific translations. + /// Re-load translations when a (new) study is set or has been configured, + /// since both make new study-specific translations available. void _onAppStateChanged() { - if (bloc.state == StudyAppState.configured && _previousBlocState != StudyAppState.configured && mounted) { + final configured = bloc.state == StudyAppState.configured; + final deploymentId = bloc.study.study?.studyDeploymentId; + + if (mounted && + ((configured && _previousBlocState != StudyAppState.configured) || deploymentId != _previousDeploymentId)) { reloadLocale(); } + _previousBlocState = bloc.state; + _previousDeploymentId = deploymentId; } @override diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index 5bbc117e..fbad551a 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -147,17 +147,7 @@ class CarpBackend { Future> getInvitations() async { CarpParticipationService().configureFrom(CarpService()); - invitations = await CarpParticipationService().getActiveParticipationInvitations(); - - // Filter the invitations to only include those that - // have a smartphone as a device in [ActiveParticipationInvitation.assignedDevices] list - // (i.e. the invitation is for a smartphone). - // This is done to avoid showing invitations for other devices (e.g. [WebBrowser]). - invitations.removeWhere( - (invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) ?? false, - ); - - return invitations; + return invitations = await CarpParticipationService().getActiveParticipationInvitations(); } /// Set the [study] used on this phone. diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 4ac2bf2f..90776084 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -27,11 +27,20 @@ class AuthService { /// The URI of the CAWS server used in this deployment. Uri get serverUri => _backend.uri; - /// The list of invitations for this user, as last fetched by [getInvitations]. - List get invitations => _backend.invitations; + List _invitations = []; - /// Get / refresh the list of active invitations for this user from CAWS. - Future> getInvitations() => _backend.getInvitations(); + /// The list of invitations for this user, as last fetched by [getInvitations]. + List get invitations => _invitations; + + /// 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). + Future> getInvitations() async { + final all = await _backend.getInvitations(); + return _invitations = all + .where((invitation) => invitation.assignedDevices?.any((device) => device.device is! Smartphone) != true) + .toList(); + } /// Authenticate using a web view. Future authenticate() => _backend.authenticate(); diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index fc666e3c..312331f4 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -10,20 +10,10 @@ class InvitationListPage extends StatefulWidget { } class _InvitationListPageState extends State { - late Future> _invitationsFuture; - @override void initState() { super.initState(); - _invitationsFuture = bloc.auth.getInvitations(); - } - - Future _refresh() async { - final next = bloc.auth.getInvitations(); - setState(() { - _invitationsFuture = next; - }); - await next; + widget.model.loadInvitations(); } @override @@ -32,20 +22,21 @@ class _InvitationListPageState extends State { return Scaffold( backgroundColor: Theme.of(context).extension()!.backgroundGray, body: RefreshIndicator( - onRefresh: _refresh, - child: FutureBuilder>( - future: _invitationsFuture, - builder: (context, snapshot) { + onRefresh: widget.model.loadInvitations, + child: ListenableBuilder( + listenable: widget.model, + builder: (context, _) { Widget child; - if (snapshot.hasData) { + if (widget.model.isLoading) { + child = const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator())); + } else { + final invitations = widget.model.invitations; child = SliverFixedExtentList( itemExtent: 150, delegate: SliverChildBuilderDelegate((context, index) { - return Container(child: InvitationMaterial(invitation: snapshot.data![index])); - }, childCount: snapshot.data!.length), + return Container(child: InvitationMaterial(invitation: invitations[index])); + }, childCount: invitations.length), ); - } else { - child = const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator())); } return CustomScrollView( @@ -72,7 +63,7 @@ class _InvitationListPageState extends State { bottom: 0, child: IconButton( icon: const Icon(Icons.arrow_back_ios), - onPressed: () => bloc.signOutAndLeaveStudy(), + onPressed: () => widget.model.signOut(), ), ), Center( diff --git a/lib/ui/pages/invitation_page.dart b/lib/ui/pages/invitation_page.dart index 7757e138..fc709221 100644 --- a/lib/ui/pages/invitation_page.dart +++ b/lib/ui/pages/invitation_page.dart @@ -135,7 +135,7 @@ class InvitationDetailsPage extends StatelessWidget { decoration: BoxDecoration(color: const Color(0xff006398), borderRadius: BorderRadius.circular(100)), child: TextButton( onPressed: () { - bloc.setStudyInvitation(invitation, context); + model.accept(invitation); context.go(StudyPage.route); }, child: Text( diff --git a/lib/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index 44594365..a1331f8e 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -1,8 +1,41 @@ part of carp_study_app; +/// The view model for the [InvitationListPage] and [InvitationDetailsPage]. class InvitationsViewModel extends ViewModel { - List get invitations => bloc.auth.invitations; + InvitationsViewModel({AuthService? authService}) : _authService = authService; + + final AuthService? _authService; + AuthService get _auth => _authService ?? bloc.auth; + + List? _invitations; + Object? _error; + + /// The invitations loaded so far. Empty until [loadInvitations] completes. + List get invitations => _invitations ?? []; + + /// Is the (initial) list of invitations being loaded? + bool get isLoading => _invitations == null && _error == null; + + bool get hasError => _error != null; + + /// Load / refresh the list of invitations from CAWS. + Future loadInvitations() async { + try { + _invitations = await _auth.getInvitations(); + _error = null; + } catch (error) { + warning('$runtimeType - Could not load invitations - $error'); + _error = error; + } + notifyListeners(); + } ActiveParticipationInvitation getInvitation(String invitationId) => invitations.firstWhere((invitation) => invitation.participation.participantId == invitationId); + + /// Accept [invitation] and make it the active study in the app. + void accept(ActiveParticipationInvitation invitation) => bloc.setStudyInvitation(invitation); + + /// Sign out and return to the login page. + Future signOut() => bloc.signOutAndLeaveStudy(); } diff --git a/test/services_test.dart b/test/services_test.dart index 8f449e72..efe51b9e 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -1,5 +1,6 @@ import 'package:carp_backend/carp_backend.dart'; import 'package:carp_context_package/carp_context_package.dart'; +import 'package:carp_core/carp_core.dart' as core; import 'package:carp_webservices/carp_auth/carp_auth.dart'; import 'package:cognition_package/cognition_package.dart'; import 'package:fake_async/fake_async.dart'; @@ -166,13 +167,33 @@ void main() { expect(auth.friendlyUsername, 'John'); }); - test('delegates authentication state and invitations to the backend', () { + test('delegates authentication state to the backend', () { when(backend.isAuthenticated).thenReturn(true); - when(backend.invitations).thenReturn([]); expect(auth.isAuthenticated, isTrue); expect(auth.invitations, isEmpty); }); + + test('getInvitations keeps only invitations assigned to a smartphone', () async { + ActiveParticipationInvitation invitation(PrimaryDeviceConfiguration? device) { + final invitation = ActiveParticipationInvitation( + Participation('dep-1', 'participant-1', AssignedTo()), + StudyInvitation('Test study'), + ); + if (device != null) invitation.assignedDevices = [AssignedPrimaryDevice(device: device)]; + return invitation; + } + + final forPhone = invitation(Smartphone()); + final forOtherDevice = invitation(core.Smartphone(roleName: 'web')); + final unassigned = invitation(null); + when(backend.getInvitations()).thenAnswer((_) async => [forPhone, forOtherDevice, unassigned]); + + final invitations = await auth.getInvitations(); + + expect(invitations, [forPhone, unassigned]); + expect(auth.invitations, [forPhone, unassigned]); + }); }); group('ConsentService', () { diff --git a/test/view_models_test.dart b/test/view_models_test.dart index 1192a203..1a323eb1 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -112,6 +112,44 @@ void main() { }); }); + group('InvitationsViewModel', () { + late MockAuthService auth; + late InvitationsViewModel model; + + setUp(() { + auth = MockAuthService(); + model = InvitationsViewModel(authService: auth); + }); + + test('loads invitations and notifies', () async { + final invitation = ActiveParticipationInvitation( + Participation('dep-1', 'participant-1', AssignedTo()), + StudyInvitation('Test study'), + ); + when(auth.getInvitations()).thenAnswer((_) async => [invitation]); + var notified = false; + model.addListener(() => notified = true); + + expect(model.isLoading, isTrue); + await model.loadInvitations(); + + expect(model.isLoading, isFalse); + expect(model.invitations, [invitation]); + expect(model.getInvitation('participant-1'), invitation); + expect(notified, isTrue); + }); + + test('reports an error when loading fails', () async { + when(auth.getInvitations()).thenAnswer((_) async => throw Exception('offline')); + + await model.loadInvitations(); + + expect(model.hasError, isTrue); + expect(model.isLoading, isFalse); + expect(model.invitations, isEmpty); + }); + }); + group('ProfilePageViewModel', () { test('is empty-safe when signed out and nothing is deployed', () { final backend = MockCarpBackend(); From 9d0a9d55aa115bc07dca7e30b2dee517a8f05975 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 16:09:18 +0200 Subject: [PATCH 55/94] refactor(ui): move StudyPage refresh and state into its view-model StudyPageViewModel owns the pull-to-refresh sequence (messages, re-deploy, guarded start, status refresh), the one-shot app-update check, and the isConfigured/isAnonymous state; the page listens to the view-model instead of the bloc. The configuring loader gains pull-to-refresh as a retry affordance for a failed configuration, backed by the now-public bloc.tryConfigureStudy(). Refs #604, #608 --- lib/blocs/app_bloc.dart | 7 +- lib/ui/pages/study_page.dart | 98 ++++++++++++--------------- lib/view_models/study_page_model.dart | 57 +++++++++++++++- test/view_models_test.dart | 28 ++++++++ 4 files changed, 132 insertions(+), 58 deletions(-) diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 79829f03..5e4badb9 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -82,12 +82,13 @@ class StudyAppBLoC extends ChangeNotifier { void _onConsentChanged() { notifyListeners(); - if (consent.isAccepted == true) unawaited(_tryConfigureStudy()); + if (consent.isAccepted == true) unawaited(tryConfigureStudy()); } /// Run [configureStudy], surfacing a failure to the user instead of - /// throwing. Used by the setup flow, where no caller can handle the error. - Future _tryConfigureStudy() async { + /// throwing. Used by the setup flow and retry affordances, where no + /// caller can handle the error. + Future tryConfigureStudy() async { try { await configureStudy(); } catch (error) { diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 36146150..072a8bce 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -24,24 +24,26 @@ class StudyPageState extends State { child: const CarpAppBar(hasProfileIcon: true), ), Flexible( - // Re-render when configureStudy completes; until then show loader. + // Re-render when configureStudy completes; until then show a + // loader, with pull-to-refresh as the retry affordance. child: ListenableBuilder( - listenable: bloc, + listenable: widget.model, builder: (context, _) { - if (!bloc.isConfigured) { - return const _ConfiguringStudyLoader(); + if (!widget.model.isConfigured) { + return RefreshIndicator( + onRefresh: widget.model.retryConfiguration, + child: const CustomScrollView( + physics: AlwaysScrollableScrollPhysics(), + slivers: [SliverFillRemaining(hasScrollBody: false, child: _ConfiguringStudyLoader())], + ), + ); } return StreamBuilder( stream: widget.model.messageStream, builder: (context, AsyncSnapshot snapshot) { final cards = _buildCards(context); return RefreshIndicator( - onRefresh: () async { - await bloc.messages.refresh(); - await bloc.study.tryDeployment(); - await bloc.study.start(); - await bloc.study.refreshDeploymentStatus(); - }, + onRefresh: widget.model.refresh, child: ListView.builder(itemCount: cards.length, itemBuilder: (context, index) => cards[index]), ); }, @@ -57,8 +59,7 @@ class StudyPageState extends State { List _buildCards(BuildContext context) { final items = []; - final updateCard = _hasUpdateCard(); - items.add(updateCard); + if (widget.model.appUpdateAvailable) items.add(_hasUpdateCard()); items.add( _studyCard( context, @@ -69,7 +70,7 @@ class StudyPageState extends State { ), ); items.add(_studyStatusCard()); - if (LocalSettings().isAnonymous) { + if (widget.model.isAnonymousUser) { items.add(AnonymousCard()); } if (widget.model.messages.isNotEmpty) { @@ -87,47 +88,38 @@ class StudyPageState extends State { Widget _hasUpdateCard() { RPLocalizations locale = RPLocalizations.of(context)!; - return FutureBuilder( - future: bloc.system.getAppHasUpdate(), - builder: (context, snapshot) { - if (snapshot.data == true) { - return StudiesMaterial( - backgroundColor: Theme.of(context).extension()!.grey50!, - elevation: 8, - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - locale.translate('pages.about.app_update'), - style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(right: 16), - child: ElevatedButton( - onPressed: () async { - _redirectToUpdateStore(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: CACHET.DEPLOYMENT_DEPLOYING, - padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), - ), - child: Text(locale.translate("get"), style: TextStyle(color: Colors.white)), - ), - ), - ], + return StudiesMaterial( + backgroundColor: Theme.of(context).extension()!.grey50!, + elevation: 8, + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + locale.translate('pages.about.app_update'), + style: fs16fw600.copyWith(color: Theme.of(context).extension()!.grey900), + ), ), ), - ); - } else { - return SizedBox.shrink(); - } - }, + Padding( + padding: const EdgeInsets.only(right: 16), + child: ElevatedButton( + onPressed: () async { + _redirectToUpdateStore(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: CACHET.DEPLOYMENT_DEPLOYING, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + ), + child: Text(locale.translate("get"), style: TextStyle(color: Colors.white)), + ), + ), + ], + ), + ), ); } @@ -203,7 +195,7 @@ class StudyPageState extends State { RPLocalizations locale = RPLocalizations.of(context)!; return FutureBuilder( - future: bloc.study.refreshDeploymentStatus(), + future: widget.model.studyDeploymentStatus, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return StudiesMaterial( diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index 732f6f18..ed781fc4 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -3,15 +3,68 @@ 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. class StudyPageViewModel extends ViewModel { - StudyPageViewModel({StudyService? studyService, MessageService? messageService}) + StudyPageViewModel({StudyService? studyService, MessageService? messageService, SystemInfoService? systemInfoService}) : _studyService = studyService, - _messageService = messageService; + _messageService = messageService, + _systemInfoService = systemInfoService; final StudyService? _studyService; final MessageService? _messageService; + final SystemInfoService? _systemInfoService; + bool _attachedToApp = false; + bool _appUpdateAvailable = false; StudyService get _study => _studyService ?? bloc.study; MessageService get _messages => _messageService ?? bloc.messages; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + + // Relay app-state changes (e.g. configuration completing) to the page, so + // it only needs to listen to this view model. Attached lazily since the + // global bloc is not available while this view model is constructed. + @override + void addListener(VoidCallback listener) { + if (!_attachedToApp) { + _attachedToApp = true; + bloc.addListener(notifyListeners); + } + super.addListener(listener); + } + + /// Is the app fully configured with the study? + bool get isConfigured => bloc.isConfigured; + + /// Is the user signed in anonymously? + bool get isAnonymousUser => LocalSettings().isAnonymous; + + /// Is a newer version of this app available? Checked once on [init]. + bool get appUpdateAvailable => _appUpdateAvailable; + + @override + void init(SmartphoneStudyController ctrl) { + super.init(ctrl); + unawaited(_checkAppUpdate()); + } + + Future _checkAppUpdate() async { + try { + _appUpdateAvailable = await _system.getAppHasUpdate() ?? false; + } catch (error) { + _appUpdateAvailable = false; + } + notifyListeners(); + } + + /// Re-fetch messages, re-try deployment if needed, (re)start sensing, and + /// refresh the deployment status. Used on pull-to-refresh. + Future refresh() async { + await _messages.refresh(); + await _study.tryDeployment(); + await _study.start(); + await _study.refreshDeploymentStatus(); + } + + /// Retry study configuration after a failure. + Future retryConfiguration() => bloc.tryConfigureStudy(); String get title => _study.deployment?.studyDescription?.title ?? 'Unnamed'; String get description => _study.deployment?.studyDescription?.description ?? ''; diff --git a/test/view_models_test.dart b/test/view_models_test.dart index 1a323eb1..4463e91e 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -64,6 +64,34 @@ void main() { // The service keeps them newest first; the page shows them reversed. expect(model.messages.map((m) => m.id), ['old', 'new']); }); + + test('refresh completes safely when the study is not yet deployed', () async { + final manager = _FakeMessageManager()..toReturn = [Message(id: 'a', timestamp: DateTime(2024, 1, 1))]; + final messages = MessageService(manager); + final model = StudyPageViewModel(studyService: StudyService(), messageService: messages); + + await model.refresh(); // no deployment, no controller - must not throw + + expect(model.messages.length, 1); + }); + + test('init checks for an app update and notifies', () async { + final system = MockSystemInfoService(); + when(system.getAppHasUpdate()).thenAnswer((_) async => true); + final model = StudyPageViewModel( + studyService: StudyService(), + messageService: MessageService(_FakeMessageManager()), + systemInfoService: system, + ); + var notified = false; + model.addListener(() => notified = true); + + model.init(MockSmartphoneStudyController()); + await Future.delayed(Duration.zero); + + expect(model.appUpdateAvailable, isTrue); + expect(notified, isTrue); + }); }); group('LoginViewModel', () { From 4f728371813f6c21299b54dc55dd2462c262d9d0 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 16:13:08 +0200 Subject: [PATCH 56/94] refactor(ui): task-list and data-viz pages driven by their view-models TaskListPageViewModel owns participant-data loading and the 10-second auto-complete of background sensing tasks - timers are stored and cancelled on clear/dispose, and the completion snackbar is only shown on a mounted page. DataVisualizationPageViewModel precomputes card availability once on init instead of re-scanning the deployment with sampling-package constants on every rebuild. Refs #604, #609, #610 --- lib/main.g.dart | 145 ++++--- lib/ui/pages/data_visualization_page.dart | 18 +- lib/ui/pages/task_list_page.dart | 58 +-- .../data_visualization_page_model.dart | 36 +- lib/view_models/tasklist_page_model.dart | 67 ++- test/services_test.dart | 8 +- test/services_test.mocks.dart | 405 ++++++++++++++++-- test/view_models_test.dart | 80 ++++ 8 files changed, 686 insertions(+), 131 deletions(-) diff --git a/lib/main.g.dart b/lib/main.g.dart index 95750247..605ef375 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,78 @@ 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), + ), + ) + ..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(), +); -Map _$HeartRateMinMaxPrHourToJson(HeartRateMinMaxPrHour instance) => { - 'min': ?instance.min, - 'max': ?instance.max, -}; +Map _$HeartRateMinMaxPrHourToJson( + HeartRateMinMaxPrHour instance, +) => {'min': ?instance.min, 'max': ?instance.max}; diff --git a/lib/ui/pages/data_visualization_page.dart b/lib/ui/pages/data_visualization_page.dart index 337d700e..6bd7a5a4 100644 --- a/lib/ui/pages/data_visualization_page.dart +++ b/lib/ui/pages/data_visualization_page.dart @@ -77,43 +77,43 @@ class _DataVisualizationPageState extends State { final List widgets = []; // Show user task progress, if study has any tasks. - if (bloc.study.hasUserTasks()) { + if (widget.model.hasUserTasks) { widgets.add(StudyProgressCardWidget(widget.model.studyProgressCardDataModel)); } // Show HR if there is a POLAR or MOVESENSE device in the study - if (bloc.study.hasMeasure(PolarSamplingPackage.HR) || bloc.study.hasMeasure(MovesenseSamplingPackage.HR)) { + if (widget.model.hasHeartRateMeasure) { widgets.add(HeartRateOuterStatefulWidget(widget.model.heartRateCardDataModel)); } // check to show surveys stats - if (bloc.study.hasUserTasks()) { + 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 (bloc.study.hasMeasure(MediaSamplingPackage.AUDIO)) { + if (widget.model.hasAudioMeasure) { mediaModelsList.add(widget.model.audioCardDataModel); } - if (bloc.study.hasMeasure(MediaSamplingPackage.VIDEO)) { + if (widget.model.hasVideoMeasure) { mediaModelsList.add(widget.model.videoCardDataModel); } - if (bloc.study.hasMeasure(MediaSamplingPackage.IMAGE)) { + if (widget.model.hasImageMeasure) { mediaModelsList.add(widget.model.imageCardDataModel); } if (mediaModelsList.isNotEmpty) { widgets.add(MediaCardWidget(mediaModelsList)); } - if (bloc.study.hasMeasure(CarpDataTypes.STEP_COUNT)) { + if (widget.model.hasStepsMeasure) { widgets.add(StepsCardWidget(widget.model.stepsCardDataModel)); } - if (bloc.study.hasMeasure(ContextSamplingPackage.ACTIVITY)) { + if (widget.model.hasActivityMeasure) { widgets.add(ActivityCard(widget.model.activityCardDataModel)); } - if (bloc.study.hasMeasure(ContextSamplingPackage.MOBILITY)) { + if (widget.model.hasMobilityMeasure) { widgets.add(MobilityCard(widget.model.mobilityCardDataModel)); widgets.add(DistanceCard(widget.model.mobilityCardDataModel)); } diff --git a/lib/ui/pages/task_list_page.dart b/lib/ui/pages/task_list_page.dart index b0b2a40f..54cc55cc 100644 --- a/lib/ui/pages/task_list_page.dart +++ b/lib/ui/pages/task_list_page.dart @@ -43,23 +43,44 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { class TaskListPageState extends State with TickerProviderStateMixin { late TabController _tabController; - bool showParticipantDataCard = false; - @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - bloc.study.getParticipantDataListFromDeployment().then((value) { - setState(() { - showParticipantDataCard = value.isEmpty; - }); - }); + widget.model.addListener(_onModelChanged); + widget.model.checkParticipantData(); _tabController.addListener(() { setState(() {}); }); } + @override + void dispose() { + widget.model.removeListener(_onModelChanged); + _tabController.dispose(); + super.dispose(); + } + + void _onModelChanged() { + if (!mounted) return; + + final autoCompleted = widget.model.autoCompletedTask; + if (autoCompleted != null) { + widget.model.autoCompletedTaskShown(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Theme.of(context).extension()!.grey700, + content: Text(RPLocalizations.of(context)!.translate('Done!')), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + duration: const Duration(seconds: 1), + ), + ); + } + + setState(() {}); + } + @override Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; @@ -135,7 +156,8 @@ class TaskListPageState extends State with TickerProviderStateMixi ), ), ), - if (showParticipantDataCard) SliverToBoxAdapter(child: _buildParticipantDataCard()), + if (widget.model.showParticipantDataCard) + SliverToBoxAdapter(child: _buildParticipantDataCard()), SliverList( delegate: SliverChildBuilderDelegate((BuildContext context, int index) { UserTask userTask = widget.model.tasks[index]; @@ -336,24 +358,8 @@ class TaskListPageState extends State with TickerProviderStateMixi ), ), onTap: () { - // only start if not already started, done, or expired - if (userTask.state == UserTaskState.enqueued || userTask.state == UserTaskState.canceled) { - userTask.onStart(); - if (userTask.hasWidget) { - context.push('/task/${userTask.id}'); - } else { - Timer(const Duration(seconds: 10), () { - userTask.onDone(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Theme.of(context).extension()!.grey700, - content: Text(locale.translate('Done!')), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), - duration: const Duration(seconds: 1), - ), - ); - }); - } + if (widget.model.startUserTask(userTask)) { + context.push('/task/${userTask.id}'); } }, ), diff --git a/lib/view_models/data_visualization_page_model.dart b/lib/view_models/data_visualization_page_model.dart index c2bc0117..7229918a 100644 --- a/lib/view_models/data_visualization_page_model.dart +++ b/lib/view_models/data_visualization_page_model.dart @@ -1,6 +1,30 @@ part of carp_study_app; class DataVisualizationPageViewModel extends ViewModel { + DataVisualizationPageViewModel({StudyService? studyService}) : _studyService = studyService; + + final StudyService? _studyService; + StudyService get _study => _studyService ?? bloc.study; + + bool _hasUserTasks = false; + bool _hasHeartRateMeasure = false; + bool _hasAudioMeasure = false; + bool _hasVideoMeasure = false; + bool _hasImageMeasure = false; + bool _hasStepsMeasure = false; + bool _hasActivityMeasure = false; + bool _hasMobilityMeasure = false; + + // Card availability for the current deployment, computed once in [init]. + bool get hasUserTasks => _hasUserTasks; + bool get hasHeartRateMeasure => _hasHeartRateMeasure; + bool get hasAudioMeasure => _hasAudioMeasure; + bool get hasVideoMeasure => _hasVideoMeasure; + bool get hasImageMeasure => _hasImageMeasure; + bool get hasStepsMeasure => _hasStepsMeasure; + bool get hasActivityMeasure => _hasActivityMeasure; + bool get hasMobilityMeasure => _hasMobilityMeasure; + final ActivityCardViewModel _activityCardDataModel = ActivityCardViewModel(); final StepsCardViewModel _stepsCardDataModel = StepsCardViewModel(); final MeasurementsCardViewModel _measuresCardDataModel = MeasurementsCardViewModel(); @@ -35,11 +59,19 @@ class DataVisualizationPageViewModel extends ViewModel { /// The number of tasks completed so far. int get taskCompleted => AppTaskController().userTaskQueue.where((task) => task.state == UserTaskState.done).length; - DataVisualizationPageViewModel(); - @override void init(SmartphoneStudyController ctrl) { super.init(ctrl); + + _hasUserTasks = _study.hasUserTasks(); + _hasHeartRateMeasure = _study.hasMeasure(PolarSamplingPackage.HR) || _study.hasMeasure(MovesenseSamplingPackage.HR); + _hasAudioMeasure = _study.hasMeasure(MediaSamplingPackage.AUDIO); + _hasVideoMeasure = _study.hasMeasure(MediaSamplingPackage.VIDEO); + _hasImageMeasure = _study.hasMeasure(MediaSamplingPackage.IMAGE); + _hasStepsMeasure = _study.hasMeasure(CarpDataTypes.STEP_COUNT); + _hasActivityMeasure = _study.hasMeasure(ContextSamplingPackage.ACTIVITY); + _hasMobilityMeasure = _study.hasMeasure(ContextSamplingPackage.MOBILITY); + _activityCardDataModel.init(ctrl); _stepsCardDataModel.init(ctrl); _heartRateCardDataModel.init(ctrl); diff --git a/lib/view_models/tasklist_page_model.dart b/lib/view_models/tasklist_page_model.dart index 741880f9..ab6dd28e 100644 --- a/lib/view_models/tasklist_page_model.dart +++ b/lib/view_models/tasklist_page_model.dart @@ -2,7 +2,72 @@ part of carp_study_app; /// A view model for the [TaskListPage]. class TaskListPageViewModel extends ViewModel { - TaskListPageViewModel(); + TaskListPageViewModel({StudyService? studyService}) : _studyService = studyService; + + final StudyService? _studyService; + StudyService get _study => _studyService ?? bloc.study; + + bool _showParticipantDataCard = false; + final Map _autoCompleteTimers = {}; + UserTask? _autoCompletedTask; + + /// Should the card prompting for participant data be shown? + bool get showParticipantDataCard => _showParticipantDataCard; + + /// The task most recently auto-completed by [startUserTask], if any. + /// One-shot - the page calls [autoCompletedTaskShown] once it has shown + /// a confirmation. + UserTask? get autoCompletedTask => _autoCompletedTask; + + void autoCompletedTaskShown() => _autoCompletedTask = null; + + /// Check whether participant data is still missing, updating + /// [showParticipantDataCard]. + Future checkParticipantData() async { + final data = await _study.getParticipantDataListFromDeployment(); + _showParticipantDataCard = data.isEmpty; + notifyListeners(); + } + + /// Start [userTask], if it is not already started, done, or expired. + /// Returns true when the task opens its own page. Otherwise the task is a + /// background sensing task, which auto-completes after 10 seconds - even + /// if the user navigates away in the meantime. + bool startUserTask(UserTask userTask) { + if (userTask.state != UserTaskState.enqueued && userTask.state != UserTaskState.canceled) return false; + + userTask.onStart(); + if (userTask.hasWidget) return true; + + _autoCompleteTimers[userTask.id] = Timer(const Duration(seconds: 10), () { + _autoCompleteTimers.remove(userTask.id); + userTask.onDone(); + _autoCompletedTask = userTask; + notifyListeners(); + }); + return false; + } + + void _cancelAutoCompleteTimers() { + for (var timer in _autoCompleteTimers.values) { + timer.cancel(); + } + _autoCompleteTimers.clear(); + } + + @override + void clear() { + _cancelAutoCompleteTimers(); + _showParticipantDataCard = false; + _autoCompletedTask = null; + super.clear(); + } + + @override + void dispose() { + _cancelAutoCompleteTimers(); + super.dispose(); + } List get tasks { var tasks = AppTaskController().userTaskQueue; diff --git a/test/services_test.dart b/test/services_test.dart index efe51b9e..f922c5c3 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -59,7 +59,13 @@ class _FakeConsentManager extends InformedConsentManager { Future deleteConsentDocument() async => true; } -@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) void main() { setUpAll(() async { CarpMobileSensing.ensureInitialized(); diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart index af0936bd..eca70298 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 _i5; -import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i6; -import 'package:carp_study_app/main.dart' as _i4; +import 'package:carp_core/carp_core.dart' as _i6; +import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i4; +import 'package:carp_study_app/main.dart' as _i5; 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; @@ -45,10 +45,27 @@ class _FakeCarpUser_3 extends _i1.SmartFake implements _i3.CarpUser { _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); +} + +class _FakeDateTime_5 extends _i1.SmartFake implements DateTime { + _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); +} + +class _FakeBackgroundTaskExecutor_7 extends _i1.SmartFake implements _i4.BackgroundTaskExecutor { + _FakeBackgroundTaskExecutor_7(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 _i4.CarpBackend { +class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { @override Uri get uri => (super.noSuchMethod( @@ -91,23 +108,23 @@ class MockCarpBackend extends _i1.Mock implements _i4.CarpBackend { as bool); @override - List<_i5.ActiveParticipationInvitation> get invitations => + List<_i6.ActiveParticipationInvitation> get invitations => (super.noSuchMethod( Invocation.getter(#invitations), - returnValue: <_i5.ActiveParticipationInvitation>[], - returnValueForMissingStub: <_i5.ActiveParticipationInvitation>[], + returnValue: <_i6.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i6.ActiveParticipationInvitation>[], ) - as List<_i5.ActiveParticipationInvitation>); + as List<_i6.ActiveParticipationInvitation>); @override set user(_i3.CarpUser? user) => super.noSuchMethod(Invocation.setter(#user, user), returnValueForMissingStub: null); @override - set invitations(List<_i5.ActiveParticipationInvitation>? value) => + set invitations(List<_i6.ActiveParticipationInvitation>? value) => super.noSuchMethod(Invocation.setter(#invitations, value), returnValueForMissingStub: null); @override - set study(_i6.SmartphoneStudy? study) => + set study(_i4.SmartphoneStudy? study) => super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); @override @@ -158,40 +175,40 @@ class MockCarpBackend extends _i1.Mock implements _i4.CarpBackend { as _i7.Future); @override - _i7.Future> getInvitations() => + _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], + returnValue: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], ), - returnValueForMissingStub: _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], + returnValueForMissingStub: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], ), ) - as _i7.Future>); + as _i7.Future>); @override - _i7.Future<_i5.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => + _i7.Future<_i6.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => (super.noSuchMethod( Invocation.method(#uploadInformedConsent, [consent]), - returnValue: _i7.Future<_i5.InformedConsentInput?>.value(), - returnValueForMissingStub: _i7.Future<_i5.InformedConsentInput?>.value(), + returnValue: _i7.Future<_i6.InformedConsentInput?>.value(), + returnValueForMissingStub: _i7.Future<_i6.InformedConsentInput?>.value(), ) - as _i7.Future<_i5.InformedConsentInput?>); + as _i7.Future<_i6.InformedConsentInput?>); @override - _i7.Future<_i5.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => + _i7.Future<_i6.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => (super.noSuchMethod( Invocation.method(#getInformedConsentByRole, [studyDeploymentId, role]), returnValueForMissingStub: null, ) - as _i7.Future<_i5.InformedConsentInput?>?); + as _i7.Future<_i6.InformedConsentInput?>?); } /// A class which mocks [AuthService]. /// /// See the documentation for Mockito's code generation for more information. -class MockAuthService extends _i1.Mock implements _i4.AuthService { +class MockAuthService extends _i1.Mock implements _i5.AuthService { @override bool get isAuthenticated => (super.noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false, returnValueForMissingStub: false) @@ -225,13 +242,13 @@ class MockAuthService extends _i1.Mock implements _i4.AuthService { as Uri); @override - List<_i5.ActiveParticipationInvitation> get invitations => + List<_i6.ActiveParticipationInvitation> get invitations => (super.noSuchMethod( Invocation.getter(#invitations), - returnValue: <_i5.ActiveParticipationInvitation>[], - returnValueForMissingStub: <_i5.ActiveParticipationInvitation>[], + returnValue: <_i6.ActiveParticipationInvitation>[], + returnValueForMissingStub: <_i6.ActiveParticipationInvitation>[], ) - as List<_i5.ActiveParticipationInvitation>); + as List<_i6.ActiveParticipationInvitation>); @override _i7.Future initialize() => @@ -243,17 +260,17 @@ class MockAuthService extends _i1.Mock implements _i4.AuthService { as _i7.Future); @override - _i7.Future> getInvitations() => + _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], + returnValue: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], ), - returnValueForMissingStub: _i7.Future>.value( - <_i5.ActiveParticipationInvitation>[], + returnValueForMissingStub: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], ), ) - as _i7.Future>); + as _i7.Future>); @override _i7.Future authenticate() => @@ -286,7 +303,7 @@ class MockAuthService extends _i1.Mock implements _i4.AuthService { /// A class which mocks [SystemInfoService]. /// /// See the documentation for Mockito's code generation for more information. -class MockSystemInfoService extends _i1.Mock implements _i4.SystemInfoService { +class MockSystemInfoService extends _i1.Mock implements _i5.SystemInfoService { @override _i7.Future checkConnectivity() => (super.noSuchMethod( @@ -314,3 +331,323 @@ class MockSystemInfoService extends _i1.Mock implements _i4.SystemInfoService { ) as _i7.Future); } + +/// A class which mocks [UserTask]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUserTask extends _i1.Mock implements _i4.UserTask { + @override + _i4.AppTask get task => + (super.noSuchMethod( + Invocation.getter(#task), + returnValue: _FakeAppTask_4(this, Invocation.getter(#task)), + returnValueForMissingStub: _FakeAppTask_4(this, Invocation.getter(#task)), + ) + as _i4.AppTask); + + @override + String get id => + (super.noSuchMethod( + Invocation.getter(#id), + returnValue: _i9.dummyValue(this, Invocation.getter(#id)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#id)), + ) + as String); + + @override + String get type => + (super.noSuchMethod( + Invocation.getter(#type), + returnValue: _i9.dummyValue(this, Invocation.getter(#type)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#type)), + ) + as String); + + @override + String get name => + (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i9.dummyValue(this, Invocation.getter(#name)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#name)), + ) + as String); + + @override + String get title => + (super.noSuchMethod( + Invocation.getter(#title), + returnValue: _i9.dummyValue(this, Invocation.getter(#title)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#title)), + ) + as String); + + @override + String get description => + (super.noSuchMethod( + Invocation.getter(#description), + returnValue: _i9.dummyValue(this, Invocation.getter(#description)), + returnValueForMissingStub: _i9.dummyValue(this, Invocation.getter(#description)), + ) + as String); + + @override + String get instructions => + (super.noSuchMethod( + 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) + as bool); + + @override + DateTime get triggerTime => + (super.noSuchMethod( + Invocation.getter(#triggerTime), + returnValue: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), + returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#triggerTime)), + ) + as DateTime); + + @override + DateTime get enqueued => + (super.noSuchMethod( + Invocation.getter(#enqueued), + returnValue: _FakeDateTime_5(this, Invocation.getter(#enqueued)), + returnValueForMissingStub: _FakeDateTime_5(this, Invocation.getter(#enqueued)), + ) + as DateTime); + + @override + _i4.UserTaskState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _i4.UserTaskState.initialized, + returnValueForMissingStub: _i4.UserTaskState.initialized, + ) + as _i4.UserTaskState); + + @override + bool get availableForUser => + (super.noSuchMethod(Invocation.getter(#availableForUser), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + bool get hasNotificationBeenCreated => + (super.noSuchMethod( + Invocation.getter(#hasNotificationBeenCreated), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); + + @override + _i7.Stream<_i4.UserTaskState> get stateEvents => + (super.noSuchMethod( + Invocation.getter(#stateEvents), + returnValue: _i7.Stream<_i4.UserTaskState>.empty(), + returnValueForMissingStub: _i7.Stream<_i4.UserTaskState>.empty(), + ) + as _i7.Stream<_i4.UserTaskState>); + + @override + _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)), + ) + as _i4.AppTaskExecutor<_i4.AppTask>); + + @override + _i4.BackgroundTaskExecutor get backgroundTaskExecutor => + (super.noSuchMethod( + 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); + + @override + 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); + + @override + 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); + + @override + 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); + + @override + set backgroundTaskExecutor(_i4.BackgroundTaskExecutor? value) => + super.noSuchMethod(Invocation.setter(#backgroundTaskExecutor, value), returnValueForMissingStub: null); + + @override + set result(_i6.Data? value) => super.noSuchMethod(Invocation.setter(#result, value), returnValueForMissingStub: null); + + @override + void onStart() => super.noSuchMethod(Invocation.method(#onStart, []), returnValueForMissingStub: null); + + @override + void onCancel({bool? dequeue = false}) => + super.noSuchMethod(Invocation.method(#onCancel, [], {#dequeue: dequeue}), returnValueForMissingStub: null); + + @override + void onExpired() => super.noSuchMethod(Invocation.method(#onExpired, []), returnValueForMissingStub: null); + + @override + void onDone({bool? dequeue = false, _i6.Data? result}) => super.noSuchMethod( + Invocation.method(#onDone, [], {#dequeue: dequeue, #result: result}), + returnValueForMissingStub: null, + ); + + @override + 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 { + @override + bool get hasStudy => + (super.noSuchMethod(Invocation.getter(#hasStudy), returnValue: false, returnValueForMissingStub: false) as bool); + + @override + bool get isDeployed => + (super.noSuchMethod(Invocation.getter(#isDeployed), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + Set<_i6.ExpectedParticipantData?> get expectedParticipantData => + (super.noSuchMethod( + Invocation.getter(#expectedParticipantData), + returnValue: <_i6.ExpectedParticipantData?>{}, + returnValueForMissingStub: <_i6.ExpectedParticipantData?>{}, + ) + as Set<_i6.ExpectedParticipantData?>); + + @override + bool get isRunning => + (super.noSuchMethod(Invocation.getter(#isRunning), returnValue: false, returnValueForMissingStub: false) as bool); + + @override + Iterable<_i5.DeviceViewModel> get deploymentDevices => + (super.noSuchMethod( + Invocation.getter(#deploymentDevices), + returnValue: <_i5.DeviceViewModel>[], + returnValueForMissingStub: <_i5.DeviceViewModel>[], + ) + as Iterable<_i5.DeviceViewModel>); + + @override + set study(_i4.SmartphoneStudy? study) => + super.noSuchMethod(Invocation.setter(#study, study), returnValueForMissingStub: null); + + @override + _i7.Future<_i6.StudyDeploymentStatus?> refreshDeploymentStatus() => + (super.noSuchMethod( + Invocation.method(#refreshDeploymentStatus, []), + returnValue: _i7.Future<_i6.StudyDeploymentStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i6.StudyDeploymentStatus?>.value(), + ) + as _i7.Future<_i6.StudyDeploymentStatus?>); + + @override + _i7.Future configure() => + (super.noSuchMethod( + Invocation.method(#configure, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future<_i6.StudyStatus?> tryDeployment() => + (super.noSuchMethod( + Invocation.method(#tryDeployment, []), + returnValue: _i7.Future<_i6.StudyStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i6.StudyStatus?>.value(), + ) + as _i7.Future<_i6.StudyStatus?>); + + @override + _i7.Future start() => + (super.noSuchMethod( + Invocation.method(#start, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + void addMeasurement(_i6.Measurement? measurement) => + super.noSuchMethod(Invocation.method(#addMeasurement, [measurement]), returnValueForMissingStub: null); + + @override + bool hasMeasures() => + (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) + as bool); + + @override + bool hasUserTasks() => + (super.noSuchMethod(Invocation.method(#hasUserTasks, []), returnValue: false, returnValueForMissingStub: false) + as bool); + + @override + _i7.Future> getParticipantDataListFromDeployment() => + (super.noSuchMethod( + Invocation.method(#getParticipantDataListFromDeployment, []), + returnValue: _i7.Future>.value(<_i6.ParticipantData>[]), + returnValueForMissingStub: _i7.Future>.value(<_i6.ParticipantData>[]), + ) + as _i7.Future>); + + @override + void setParticipantData(Map? data) => + super.noSuchMethod(Invocation.method(#setParticipantData, [data]), returnValueForMissingStub: null); + + @override + _i7.Future deployLocalProtocol() => + (super.noSuchMethod( + Invocation.method(#deployLocalProtocol, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + + @override + _i7.Future remove() => + (super.noSuchMethod( + Invocation.method(#remove, []), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); +} diff --git a/test/view_models_test.dart b/test/view_models_test.dart index 4463e91e..1f3ea097 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -1,5 +1,7 @@ import 'package:carp_backend/carp_backend.dart'; +import 'package:carp_audio_package/media.dart'; import 'package:cognition_package/cognition_package.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:research_package/research_package.dart'; import 'exports.dart'; @@ -178,6 +180,84 @@ void main() { }); }); + group('TaskListPageViewModel', () { + MockUserTask backgroundTask() { + final task = MockUserTask(); + when(task.state).thenReturn(UserTaskState.enqueued); + when(task.hasWidget).thenReturn(false); + when(task.id).thenReturn('task-1'); + return task; + } + + test('startUserTask auto-completes a background task after 10 seconds', () { + fakeAsync((async) { + final task = backgroundTask(); + final model = TaskListPageViewModel(studyService: StudyService()); + + expect(model.startUserTask(task), isFalse); + verify(task.onStart()).called(1); + verifyNever(task.onDone()); + + async.elapse(const Duration(seconds: 11)); + + verify(task.onDone()).called(1); + expect(model.autoCompletedTask, task); + }); + }); + + test('startUserTask does not start an already done task', () { + final task = MockUserTask(); + when(task.state).thenReturn(UserTaskState.done); + + expect(TaskListPageViewModel(studyService: StudyService()).startUserTask(task), isFalse); + verifyNever(task.onStart()); + }); + + test('clear cancels pending auto-complete timers', () { + fakeAsync((async) { + final task = backgroundTask(); + final model = TaskListPageViewModel(studyService: StudyService()); + + model.startUserTask(task); + model.clear(); + async.elapse(const Duration(seconds: 11)); + + verifyNever(task.onDone()); + expect(model.autoCompletedTask, isNull); + }); + }); + + test('checkParticipantData shows the card when no data is set', () async { + final model = TaskListPageViewModel(studyService: StudyService()); + var notified = false; + model.addListener(() => notified = true); + + await model.checkParticipantData(); // no deployment - data list is empty + + expect(model.showParticipantDataCard, isTrue); + expect(notified, isTrue); + }); + }); + + group('DataVisualizationPageViewModel', () { + 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); + + model.init(MockSmartphoneStudyController()); + + expect(model.hasUserTasks, isTrue); + expect(model.hasHeartRateMeasure, isTrue); + expect(model.hasAudioMeasure, isTrue); + expect(model.hasVideoMeasure, isFalse); + expect(model.hasStepsMeasure, isFalse); + expect(model.hasMobilityMeasure, isFalse); + }); + }); + group('ProfilePageViewModel', () { test('is empty-safe when signed out and nothing is deployed', () { final backend = MockCarpBackend(); From 2173fb5bbf80906bf69e8d809cdf48bdc36be6e5 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 16:15:52 +0200 Subject: [PATCH 57/94] refactor(ui): route remaining one-shot actions through view-models Profile (connectivity check, sign-out, leave study), informed consent (accept-without-document, consent-abandoned), participant data submission, message-details lookup, and the device-list categorization all go through their page view-models. DeviceListPage now receives its view-model, which owns the smartphone/hardware/online-service device filtering. Refs #604, #612 --- lib/carp_study_app.dart | 6 +- lib/main.g.dart | 145 +++++++----------- lib/ui/pages/device_list_page.dart | 20 +-- .../devices_page.health_service_connect.dart | 4 +- lib/ui/pages/informed_consent_page.dart | 4 +- lib/ui/pages/message_details_page.dart | 14 +- lib/ui/pages/profile_page.dart | 6 +- lib/ui/tasks/participant_data_page.dart | 2 +- lib/view_models/device_view_models.dart | 25 ++- .../informed_consent_page_model.dart | 6 + .../participant_data_page_model.dart | 3 + lib/view_models/profile_page_model.dart | 16 +- lib/view_models/study_page_model.dart | 12 ++ 13 files changed, 133 insertions(+), 130 deletions(-) diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index d3e93cf2..2d23a168 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -103,8 +103,10 @@ class CarpStudyAppState extends State { GoRoute( path: DeviceListPage.route, parentNavigatorKey: _shellNavigatorKey, - pageBuilder: (context, state) => - const CustomTransitionPage(child: DeviceListPage(), transitionsBuilder: bottomNavigationBarAnimation), + pageBuilder: (context, state) => CustomTransitionPage( + child: DeviceListPage(model: bloc.appViewModel.devicesPageViewModel), + transitionsBuilder: bottomNavigationBarAnimation, + ), ), GoRoute( path: ProfilePage.route, diff --git a/lib/main.g.dart b/lib/main.g.dart index 605ef375..95750247 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,78 +48,57 @@ 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), - ), - ) - ..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)), + ) + ..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, +}; -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/pages/device_list_page.dart b/lib/ui/pages/device_list_page.dart index 7d333722..7d701888 100644 --- a/lib/ui/pages/device_list_page.dart +++ b/lib/ui/pages/device_list_page.dart @@ -6,7 +6,8 @@ part of carp_study_app; /// * Any online services (connected services) class DeviceListPage extends StatefulWidget { static const String route = '/devices'; - const DeviceListPage({super.key}); + final DeviceListPageViewModel model; + const DeviceListPage({required this.model, super.key}); @override DeviceListPageState createState() => DeviceListPageState(); @@ -16,20 +17,9 @@ class DeviceListPageState extends State { StreamSubscription? bluetoothStateStream; BluetoothAdapterState? bluetoothAdapterState; - final List _smartphoneDevice = bloc.study.deploymentDevices - .where((element) => element.deviceManager is SmartphoneDeviceManager) - .toList(); - - final List _hardwareDevices = bloc.study.deploymentDevices - .where( - (element) => - element.deviceManager is HardwareDeviceManager && element.deviceManager is! SmartphoneDeviceManager, - ) - .toList(); - - final List _onlineServices = bloc.study.deploymentDevices - .where((element) => element.deviceManager is ServiceManager) - .toList(); + late final List _smartphoneDevice = widget.model.smartphoneDevice; + late final List _hardwareDevices = widget.model.hardwareDevices; + late final List _onlineServices = widget.model.onlineServices; @override void initState() { diff --git a/lib/ui/pages/devices_page.health_service_connect.dart b/lib/ui/pages/devices_page.health_service_connect.dart index 763af415..a68fb901 100644 --- a/lib/ui/pages/devices_page.health_service_connect.dart +++ b/lib/ui/pages/devices_page.health_service_connect.dart @@ -7,9 +7,7 @@ class HealthServiceConnectPage extends StatelessWidget { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - DeviceViewModel healthServive = bloc.study.deploymentDevices - .where((element) => element.deviceManager is ServiceManager && element.type == HealthService.DEVICE_TYPE) - .first; + DeviceViewModel healthServive = bloc.appViewModel.devicesPageViewModel.healthService!; return Scaffold( backgroundColor: Theme.of(context).extension()!.grey100, diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index e4dce5a8..d839ee02 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -30,7 +30,7 @@ class InformedConsentState extends State { void dispose() { // Bypassing onCancel entirely is intentional — see issue carp-dk/research.package#168. if (!_submitted) { - bloc.leaveStudy(); + widget.model.abandonConsent(); } super.dispose(); } @@ -48,7 +48,7 @@ class InformedConsentState extends State { // the study back down. if (document == null && !_submitted) { _submitted = true; - await bloc.consent.accept(); + await widget.model.acceptWithoutDocument(); if (mounted) context.go(CarpStudyAppState.homeRoute); } return document; diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index 7b15b0d5..a0444e3c 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -10,19 +10,7 @@ class MessageDetailsPage extends StatelessWidget { Widget build(BuildContext context) { RPLocalizations locale = RPLocalizations.of(context)!; - Message message = bloc.messages.messages.firstWhere( - (element) => element.id == messageId, - orElse: () { - return Message( - id: '0', - title: 'Unknown message', - subTitle: 'Unknown message', - type: MessageType.announcement, - timestamp: DateTime.now(), - image: './assets/images/kids.png', - ); - }, - ); + Message message = bloc.appViewModel.studyPageViewModel.messageById(messageId); return Scaffold( body: SafeArea( diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index fff26e09..531c0e42 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -146,7 +146,7 @@ class ProfilePageState extends State { leading: const Icon(Icons.power_settings_new, color: CACHET.RED_1), title: locale.translate('pages.profile.log_out'), onTap: () async { - bool isConnected = await bloc.system.checkConnectivity(); + bool isConnected = await widget.model.checkConnectivity(); if (isConnected) { _showLogoutConfirmationDialog(); } else { @@ -251,7 +251,7 @@ class ProfilePageState extends State { child: Text(locale.translate("YES")), onPressed: () async { if (builderContext.mounted) { - await bloc.signOutAndLeaveStudy(); + await widget.model.signOutAndLeaveStudy(); builderContext.pop(); builderContext.go(CarpStudyAppState.homeRoute); } @@ -284,7 +284,7 @@ class ProfilePageState extends State { child: Text(locale.translate("YES")), onPressed: () async { if (builderContext.mounted) { - await bloc.leaveStudy(); + await widget.model.leaveStudy(); builderContext.pop(); builderContext.go(InvitationListPage.route); } diff --git a/lib/ui/tasks/participant_data_page.dart b/lib/ui/tasks/participant_data_page.dart index 800b864e..109638bb 100644 --- a/lib/ui/tasks/participant_data_page.dart +++ b/lib/ui/tasks/participant_data_page.dart @@ -702,7 +702,7 @@ class ParticipantDataPageState extends State { } } - bloc.study.setParticipantData(participantData); + widget.model.setParticipantData(participantData); } Future _showCancelConfirmationDialog() { diff --git a/lib/view_models/device_view_models.dart b/lib/view_models/device_view_models.dart index 490be5fb..1370d1f2 100644 --- a/lib/view_models/device_view_models.dart +++ b/lib/view_models/device_view_models.dart @@ -2,8 +2,29 @@ part of carp_study_app; /// The view model for the [DeviceListPage]. class DeviceListPageViewModel extends ViewModel { - final List _devices = []; - List get devices => _devices; + DeviceListPageViewModel({StudyService? studyService}) : _studyService = studyService; + + final StudyService? _studyService; + StudyService get _study => _studyService ?? bloc.study; + + /// The smartphone (primary) device of this deployment. + List get smartphoneDevice => + _study.deploymentDevices.where((device) => device.deviceManager is SmartphoneDeviceManager).toList(); + + /// The hardware devices (connected devices) of this deployment. + List get hardwareDevices => _study.deploymentDevices + .where( + (device) => device.deviceManager is HardwareDeviceManager && device.deviceManager is! SmartphoneDeviceManager, + ) + .toList(); + + /// The online services of this deployment. + List get onlineServices => + _study.deploymentDevices.where((device) => device.deviceManager is ServiceManager).toList(); + + /// The Health service of this deployment, if any. + DeviceViewModel? get healthService => + onlineServices.where((device) => device.type == HealthService.DEVICE_TYPE).firstOrNull; } /// The view model for each device - [DeviceManager]. diff --git a/lib/view_models/informed_consent_page_model.dart b/lib/view_models/informed_consent_page_model.dart index 41974450..d36368c0 100644 --- a/lib/view_models/informed_consent_page_model.dart +++ b/lib/view_models/informed_consent_page_model.dart @@ -26,4 +26,10 @@ class InformedConsentViewModel extends ViewModel { /// 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/participant_data_page_model.dart b/lib/view_models/participant_data_page_model.dart index 33c59931..5f0c0a83 100644 --- a/lib/view_models/participant_data_page_model.dart +++ b/lib/view_models/participant_data_page_model.dart @@ -3,6 +3,9 @@ part of carp_study_app; class ParticipantDataPageViewModel extends ViewModel { Set get expectedData => bloc.study.expectedParticipantData; + /// Save the participant [data] for the current study. + void setParticipantData(Map data) => bloc.study.setParticipantData(data); + late TextEditingController _address1Controller; late TextEditingController _address2Controller; late TextEditingController _streetController; diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_page_model.dart index 4d04946b..829e0876 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -1,15 +1,27 @@ part of carp_study_app; class ProfilePageViewModel extends ViewModel { - ProfilePageViewModel({AuthService? authService, StudyService? studyService}) + ProfilePageViewModel({AuthService? authService, StudyService? studyService, SystemInfoService? systemInfoService}) : _authService = authService, - _studyService = studyService; + _studyService = studyService, + _systemInfoService = systemInfoService; final AuthService? _authService; final StudyService? _studyService; + final SystemInfoService? _systemInfoService; AuthService get _auth => _authService ?? bloc.auth; StudyService get _study => _studyService ?? bloc.study; + SystemInfoService get _system => _systemInfoService ?? bloc.system; + + /// Is the phone connected to the internet? + Future checkConnectivity() => _system.checkConnectivity(); + + /// Sign out and leave the study. + Future signOutAndLeaveStudy() => bloc.signOutAndLeaveStudy(); + + /// Leave the study, returning to the invitation list. + Future leaveStudy() => bloc.leaveStudy(); String get userId => _auth.user?.id ?? _study.study?.participantId ?? ''; String get username => _auth.username; diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index ed781fc4..5167d148 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -96,6 +96,18 @@ class StudyPageViewModel extends ViewModel { /// The list of messages to be displayed. List get messages => _messages.messages.reversed.toList(); + /// The message with [id], or a placeholder if it is not found. + Message messageById(String id) => + _messages.byId(id) ?? + Message( + id: '0', + title: 'Unknown message', + subTitle: 'Unknown message', + type: MessageType.announcement, + timestamp: DateTime.now(), + image: './assets/images/kids.png', + ); + /// Get the image based on [imagePath]. Can be both an asset and a network /// image. See [Message.imagePath]. /// From ba2eb779fd5f512d809c8f219cd54ad0ce2126bc Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 12 Jun 2026 16:18:03 +0200 Subject: [PATCH 58/94] fix(ui): store and cancel the BLE statusEvents subscription Repeated connect attempts on the Bluetooth connection page stacked a new statusEvents listener each time; the subscription is now stored, cancelled before re-subscribing, and cancelled on dispose. The full device-connection view-model extraction (#611) is deferred until it can be regression-tested with physical devices. Refs #604, #611 --- lib/ui/pages/devices_page.bluetooth_connection_page.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/ui/pages/devices_page.bluetooth_connection_page.dart b/lib/ui/pages/devices_page.bluetooth_connection_page.dart index 81d978e5..daaddc48 100644 --- a/lib/ui/pages/devices_page.bluetooth_connection_page.dart +++ b/lib/ui/pages/devices_page.bluetooth_connection_page.dart @@ -21,6 +21,7 @@ class _BluetoothConnectionPageState extends State { CurrentStep currentStep; bool isConnecting = false; Timer? _connectionTimeoutTimer; + StreamSubscription? _statusSubscription; @override initState() { @@ -32,6 +33,7 @@ class _BluetoothConnectionPageState extends State { void dispose() { FlutterBluePlus.stopScan(); _connectionTimeoutTimer?.cancel(); + _statusSubscription?.cancel(); LocalSettings().hasSeenBluetoothConnectionInstructions = true; super.dispose(); } @@ -227,7 +229,9 @@ class _BluetoothConnectionPageState extends State { FlutterBluePlus.stopScan(); widget.device.connectToDevice(selectedDevice!); - widget.device.statusEvents.listen((state) { + // Repeated connect attempts must not stack listeners. + _statusSubscription?.cancel(); + _statusSubscription = widget.device.statusEvents.listen((state) { if (state == DeviceStatus.connected) { _connectionTimeoutTimer?.cancel(); if (mounted) { From 0686f0d87b879183c88ecb3595f5b5f7fca9fb02 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 09:34:26 +0200 Subject: [PATCH 59/94] feat(invitations): land on the single invitation's detail after sign-in When a user signs in (web view or magic-link QR) and has exactly one invitation, route straight to its detail instead of the list. The landing decision is a one-time post-auth navigation owned by the InvitationsViewModel (landingRoute) and performed by the sign-in intent handlers - not the router redirect, which would trap the user on the detail page since it re-evaluates on every navigation. The list page now uses ensureInvitationsLoaded() on entry so it does not re-fetch what sign-in just loaded; pull-to-refresh still forces a refresh via loadInvitations(). --- lib/ui/pages/invitation_list_page.dart | 2 +- lib/ui/pages/login_page.dart | 5 ++- lib/ui/pages/qr_scanner.dart | 9 ++++- lib/view_models/invitations_view_model.dart | 27 +++++++++++++- test/view_models_test.dart | 41 +++++++++++++++++++++ 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/lib/ui/pages/invitation_list_page.dart b/lib/ui/pages/invitation_list_page.dart index 312331f4..f21bda27 100644 --- a/lib/ui/pages/invitation_list_page.dart +++ b/lib/ui/pages/invitation_list_page.dart @@ -13,7 +13,7 @@ class _InvitationListPageState extends State { @override void initState() { super.initState(); - widget.model.loadInvitations(); + widget.model.ensureInvitationsLoaded(); } @override diff --git a/lib/ui/pages/login_page.dart b/lib/ui/pages/login_page.dart index 93c28011..0b1cbaf5 100644 --- a/lib/ui/pages/login_page.dart +++ b/lib/ui/pages/login_page.dart @@ -55,7 +55,10 @@ class _LoginPageState extends State { final result = await widget.model.signIn(); if (!context.mounted) return; if (result == SignInResult.success) { - context.go(CarpStudyAppState.homeRoute); + final invitations = bloc.appViewModel.invitationsListViewModel; + await invitations.loadInvitations(); + if (!context.mounted) return; + context.go(invitations.landingRoute); } else if (result == SignInResult.offline) { showDialog( context: context, diff --git a/lib/ui/pages/qr_scanner.dart b/lib/ui/pages/qr_scanner.dart index 8f10d7c2..15164792 100644 --- a/lib/ui/pages/qr_scanner.dart +++ b/lib/ui/pages/qr_scanner.dart @@ -126,9 +126,14 @@ class _QRViewExampleState extends State { final qrcode = scanData.code; if (qrcode == null) return; - await widget.model.signInWithMagicLink(qrcode); + final success = await widget.model.signInWithMagicLink(qrcode); if (!mounted) return; - context.go('/'); + if (success) { + final invitations = bloc.appViewModel.invitationsListViewModel; + await invitations.loadInvitations(); + if (!mounted) return; + context.go(invitations.landingRoute); + } Navigator.of(context).pop(); }); } diff --git a/lib/view_models/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index a1331f8e..75e1da07 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -13,12 +13,22 @@ class InvitationsViewModel extends ViewModel { /// The invitations loaded so far. Empty until [loadInvitations] completes. List get invitations => _invitations ?? []; + /// Have invitations been successfully loaded at least once? + bool get isLoaded => _invitations != null; + /// Is the (initial) list of invitations being loaded? bool get isLoading => _invitations == null && _error == null; bool get hasError => _error != null; - /// Load / refresh the list of invitations from CAWS. + /// 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}' + : 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 { try { _invitations = await _auth.getInvitations(); @@ -30,6 +40,14 @@ class InvitationsViewModel extends ViewModel { notifyListeners(); } + /// Load invitations only if not already loaded. Used on entry to the list + /// 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; + await loadInvitations(); + } + ActiveParticipationInvitation getInvitation(String invitationId) => invitations.firstWhere((invitation) => invitation.participation.participantId == invitationId); @@ -38,4 +56,11 @@ class InvitationsViewModel extends ViewModel { /// Sign out and return to the login page. Future signOut() => bloc.signOutAndLeaveStudy(); + + @override + void clear() { + _invitations = null; + _error = null; + super.clear(); + } } diff --git a/test/view_models_test.dart b/test/view_models_test.dart index 1f3ea097..496211b6 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -178,6 +178,47 @@ void main() { expect(model.isLoading, isFalse); expect(model.invitations, isEmpty); }); + + ActiveParticipationInvitation invitation(String participantId) => + ActiveParticipationInvitation(Participation('dep-1', participantId, AssignedTo()), StudyInvitation('Study')); + + test('landingRoute targets the single invitation detail when there is exactly one', () async { + when(auth.getInvitations()).thenAnswer((_) async => [invitation('participant-1')]); + await model.loadInvitations(); + + expect(model.landingRoute, '${InvitationDetailsPage.route}/participant-1'); + }); + + test('landingRoute targets the list for zero or multiple invitations', () async { + when(auth.getInvitations()).thenAnswer((_) async => []); + await model.loadInvitations(); + expect(model.landingRoute, InvitationListPage.route); + + when(auth.getInvitations()).thenAnswer((_) async => [invitation('a'), invitation('b')]); + await model.loadInvitations(); + expect(model.landingRoute, InvitationListPage.route); + }); + + test('ensureInvitationsLoaded loads once; loadInvitations always refreshes', () async { + when(auth.getInvitations()).thenAnswer((_) async => []); + + await model.ensureInvitationsLoaded(); + await model.ensureInvitationsLoaded(); + verify(auth.getInvitations()).called(1); // second call is a no-op + + await model.loadInvitations(); // manual refresh still hits the backend + verify(auth.getInvitations()).called(1); + }); + + test('ensureInvitationsLoaded retries after a failed load', () async { + when(auth.getInvitations()).thenAnswer((_) async => throw Exception('offline')); + await model.ensureInvitationsLoaded(); + expect(model.isLoaded, isFalse); + + when(auth.getInvitations()).thenAnswer((_) async => [invitation('participant-1')]); + await model.ensureInvitationsLoaded(); // not loaded yet, so it retries + expect(model.isLoaded, isTrue); + }); }); group('TaskListPageViewModel', () { From 113a20be5d2b8d2723255e49d37a2976610b7d04 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 09:34:26 +0200 Subject: [PATCH 60/94] test(integration): make join-study flow self-terminating Cancel the bloc's periodic timers (message polling, view-model persistence) and the user-task subscription in tearDown via leaveStudy(), so the isolate goes idle and the run no longer hangs after 'All tests passed!'. Add a 5-minute test timeout as a backstop turning any remaining hang into a bounded failure. leaveStudy() also erases the persisted study so repeated runs start clean. --- integration_test/join_study_flow_test.dart | 149 ++++++++++++--------- 1 file changed, 83 insertions(+), 66 deletions(-) diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart index c164284c..f786ea12 100644 --- a/integration_test/join_study_flow_test.dart +++ b/integration_test/join_study_flow_test.dart @@ -99,73 +99,90 @@ String _diagnostics() => void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('join study flow: deploy, consent gate, configure, start sensing', (tester) async { - if (AppConfig().deploymentMode != DeploymentMode.local) { - markTestSkipped('This test must run with --dart-define=deployment-mode=local'); - return; + // Stop the study after the test so the bloc's periodic timers (message + // polling, view-model persistence) and the user-task subscription are + // cancelled - otherwise the isolate never goes idle and the run hangs + // after "All tests passed!". Also clears the persisted study so the next + // run starts clean. + tearDown(() async { + try { + await bloc.leaveStudy(); + } catch (_) { + // best-effort cleanup - never mask the test result } + }); - mockSimulatorPlatformChannels(); - - // Same package initialization as main(). - CarpMobileSensing.ensureInitialized(); - CognitionPackage.ensureInitialized(); - CarpDataManager.ensureInitialized(); - - // Deploy a minimal study protocol on the local (on-phone) deployment - // service - the in-test equivalent of placing a protocol.json in - // assets/carp/resources/ for local mode. - await Settings().init(); - final phone = Smartphone(); - final protocol = SmartphoneStudyProtocol(name: 'Join flow integration test', ownerId: 'test') - ..addPrimaryDevice(phone) - ..addTaskControl( - ImmediateTrigger(), - BackgroundTask(measures: [Measure(type: DeviceSamplingPackage.BATTERY_STATE)]), - phone, + testWidgets( + 'join study flow: deploy, consent gate, configure, start sensing', + timeout: const Timeout(Duration(minutes: 5)), + (tester) async { + if (AppConfig().deploymentMode != DeploymentMode.local) { + markTestSkipped('This test must run with --dart-define=deployment-mode=local'); + return; + } + + mockSimulatorPlatformChannels(); + + // Same package initialization as main(). + CarpMobileSensing.ensureInitialized(); + CognitionPackage.ensureInitialized(); + CarpDataManager.ensureInitialized(); + + // Deploy a minimal study protocol on the local (on-phone) deployment + // service - the in-test equivalent of placing a protocol.json in + // assets/carp/resources/ for local mode. + await Settings().init(); + final phone = Smartphone(); + final protocol = SmartphoneStudyProtocol(name: 'Join flow integration test', ownerId: 'test') + ..addPrimaryDevice(phone) + ..addTaskControl( + ImmediateTrigger(), + BackgroundTask(measures: [Measure(type: DeviceSamplingPackage.BATTERY_STATE)]), + phone, + ); + final status = await SmartphoneDeploymentService().createStudyDeployment(protocol); + final primaryDeviceRoleName = status.deviceStatusList + .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) + .device + .roleName; + LocalSettings().participant = Participant( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, ); - final status = await SmartphoneDeploymentService().createStudyDeployment(protocol); - final primaryDeviceRoleName = status.deviceStatusList - .firstWhere((deviceStatus) => deviceStatus.device is PrimaryDeviceConfiguration) - .device - .roleName; - LocalSettings().participant = Participant( - studyDeploymentId: status.studyDeploymentId, - deviceRoleName: primaryDeviceRoleName, - ); - bloc.study.study = SmartphoneStudy( - studyDeploymentId: status.studyDeploymentId, - deviceRoleName: primaryDeviceRoleName, - ); - - await bloc.initialize(); - - // The study descriptor exists, but it is not deployed in the sensing - // runtime and consent has not been given yet. - expect(bloc.study.hasStudy, isTrue); - expect(bloc.study.isDeployed, isFalse); - expect(await bloc.consent.hasBeenAccepted, isFalse); - - await tester.pumpWidget(const CarpStudyApp()); - - // The home page must gate on consent before configuring the study. With - // no local consent document, the consent page auto-accepts, which lets - // setup proceed: configure -> deploy -> verify -> start. - await pumpUntil( - tester, - () => LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false, - reason: 'consent gate to accept', - ); - await pumpUntil(tester, () => bloc.isConfigured, reason: 'study configuration to complete'); - - expect(await bloc.consent.hasBeenAccepted, isTrue); - expect(bloc.study.isDeployed, isTrue); - expect(bloc.study.deployment?.studyDeploymentId, status.studyDeploymentId); - - // Sensing must actually have been started - the executor is resumed. - await pumpUntil(tester, () => bloc.study.isRunning, reason: 'sensing to start'); - - // And the user lands on the study page. - await pumpUntil(tester, () => find.byType(StudyPage).evaluate().isNotEmpty, reason: 'study page to be shown'); - }); + bloc.study.study = SmartphoneStudy( + studyDeploymentId: status.studyDeploymentId, + deviceRoleName: primaryDeviceRoleName, + ); + + await bloc.initialize(); + + // The study descriptor exists, but it is not deployed in the sensing + // runtime and consent has not been given yet. + expect(bloc.study.hasStudy, isTrue); + expect(bloc.study.isDeployed, isFalse); + expect(await bloc.consent.hasBeenAccepted, isFalse); + + await tester.pumpWidget(const CarpStudyApp()); + + // The home page must gate on consent before configuring the study. With + // no local consent document, the consent page auto-accepts, which lets + // setup proceed: configure -> deploy -> verify -> start. + await pumpUntil( + tester, + () => LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false, + reason: 'consent gate to accept', + ); + await pumpUntil(tester, () => bloc.isConfigured, reason: 'study configuration to complete'); + + expect(await bloc.consent.hasBeenAccepted, isTrue); + expect(bloc.study.isDeployed, isTrue); + expect(bloc.study.deployment?.studyDeploymentId, status.studyDeploymentId); + + // Sensing must actually have been started - the executor is resumed. + await pumpUntil(tester, () => bloc.study.isRunning, reason: 'sensing to start'); + + // And the user lands on the study page. + await pumpUntil(tester, () => find.byType(StudyPage).evaluate().isNotEmpty, reason: 'study page to be shown'); + }, + ); } From 9d5c02df94cdf0b4a50c4da5ac1ba35da7c41a9e Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 09:35:00 +0200 Subject: [PATCH 61/94] fix: ensure WidgetsFlutterBinding before package initialization Initialize the Flutter binding before the CAMS/Cognition/CarpDataManager package inits, since those may touch platform channels. --- lib/main.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index cdcc3f69..bf578866 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -158,16 +158,16 @@ part 'main.g.dart'; late CarpStudyApp app; void main() async { - // Initialize CAMS and related packages (loading json deserialization functions) - CarpMobileSensing.ensureInitialized(); - CognitionPackage.ensureInitialized(); - CarpDataManager.ensureInitialized(); - // Make sure to have an instance of the WidgetsBinding, which is required // to use platform channels to call native code. // See also >> https://stackoverflow.com/questions/63873338/what-does-widgetsflutterbinding-ensureinitialized-do/63873689 WidgetsFlutterBinding.ensureInitialized(); + // Initialize CAMS and related packages (loading json deserialization functions) + CarpMobileSensing.ensureInitialized(); + CognitionPackage.ensureInitialized(); + CarpDataManager.ensureInitialized(); + await bloc.initialize(); app = const CarpStudyApp(); From 430d7337ec526c099470a0d0b76a509c19bb0e34 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 10:52:01 +0200 Subject: [PATCH 62/94] test: heart-rate trim, sign-out coverage; drop key-bearing fixtures Replaces the two commits that added test/json/protocol_cams_1x.json and protocol_cams_2x.json - those fixtures embedded live WeatherService and AirQualityService API keys (copied from the configs demo protocol), so they must not be committed. The 1.x/2.x protocol parse tests are skipped until key-free fixtures are regenerated. Retains the rest of the test work: heart-rate stream-event tests removed (kept HourlyHeartRate logic), the stale snake_case study_protocol.json deleted, and the join-study integration test extended with the sign-out state transition. Refs #604 --- integration_test/join_study_flow_test.dart | 16 + test/cams_app_test.dart | 54 +- test/heart_rate_data_model_test.dart | 125 +- test/heart_rate_data_model_test.mocks.dart | 538 ++------- test/json/study_protocol.json | 1211 -------------------- test/services_test.mocks.dart | 393 +++++-- 6 files changed, 448 insertions(+), 1889 deletions(-) delete mode 100644 test/json/study_protocol.json diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart index f786ea12..1dd2e071 100644 --- a/integration_test/join_study_flow_test.dart +++ b/integration_test/join_study_flow_test.dart @@ -183,6 +183,22 @@ void main() { // And the user lands on the study page. await pumpUntil(tester, () => find.byType(StudyPage).evaluate().isNotEmpty, reason: 'study page to be shown'); + + // Signing out tears down sensing, clears auth, erases the study, and + // returns to the initial state - the router sends the user back to the + // invitation flow. + await bloc.signOutAndLeaveStudy(); + + expect(bloc.state, StudyAppState.initialized); + expect(bloc.study.hasStudy, isFalse); + expect(bloc.study.isRunning, isFalse); + expect(bloc.auth.isAuthenticated, isFalse); + + await pumpUntil( + tester, + () => find.byType(InvitationListPage).evaluate().isNotEmpty, + reason: 'invitation list after signing out', + ); }, ); } diff --git a/test/cams_app_test.dart b/test/cams_app_test.dart index aa89766e..f58028c1 100644 --- a/test/cams_app_test.dart +++ b/test/cams_app_test.dart @@ -1,19 +1,9 @@ import 'dart:convert'; import 'dart:io'; -import 'package:carp_survey_package/survey.dart'; -import 'package:cognition_package/model.dart'; - -import 'package:carp_connectivity_package/connectivity.dart'; -// import 'package:carp_esense_package/esense.dart'; -import 'package:carp_context_package/carp_context_package.dart'; -import 'package:carp_audio_package/media.dart'; -// import 'package:carp_communication_package/communication.dart'; -// import 'package:carp_apps_package/apps.dart'; + import 'package:carp_backend/carp_backend.dart'; import 'package:research_package/research_package.dart'; -// import 'package:carp_webservices/carp_auth/carp_auth.dart'; -// import 'package:carp_webservices/carp_services/carp_services.dart'; -import 'package:carp_movesense_package/carp_movesense_package.dart'; +import 'package:cognition_package/cognition_package.dart'; import 'exports.dart'; @@ -27,24 +17,34 @@ void main() { CognitionPackage.ensureInitialized(); CarpDataManager(); - // register the different sampling package since we're using measures from them - SamplingPackageRegistry().register(ConnectivitySamplingPackage()); - SamplingPackageRegistry().register(ContextSamplingPackage()); - SamplingPackageRegistry().register(MediaSamplingPackage()); - // SamplingPackageRegistry().register(CommunicationSamplingPackage()); - // SamplingPackageRegistry().register(AppsSamplingPackage()); - // SamplingPackageRegistry().register(ESenseSamplingPackage()); - SamplingPackageRegistry().register(PolarSamplingPackage()); - SamplingPackageRegistry().register(MovesenseSamplingPackage()); - SamplingPackageRegistry().register(SurveySamplingPackage()); + // Register exactly what the app registers - sampling packages plus the + // old-namespace device aliases - so these tests exercise the real + // deserialization path. See Sensing. + Sensing(); }); - group("Local Study Protocol Manager", () { - // skipping this test since it is throwing strange "asUnmodifiableView" errors....? - test('JSON File -> StudyProtocol', skip: true, () async { - final plainJson = File('test/json/study_protocol.json').readAsStringSync(); + group("Study protocol deserialization", () { + SmartphoneStudyProtocol parse(String path) => + SmartphoneStudyProtocol.fromJson(json.decode(File(path).readAsStringSync()) as Map); + + // SKIPPED: the 1.x/2.x protocol fixtures were removed because the source + // demo protocol embeds live WeatherService/AirQualityService API keys. + // Re-enable once key-free fixtures are committed (regenerate with the + // apiKey fields blanked). + test('parses a CAMS 1.x protocol (old device namespace)', skip: true, () { + final protocol = parse('test/json/protocol_cams_1x.json'); + + expect(protocol.primaryDevices, isNotEmpty); + expect(protocol.connectedDevices, isNotEmpty); + expect(protocol.tasks, isNotEmpty); + }); + + test('parses a CAMS 2.x protocol (new device namespace)', skip: true, () { + final protocol = parse('test/json/protocol_cams_2x.json'); - SmartphoneStudyProtocol.fromJson(json.decode(plainJson) as Map); + expect(protocol.primaryDevices, isNotEmpty); + expect(protocol.connectedDevices, isNotEmpty); + expect(protocol.tasks, isNotEmpty); }); }); } diff --git a/test/heart_rate_data_model_test.dart b/test/heart_rate_data_model_test.dart index accd46f2..397b6056 100644 --- a/test/heart_rate_data_model_test.dart +++ b/test/heart_rate_data_model_test.dart @@ -1,122 +1,22 @@ import 'exports.dart'; -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), -]) +@GenerateNiceMocks([MockSpec()]) void main() { - setUp(() {}); group("HeartRateCardViewModel", () { - test('initializes HeartRateCardViewModel', skip: true, () { + test('initializes with an empty heart rate model', () { final controller = MockSmartphoneStudyController(); - final model = MockHeartRateCardViewModel(); - final dataModel = MockHourlyHeartRate(); - when(model.createModel()).thenReturn(dataModel); + when(controller.measurements).thenAnswer((_) => const Stream.empty()); final viewModel = HeartRateCardViewModel(); viewModel.init(controller); - verify(model.createModel()); - }); - group('init', () { - group('should listen to heart rate events', () { - final mockSmartphoneStudyController = MockSmartphoneStudyController(); - final mockPolarHRSample = MockPolarHRSample(); - final mockPolarHRDatum = MockPolarHR(); - final mockMeasurement = MockMeasurement(); - final viewModel = HeartRateCardViewModel(); - final heartRateStreamController = StreamController.broadcast(); - - setUp(() { - when(mockSmartphoneStudyController.measurements).thenAnswer((_) => heartRateStreamController.stream); - - viewModel.init(mockSmartphoneStudyController); - }); - tearDownAll(() { - heartRateStreamController.close(); - }); - group('and update model variables', () { - test('with one event', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(80); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(80.0)); - expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(80, 80))); - expect( - viewModel.hourlyHeartRate, - equals((HourlyHeartRate().addHeartRate(DateTime.now().hour, 80)).hourlyHeartRate), - ); - }); - test('with multiple events', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(90); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - when(mockPolarHRSample.hr).thenReturn(60); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(60)); - expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(60, 90))); - expect( - viewModel.hourlyHeartRate, - equals( - (HourlyHeartRate().addHeartRate(DateTime.now().hour, 60).addHeartRate(DateTime.now().hour, 90)) - .hourlyHeartRate, - ), - ); - }); - test('with events with data that is 0', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(0); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(null)); - expect(viewModel.dayMinMax, equals(HeartRateMinMaxPrHour(null, null))); - expect(viewModel.hourlyHeartRate, equals((HourlyHeartRate()).hourlyHeartRate)); - // expect(viewModel.skinContact, equals(false)); - }); - test('with contactStatus being true', () async { - // Add a heart rate data point to the stream - when(mockPolarHRSample.hr).thenReturn(1); - when(mockPolarHRSample.contactStatusSupported).thenReturn(true); - when(mockPolarHRSample.contactStatus).thenReturn(true); - when(mockPolarHRDatum.samples).thenReturn([mockPolarHRSample]); - when(mockMeasurement.data).thenReturn(mockPolarHRDatum); - - heartRateStreamController.sink.add(mockMeasurement); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(viewModel.currentHeartRate, equals(anything)); - expect(viewModel.dayMinMax, equals(anything)); - expect(viewModel.hourlyHeartRate, equals(anything)); - // expect(viewModel.skinContact, equals(true)); - }); - }); - }); + expect(viewModel.model, isA()); + expect(viewModel.currentHeartRate, isNull); + // No data yet: every hour bucket is present but empty. + expect(viewModel.hourlyHeartRate.values, everyElement(HeartRateMinMaxPrHour(null, null))); }); }); + group('HourlyHeartRate', () { test('resetDataAtMidnight', () { // create an HourlyHeartRate object with some data @@ -177,12 +77,3 @@ void main() { }); }); } - -class MockSerializableViewModel extends Mock implements SerializableViewModel {} - -SerializableViewModel restore() { - var viewModel = MockSerializableViewModel(); - when(viewModel.restore).thenReturn(() async => MockDataModel()); - - return viewModel; -} diff --git a/test/heart_rate_data_model_test.mocks.dart b/test/heart_rate_data_model_test.mocks.dart index ff2d14b4..99bc2a97 100644 --- a/test/heart_rate_data_model_test.mocks.dart +++ b/test/heart_rate_data_model_test.mocks.dart @@ -3,16 +3,13 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i7; -import 'dart:ui' as _i8; +import 'dart:async' as _i6; -import 'package:carp_core/carp_core.dart' as _i4; +import 'package:carp_core/carp_core.dart' as _i3; import 'package:carp_mobile_sensing/carp_mobile_sensing.dart' as _i2; -import 'package:carp_polar_package/carp_polar_package.dart' as _i9; -import 'package:carp_study_app/main.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; -import 'package:permission_handler/permission_handler.dart' as _i5; +import 'package:mockito/src/dummies.dart' as _i5; +import 'package:permission_handler/permission_handler.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -29,75 +26,72 @@ import 'package:permission_handler/permission_handler.dart' as _i5; // 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 _FakeHeartRateMinMaxPrHour_2 extends _i1.SmartFake implements _i3.HeartRateMinMaxPrHour { - _FakeHeartRateMinMaxPrHour_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -class _FakeHourlyHeartRate_3 extends _i1.SmartFake implements _i3.HourlyHeartRate { - _FakeHourlyHeartRate_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -class _FakeDateTime_4 extends _i1.SmartFake implements DateTime { - _FakeDateTime_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -class _FakeDataType_5 extends _i1.SmartFake implements _i4.DataType { - _FakeDataType_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -class _FakeData_6 extends _i1.SmartFake implements _i4.Data { - _FakeData_6(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -class _FakeDataModel_7 extends _i1.SmartFake implements _i3.DataModel { - _FakeDataModel_7(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<_i4.DeviceConfiguration<_i4.DeviceRegistration>> get remainingDevicesToRegister => + List<_i3.DeviceConfiguration<_i3.DeviceRegistration>> + get remainingDevicesToRegister => (super.noSuchMethod( Invocation.getter(#remainingDevicesToRegister), - returnValue: <_i4.DeviceConfiguration<_i4.DeviceRegistration>>[], - returnValueForMissingStub: <_i4.DeviceConfiguration<_i4.DeviceRegistration>>[], + returnValue: <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], + returnValueForMissingStub: + <_i3.DeviceConfiguration<_i3.DeviceRegistration>>[], ) - as List<_i4.DeviceConfiguration<_i4.DeviceRegistration>>); + as List<_i3.DeviceConfiguration<_i3.DeviceRegistration>>); @override - Map<_i5.Permission, _i5.PermissionStatus> get permissions => + Map<_i4.Permission, _i4.PermissionStatus> get permissions => (super.noSuchMethod( Invocation.getter(#permissions), - returnValue: <_i5.Permission, _i5.PermissionStatus>{}, - returnValueForMissingStub: <_i5.Permission, _i5.PermissionStatus>{}, + returnValue: <_i4.Permission, _i4.PermissionStatus>{}, + returnValueForMissingStub: <_i4.Permission, _i4.PermissionStatus>{}, ) - as Map<_i5.Permission, _i5.PermissionStatus>); + as Map<_i4.Permission, _i4.PermissionStatus>); @override _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); @@ -105,439 +99,107 @@ class MockSmartphoneStudyController extends _i1.Mock implements _i2.SmartphoneSt String get privacySchemaName => (super.noSuchMethod( Invocation.getter(#privacySchemaName), - returnValue: _i6.dummyValue(this, Invocation.getter(#privacySchemaName)), - returnValueForMissingStub: _i6.dummyValue(this, Invocation.getter(#privacySchemaName)), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#privacySchemaName), + ), + returnValueForMissingStub: _i5.dummyValue( + this, + Invocation.getter(#privacySchemaName), + ), ) as String); @override - _i7.Stream<_i4.Measurement> get measurements => + _i6.Stream<_i3.Measurement> get measurements => (super.noSuchMethod( Invocation.getter(#measurements), - returnValue: _i7.Stream<_i4.Measurement>.empty(), - returnValueForMissingStub: _i7.Stream<_i4.Measurement>.empty(), + returnValue: _i6.Stream<_i3.Measurement>.empty(), + returnValueForMissingStub: _i6.Stream<_i3.Measurement>.empty(), ) - as _i7.Stream<_i4.Measurement>); + as _i6.Stream<_i3.Measurement>); @override - _i7.Stream<_i4.Measurement> measurementsByType(String? type) => + _i6.Stream<_i3.Measurement> measurementsByType(String? type) => (super.noSuchMethod( Invocation.method(#measurementsByType, [type]), - returnValue: _i7.Stream<_i4.Measurement>.empty(), - returnValueForMissingStub: _i7.Stream<_i4.Measurement>.empty(), + returnValue: _i6.Stream<_i3.Measurement>.empty(), + returnValueForMissingStub: _i6.Stream<_i3.Measurement>.empty(), ) - as _i7.Stream<_i4.Measurement>); + as _i6.Stream<_i3.Measurement>); @override - _i7.Future tryRegisterConnectedDevice(_i4.DeviceConfiguration<_i4.DeviceRegistration>? device) => + _i6.Future tryRegisterConnectedDevice( + _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, + ) => (super.noSuchMethod( Invocation.method(#tryRegisterConnectedDevice, [device]), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i7.Future); + as _i6.Future); @override - _i7.Future tryRegisterRemainingDevicesToRegister() => + _i6.Future tryRegisterRemainingDevicesToRegister() => (super.noSuchMethod( Invocation.method(#tryRegisterRemainingDevicesToRegister, []), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i7.Future); + as _i6.Future); @override - _i7.Future tryUnregisterDisconnectedDevice(_i4.DeviceConfiguration<_i4.DeviceRegistration>? device) => + _i6.Future tryUnregisterDisconnectedDevice( + _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, + ) => (super.noSuchMethod( Invocation.method(#tryUnregisterDisconnectedDevice, [device]), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i7.Future); + as _i6.Future); @override - _i7.Future tryReregisterDevice(_i4.DeviceConfiguration<_i4.DeviceRegistration>? device) => + _i6.Future tryReregisterDevice( + _i3.DeviceConfiguration<_i3.DeviceRegistration>? device, + ) => (super.noSuchMethod( Invocation.method(#tryReregisterDevice, [device]), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i7.Future); + as _i6.Future); @override - _i7.Future askForAllPermissions() => + _i6.Future askForAllPermissions() => (super.noSuchMethod( Invocation.method(#askForAllPermissions, []), - returnValue: _i7.Future.value(), - returnValueForMissingStub: _i7.Future.value(), - ) - as _i7.Future); - - @override - void restart() => super.noSuchMethod(Invocation.method(#restart, []), returnValueForMissingStub: null); - - @override - void resume() => super.noSuchMethod(Invocation.method(#resume, []), returnValueForMissingStub: null); - - @override - void pause() => super.noSuchMethod(Invocation.method(#pause, []), returnValueForMissingStub: null); - - @override - void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null); -} - -/// A class which mocks [HeartRateCardViewModel]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockHeartRateCardViewModel extends _i1.Mock implements _i3.HeartRateCardViewModel { - @override - Map get hourlyHeartRate => - (super.noSuchMethod( - Invocation.getter(#hourlyHeartRate), - returnValue: {}, - returnValueForMissingStub: {}, - ) - as Map); - - @override - _i3.HeartRateMinMaxPrHour get dayMinMax => - (super.noSuchMethod( - Invocation.getter(#dayMinMax), - returnValue: _FakeHeartRateMinMaxPrHour_2(this, Invocation.getter(#dayMinMax)), - returnValueForMissingStub: _FakeHeartRateMinMaxPrHour_2(this, Invocation.getter(#dayMinMax)), - ) - as _i3.HeartRateMinMaxPrHour); - - @override - _i3.HourlyHeartRate get model => - (super.noSuchMethod( - Invocation.getter(#model), - returnValue: _FakeHourlyHeartRate_3(this, Invocation.getter(#model)), - returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.getter(#model)), - ) - as _i3.HourlyHeartRate); - - @override - _i7.Future get filename => - (super.noSuchMethod( - Invocation.getter(#filename), - returnValue: _i7.Future.value(_i6.dummyValue(this, Invocation.getter(#filename))), - returnValueForMissingStub: _i7.Future.value( - _i6.dummyValue(this, Invocation.getter(#filename)), - ), - ) - as _i7.Future); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false, returnValueForMissingStub: false) - as bool); - - @override - _i3.HourlyHeartRate createModel() => - (super.noSuchMethod( - Invocation.method(#createModel, []), - returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#createModel, [])), - returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.method(#createModel, [])), - ) - as _i3.HourlyHeartRate); - - @override - void init(_i2.SmartphoneStudyController? ctrl) => - super.noSuchMethod(Invocation.method(#init, [ctrl]), returnValueForMissingStub: null); - - @override - void clear() => super.noSuchMethod(Invocation.method(#clear, []), returnValueForMissingStub: null); - - @override - void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null); - - @override - _i7.Future save() => - (super.noSuchMethod( - Invocation.method(#save, []), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) - as _i7.Future); - - @override - bool delete() => - (super.noSuchMethod(Invocation.method(#delete, []), returnValue: false, returnValueForMissingStub: false) - as bool); - - @override - _i7.Future<_i3.HourlyHeartRate?> restore() => - (super.noSuchMethod( - Invocation.method(#restore, []), - returnValue: _i7.Future<_i3.HourlyHeartRate?>.value(), - returnValueForMissingStub: _i7.Future<_i3.HourlyHeartRate?>.value(), - ) - as _i7.Future<_i3.HourlyHeartRate?>); - - @override - void addListener(_i8.VoidCallback? listener) => - super.noSuchMethod(Invocation.method(#addListener, [listener]), returnValueForMissingStub: null); - - @override - void removeListener(_i8.VoidCallback? listener) => - super.noSuchMethod(Invocation.method(#removeListener, [listener]), returnValueForMissingStub: null); - - @override - void notifyListeners() => - super.noSuchMethod(Invocation.method(#notifyListeners, []), returnValueForMissingStub: null); -} - -/// A class which mocks [HourlyHeartRate]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockHourlyHeartRate extends _i1.Mock implements _i3.HourlyHeartRate { - @override - Map get hourlyHeartRate => - (super.noSuchMethod( - Invocation.getter(#hourlyHeartRate), - returnValue: {}, - returnValueForMissingStub: {}, - ) - as Map); - - @override - DateTime get lastUpdated => - (super.noSuchMethod( - Invocation.getter(#lastUpdated), - returnValue: _FakeDateTime_4(this, Invocation.getter(#lastUpdated)), - returnValueForMissingStub: _FakeDateTime_4(this, Invocation.getter(#lastUpdated)), - ) - as DateTime); - - @override - set hourlyHeartRate(Map? value) => - super.noSuchMethod(Invocation.setter(#hourlyHeartRate, value), returnValueForMissingStub: null); - - @override - set lastUpdated(DateTime? value) => - super.noSuchMethod(Invocation.setter(#lastUpdated, value), returnValueForMissingStub: null); - - @override - set currentHeartRate(double? value) => - super.noSuchMethod(Invocation.setter(#currentHeartRate, value), returnValueForMissingStub: null); - - @override - set maxHeartRate(double? value) => - super.noSuchMethod(Invocation.setter(#maxHeartRate, value), returnValueForMissingStub: null); - - @override - set minHeartRate(double? value) => - super.noSuchMethod(Invocation.setter(#minHeartRate, value), returnValueForMissingStub: null); - - @override - _i3.HourlyHeartRate resetDataAtMidnight() => - (super.noSuchMethod( - Invocation.method(#resetDataAtMidnight, []), - returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#resetDataAtMidnight, [])), - returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.method(#resetDataAtMidnight, [])), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i3.HourlyHeartRate); + as _i6.Future); @override - _i3.HourlyHeartRate addHeartRate(int? hour, double? heartRate) => - (super.noSuchMethod( - Invocation.method(#addHeartRate, [hour, heartRate]), - returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#addHeartRate, [hour, heartRate])), - returnValueForMissingStub: _FakeHourlyHeartRate_3( - this, - Invocation.method(#addHeartRate, [hour, heartRate]), - ), - ) - as _i3.HourlyHeartRate); - - @override - _i3.HourlyHeartRate fromJson(Map? json) => - (super.noSuchMethod( - Invocation.method(#fromJson, [json]), - returnValue: _FakeHourlyHeartRate_3(this, Invocation.method(#fromJson, [json])), - returnValueForMissingStub: _FakeHourlyHeartRate_3(this, Invocation.method(#fromJson, [json])), - ) - as _i3.HourlyHeartRate); - - @override - Map toJson() => - (super.noSuchMethod( - Invocation.method(#toJson, []), - returnValue: {}, - returnValueForMissingStub: {}, - ) - as Map); -} - -/// A class which mocks [PolarHRSample]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPolarHRSample extends _i1.Mock implements _i9.PolarHRSample { - @override - int get hr => (super.noSuchMethod(Invocation.getter(#hr), returnValue: 0, returnValueForMissingStub: 0) as int); + void restart() => super.noSuchMethod( + Invocation.method(#restart, []), + returnValueForMissingStub: null, + ); @override - List get rrsMs => - (super.noSuchMethod(Invocation.getter(#rrsMs), returnValue: [], returnValueForMissingStub: []) - as List); - - @override - bool get contactStatus => - (super.noSuchMethod(Invocation.getter(#contactStatus), returnValue: false, returnValueForMissingStub: false) - as bool); - - @override - bool get contactStatusSupported => - (super.noSuchMethod( - Invocation.getter(#contactStatusSupported), - returnValue: false, - returnValueForMissingStub: false, - ) - as bool); - - @override - Map toJson() => - (super.noSuchMethod( - Invocation.method(#toJson, []), - returnValue: {}, - returnValueForMissingStub: {}, - ) - as Map); -} - -/// A class which mocks [PolarHR]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPolarHR extends _i1.Mock implements _i9.PolarHR { - @override - Function get fromJsonFunction => - (super.noSuchMethod(Invocation.getter(#fromJsonFunction), returnValue: () {}, returnValueForMissingStub: () {}) - as Function); - - @override - String get jsonType => - (super.noSuchMethod( - Invocation.getter(#jsonType), - returnValue: _i6.dummyValue(this, Invocation.getter(#jsonType)), - returnValueForMissingStub: _i6.dummyValue(this, Invocation.getter(#jsonType)), - ) - as String); - - @override - List<_i9.PolarHRSample> get samples => - (super.noSuchMethod( - Invocation.getter(#samples), - returnValue: <_i9.PolarHRSample>[], - returnValueForMissingStub: <_i9.PolarHRSample>[], - ) - as List<_i9.PolarHRSample>); + void resume() => super.noSuchMethod( + Invocation.method(#resume, []), + returnValueForMissingStub: null, + ); @override - set sensorSpecificData(_i4.Data? value) => - super.noSuchMethod(Invocation.setter(#sensorSpecificData, value), returnValueForMissingStub: null); + void pause() => super.noSuchMethod( + Invocation.method(#pause, []), + returnValueForMissingStub: null, + ); @override - _i4.DataType get dataType => - (super.noSuchMethod( - Invocation.getter(#dataType), - returnValue: _FakeDataType_5(this, Invocation.getter(#dataType)), - returnValueForMissingStub: _FakeDataType_5(this, Invocation.getter(#dataType)), - ) - as _i4.DataType); - - @override - set $type(String? value) => super.noSuchMethod(Invocation.setter(#$type, value), returnValueForMissingStub: null); - - @override - Map toJson() => - (super.noSuchMethod( - Invocation.method(#toJson, []), - returnValue: {}, - returnValueForMissingStub: {}, - ) - as Map); - - @override - bool equivalentTo(_i4.Data? other) => - (super.noSuchMethod( - Invocation.method(#equivalentTo, [other]), - returnValue: false, - returnValueForMissingStub: false, - ) - as bool); -} - -/// A class which mocks [Measurement]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockMeasurement extends _i1.Mock implements _i4.Measurement { - @override - int get sensorStartTime => - (super.noSuchMethod(Invocation.getter(#sensorStartTime), returnValue: 0, returnValueForMissingStub: 0) as int); - - @override - _i4.DataType get dataType => - (super.noSuchMethod( - Invocation.getter(#dataType), - returnValue: _FakeDataType_5(this, Invocation.getter(#dataType)), - returnValueForMissingStub: _FakeDataType_5(this, Invocation.getter(#dataType)), - ) - as _i4.DataType); - - @override - _i4.Data get data => - (super.noSuchMethod( - Invocation.getter(#data), - returnValue: _FakeData_6(this, Invocation.getter(#data)), - returnValueForMissingStub: _FakeData_6(this, Invocation.getter(#data)), - ) - as _i4.Data); - - @override - set sensorStartTime(int? value) => - super.noSuchMethod(Invocation.setter(#sensorStartTime, value), returnValueForMissingStub: null); - - @override - set sensorEndTime(int? value) => - super.noSuchMethod(Invocation.setter(#sensorEndTime, value), returnValueForMissingStub: null); - - @override - set taskControl(_i4.TaskControl? value) => - super.noSuchMethod(Invocation.setter(#taskControl, value), returnValueForMissingStub: null); - - @override - set data(_i4.Data? value) => super.noSuchMethod(Invocation.setter(#data, value), returnValueForMissingStub: null); - - @override - Map toJson() => - (super.noSuchMethod( - Invocation.method(#toJson, []), - returnValue: {}, - returnValueForMissingStub: {}, - ) - as Map); -} - -/// A class which mocks [DataModel]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockDataModel extends _i1.Mock implements _i3.DataModel { - @override - _i3.DataModel fromJson(Map? json) => - (super.noSuchMethod( - Invocation.method(#fromJson, [json]), - returnValue: _FakeDataModel_7(this, Invocation.method(#fromJson, [json])), - returnValueForMissingStub: _FakeDataModel_7(this, Invocation.method(#fromJson, [json])), - ) - as _i3.DataModel); - - @override - Map toJson() => - (super.noSuchMethod( - Invocation.method(#toJson, []), - returnValue: {}, - returnValueForMissingStub: {}, - ) - as Map); + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); } diff --git a/test/json/study_protocol.json b/test/json/study_protocol.json deleted file mode 100644 index 93de9ea9..00000000 --- a/test/json/study_protocol.json +++ /dev/null @@ -1,1211 +0,0 @@ -{ - "applicationData": { - "studyDescription": { - "__type": "StudyDescription", - "title": "study.description.title", - "description": "study.description.description", - "purpose": "study.description.purpose", - "studyDescriptionUrl": "study.description.url", - "privacyPolicyUrl": "study.description.privacy", - "responsible": { - "__type": "StudyResponsible", - "id": "study.responsible.id", - "name": "study.responsible.name", - "title": "study.responsible.title", - "email": "study.responsible.email", - "address": "study.responsible.address", - "affiliation": "study.responsible.affiliation" - } - }, - "dataEndPoint": { - "__type": "CarpDataEndPoint", - "type": "CAWS", - "dataFormat": "dk.cachet.carp", - "uploadMethod": "datapoint", - "name": "CARP Web Services", - "onlyUploadOnWiFi": false, - "uploadInterval": 10, - "deleteWhenUploaded": true - } - }, - "id": "eb5e585f-e247-41dc-8e8d-39697b68732f", - "createdOn": "2023-04-13T18:13:18.511469Z", - "version": 0, - "description": "study.description.description", - "ownerId": "eb5e585f-e247-41dc-8e8d-39697b68732f", - "name": "CARP Study App Demo Protocol", - "primaryDevices": [ - { - "__type": "dk.cachet.carp.common.application.devices.Smartphone", - "roleName": "Primary Phone", - "defaultSamplingConfiguration": {}, - "isPrimaryDevice": true - } - ], - "connectedDevices": [ - { - "__type": "dk.cachet.carp.common.application.devices.ESenseDevice", - "roleName": "eSense", - "isOptional": true, - "supportedDataTypes": [ - "dk.cachet.carp.esense.button", - "dk.cachet.carp.esense.sensor" - ], - "defaultSamplingConfiguration": {} - }, - { - "__type": "dk.cachet.carp.common.application.devices.PolarDevice", - "roleName": "Polar HR Device", - "isOptional": true, - "supportedDataTypes": [ - "dk.cachet.carp.polar.accelerometer", - "dk.cachet.carp.polar.gyroscope", - "dk.cachet.carp.polar.magnetometer", - "dk.cachet.carp.polar.ppg", - "dk.cachet.carp.polar.ppi", - "dk.cachet.carp.polar.ecg", - "dk.cachet.carp.polar.hr" - ], - "defaultSamplingConfiguration": {} - }, - { - "__type": "dk.cachet.carp.common.application.devices.LocationService", - "roleName": "Location Service", - "isOptional": true, - "defaultSamplingConfiguration": {}, - "accuracy": "balanced", - "distance": 0.0, - "interval": 60000000 - }, - { - "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "roleName": "Weather Service", - "isOptional": true, - "defaultSamplingConfiguration": {}, - "apiKey": "12b6e28582eb9298577c734a31ba9f4f" - }, - { - "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "roleName": "Air Quality Service", - "isOptional": true, - "defaultSamplingConfiguration": {}, - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77" - } - ], - "connections": [ - { - "roleName": "eSense", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Polar HR Device", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Location Service", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Weather Service", - "connectedToRoleName": "Primary Phone" - }, - { - "roleName": "Air Quality Service", - "connectedToRoleName": "Primary Phone" - } - ], - "tasks": [ - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #26", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.coverage" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.error" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #27", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #28", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #29", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #30", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #31", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.triggeredtask" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.completedtask" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #32", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.ambientlight" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.stepcount" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.freememory" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.deviceinformation" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.batterystate" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.screenevent" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.activity" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #33", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #34", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.mobility" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #35", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.weather" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #36", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.airquality" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #37", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.esense.button" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.esense.sensor" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", - "name": "Task #38", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.polar.hr" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.polar.ecg" - } - ] - }, - { - "__type": "dk.cachet.carp.common.application.tasks.AppTask", - "name": "Task #39", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.weather" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.airquality" - } - ], - "type": "one_time_sensing", - "title": "environment.title", - "description": "environment.description", - "instructions": "", - "notification": false - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #40", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "survey", - "title": "Demographics", - "description": "A short 4-item survey on your background.", - "instructions": "", - "minutesToComplete": 2, - "expire": 432000000000, - "notification": false, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "demographic_survey", - "close_after_finished": true, - "steps": [ - { - "__type": "RPQuestionStep", - "identifier": "demographic_1", - "title": "Which is your biological sex?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "Female", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Male", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 4, - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - }, - { - "__type": "RPQuestionStep", - "identifier": "demographic_2", - "title": "How old are you?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "Under 20", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "20-29", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "30-39", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "40-49", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "50-59", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "60-69", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "70-79", - "value": 7, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "80-89", - "value": 8, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "90 and above", - "value": 9, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 10, - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - }, - { - "__type": "RPQuestionStep", - "identifier": "demographic_3", - "title": "Do you have any of these medical conditions?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "MultipleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "None", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Asthma", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Cystic fibrosis", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "COPD/Emphysema", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Pulmonary fibrosis", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other lung disease ", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "High Blood Pressure", - "value": 7, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Angina", - "value": 8, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Previous stroke or Transient ischaemic attack ", - "value": 9, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Valvular heart disease", - "value": 10, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Previous heart attack", - "value": 11, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other heart disease", - "value": 12, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Diabetes", - "value": 13, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Cancer", - "value": 14, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Previous organ transplant", - "value": 15, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "HIV or impaired immune system", - "value": 16, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Other long-term condition", - "value": 17, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 18, - "is_free_text": false - } - ], - "answer_style": "MultipleChoice" - } - }, - { - "__type": "RPQuestionStep", - "identifier": "demographic_4", - "title": "Do you, or have you, ever smoked (including e-cigarettes)?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "Never smoked", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Ex-smoker", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (less than once a day", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (1-10 cigarettes pr day", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (11-20 cigarettes pr day", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Current smoker (21+ cigarettes pr day", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 7, - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - } - ] - } - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #41", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "survey", - "title": "Symptoms", - "description": "A short 1-item survey on your daily symptoms.", - "instructions": "", - "minutesToComplete": 1, - "expire": 86400000000, - "notification": true, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "symptoms_survey", - "close_after_finished": true, - "steps": [ - { - "__type": "RPQuestionStep", - "identifier": "symptoms_1", - "title": "Do you have any of the following symptoms today?", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "MultipleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "None", - "value": 1, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Fever (warmer than usual)", - "value": 2, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Dry cough", - "value": 3, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Wet cough", - "value": 4, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Sore throat, runny or blocked nose", - "value": 5, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Loss of taste and smell", - "value": 6, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Difficulty breathing or feeling short of breath", - "value": 7, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Tightness in your chest", - "value": 8, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Dizziness, confusion or vertigo", - "value": 9, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Headache", - "value": 10, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Muscle aches", - "value": 11, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Chills", - "value": 12, - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Prefer not to say", - "value": 13, - "is_free_text": false - } - ], - "answer_style": "MultipleChoice" - } - } - ] - } - }, - { - "__type": "dk.cachet.carp.common.application.tasks.AppTask", - "name": "Task #42", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.audio" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "audio", - "title": "reading.title", - "description": "reading.description", - "instructions": "reading.instructions", - "minutesToComplete": 3, - "notification": false - }, - { - "__type": "dk.cachet.carp.common.application.tasks.AppTask", - "name": "Task #43", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.image" - } - ], - "type": "image", - "title": "wound.title", - "description": "wound.description", - "instructions": "wound.instructions", - "minutesToComplete": 3, - "notification": false - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #44", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.acceleration" - }, - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.rotation" - } - ], - "type": "cognition", - "title": "parkinsons.title", - "description": "parkinsons.description", - "instructions": "", - "minutesToComplete": 3, - "notification": false, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "parkinsons_assessment", - "close_after_finished": true, - "steps": [ - { - "__type": "RPInstructionStep", - "identifier": "parkinsons_instruction", - "title": "parkinsons.instructions.title", - "text": "parkinsons.instructions.text", - "optional": false - }, - { - "__type": "RPFlankerActivity", - "identifier": "flanker_1", - "title": "RPActivityStep", - "optional": false, - "include_instructions": true, - "include_results": true, - "length_of_test": 30, - "number_of_cards": 10 - }, - { - "__type": "RPTappingActivity", - "identifier": "tapping_1", - "title": "RPActivityStep", - "optional": false, - "include_instructions": true, - "include_results": true, - "length_of_test": 10 - } - ] - } - }, - { - "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", - "name": "Task #45", - "measures": [ - { - "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.location" - } - ], - "type": "survey", - "title": "The Parkinsons Disease Activities of Daily Living Scale", - "description": "A new simple and brief subjective measure of disability in Parkinsons disease", - "instructions": "", - "minutesToComplete": 1, - "expire": 86400000000, - "notification": true, - "rpTask": { - "__type": "RPOrderedTask", - "identifier": "parkinsons_survey", - "close_after_finished": true, - "steps": [ - { - "__type": "RPQuestionStep", - "identifier": "parkinsons_1", - "title": "Please tick one of the descriptions that best describes how your Parkinsons disease has affected your day-to-day activities in the last month.", - "optional": false, - "answer_format": { - "__type": "RPChoiceAnswerFormat", - "question_type": "SingleChoice", - "choices": [ - { - "__type": "RPChoice", - "text": "No difficulties with day-to-day activities.", - "value": 1, - "detail_text": "For example: Your Parkinsons disease at present is not affecting your daily living.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Mild difficulties with day-to-day activities.", - "value": 2, - "detail_text": "For example: Slowness with some aspects of housework, gardening or shopping. Able to dress and manage personal hygiene completely independently but rate is slower. You may feel that your medication is not quite effective as it was.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Moderate difficulties with day-to-day activities.", - "value": 3, - "detail_text": "For example: Your Parkinsons disease is interfering with your daily activities. It is increasingly difficult to do simple activities without some help such as rising from a chair, washing, dressing, shopping, housework. You may have some difficulties walking and may require assistance. Difficulties with recreational activities or the ability to drive a car. The medication is now less effective.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "High levels of difficulties with day-to-day activities.", - "value": 4, - "detail_text": "For example: You now require much more assistance with activities of daily living such as washing, dressing, housework or feeding yourself. You may have greater difficulties with mobility and find you are becoming more dependent for assistance from others or aids and appliances. Your medication appears to be significantly less effective.", - "is_free_text": false - }, - { - "__type": "RPChoice", - "text": "Extreme difficulties with day-to-day activities.", - "value": 5, - "detail_text": "For example: You require assistance in all daily activities. These may include dressing, washing, feeding yourself or walking unaided. You may now be housebound and obtain little or no benefit from your medication.", - "is_free_text": false - } - ], - "answer_style": "SingleChoice" - } - } - ] - } - } - ], - "triggers": { - "0": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "1": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "2": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "3": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "4": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "5": { - "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "6": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "7": { - "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", - "sourceDeviceRoleName": "Primary Phone", - "period": 300000000 - }, - "8": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "9": { - "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", - "sourceDeviceRoleName": "Primary Phone", - "period": 1800000000 - }, - "10": { - "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", - "sourceDeviceRoleName": "Primary Phone", - "period": 1800000000 - }, - "11": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "12": { - "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "13": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "14": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "15": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "16": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "17": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "18": { - "__type": "dk.cachet.carp.common.application.triggers.OneTimeTrigger", - "sourceDeviceRoleName": "Primary Phone" - }, - "19": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #39", - "triggerCondition": "done" - }, - "20": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #40", - "triggerCondition": "done" - }, - "21": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #41", - "triggerCondition": "done" - }, - "22": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #42", - "triggerCondition": "done" - }, - "23": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #43", - "triggerCondition": "done" - }, - "24": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #44", - "triggerCondition": "done" - }, - "25": { - "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", - "sourceDeviceRoleName": "Primary Phone", - "taskName": "Task #44", - "triggerCondition": "done" - } - }, - "taskControls": [ - { - "triggerId": 0, - "taskName": "Task #26", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 1, - "taskName": "Task #27", - "destinationDeviceRoleName": "eSense", - "control": "Start" - }, - { - "triggerId": 2, - "taskName": "Task #28", - "destinationDeviceRoleName": "Polar HR Device", - "control": "Start" - }, - { - "triggerId": 3, - "taskName": "Task #29", - "destinationDeviceRoleName": "Location Service", - "control": "Start" - }, - { - "triggerId": 4, - "taskName": "Task #30", - "destinationDeviceRoleName": "Weather Service", - "control": "Start" - }, - { - "triggerId": 5, - "taskName": "Task #31", - "destinationDeviceRoleName": "Air Quality Service", - "control": "Start" - }, - { - "triggerId": 6, - "taskName": "Task #32", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 7, - "taskName": "Task #33", - "destinationDeviceRoleName": "Location Service", - "control": "Start" - }, - { - "triggerId": 8, - "taskName": "Task #34", - "destinationDeviceRoleName": "Location Service", - "control": "Start" - }, - { - "triggerId": 9, - "taskName": "Task #35", - "destinationDeviceRoleName": "Weather Service", - "control": "Start" - }, - { - "triggerId": 10, - "taskName": "Task #36", - "destinationDeviceRoleName": "Air Quality Service", - "control": "Start" - }, - { - "triggerId": 11, - "taskName": "Task #37", - "destinationDeviceRoleName": "eSense", - "control": "Start" - }, - { - "triggerId": 12, - "taskName": "Task #38", - "destinationDeviceRoleName": "Polar HR Device", - "control": "Start" - }, - { - "triggerId": 13, - "taskName": "Task #39", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 14, - "taskName": "Task #40", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 15, - "taskName": "Task #41", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 16, - "taskName": "Task #42", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 17, - "taskName": "Task #43", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 18, - "taskName": "Task #44", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 19, - "taskName": "Task #39", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 20, - "taskName": "Task #40", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 21, - "taskName": "Task #41", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 22, - "taskName": "Task #42", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 23, - "taskName": "Task #43", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 24, - "taskName": "Task #44", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - }, - { - "triggerId": 25, - "taskName": "Task #45", - "destinationDeviceRoleName": "Primary Phone", - "control": "Start" - } - ], - "participantRoles": [ - { - "role": "Participant", - "isOptional": false - } - ], - "assignedDevices": {}, - "expectedParticipantData": [] -} \ No newline at end of file diff --git a/test/services_test.mocks.dart b/test/services_test.mocks.dart index eca70298..90acdf60 100644 --- a/test/services_test.mocks.dart +++ b/test/services_test.mocks.dart @@ -30,36 +30,46 @@ 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); } /// A class which mocks [CarpBackend]. @@ -71,7 +81,10 @@ class MockCarpBackend extends _i1.Mock implements _i5.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); @@ -80,7 +93,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 +105,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,14 +116,24 @@ 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 @@ -117,15 +146,23 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { as List<_i6.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); + 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 +195,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, [])), ), @@ -178,28 +217,39 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), - returnValueForMissingStub: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), + returnValue: + _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: + _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), ) as _i7.Future>); @override - _i7.Future<_i6.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => + _i7.Future<_i6.InformedConsentInput?> uploadInformedConsent( + _i8.RPTaskResult? consent, + ) => (super.noSuchMethod( Invocation.method(#uploadInformedConsent, [consent]), returnValue: _i7.Future<_i6.InformedConsentInput?>.value(), - returnValueForMissingStub: _i7.Future<_i6.InformedConsentInput?>.value(), + returnValueForMissingStub: + _i7.Future<_i6.InformedConsentInput?>.value(), ) as _i7.Future<_i6.InformedConsentInput?>); @override - _i7.Future<_i6.InformedConsentInput?>? getInformedConsentByRole(String? studyDeploymentId, String? role) => + _i7.Future<_i6.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?>?); @@ -211,15 +261,25 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { class MockAuthService extends _i1.Mock implements _i5.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 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 +287,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,7 +303,10 @@ 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); @@ -263,12 +332,14 @@ class MockAuthService extends _i1.Mock implements _i5.AuthService { _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), - returnValueForMissingStub: _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), + returnValue: + _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: + _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), ) as _i7.Future>); @@ -341,7 +412,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 +424,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 +436,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 +448,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 +459,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 +474,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 +489,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 +514,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 +526,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 +544,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 +573,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,54 +588,92 @@ 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(_i6.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( @@ -523,7 +682,10 @@ 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]. @@ -532,11 +694,20 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { class MockStudyService extends _i1.Mock implements _i5.StudyService { @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 @@ -550,7 +721,12 @@ class MockStudyService extends _i1.Mock implements _i5.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<_i5.DeviceViewModel> get deploymentDevices => @@ -562,15 +738,18 @@ class MockStudyService extends _i1.Mock implements _i5.StudyService { as Iterable<_i5.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() => (super.noSuchMethod( Invocation.method(#refreshDeploymentStatus, []), returnValue: _i7.Future<_i6.StudyDeploymentStatus?>.value(), - returnValueForMissingStub: _i7.Future<_i6.StudyDeploymentStatus?>.value(), + returnValueForMissingStub: + _i7.Future<_i6.StudyDeploymentStatus?>.value(), ) as _i7.Future<_i6.StudyDeploymentStatus?>); @@ -602,36 +781,58 @@ 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(_i6.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() => + _i7.Future> + getParticipantDataListFromDeployment() => (super.noSuchMethod( Invocation.method(#getParticipantDataListFromDeployment, []), - returnValue: _i7.Future>.value(<_i6.ParticipantData>[]), - returnValueForMissingStub: _i7.Future>.value(<_i6.ParticipantData>[]), + returnValue: _i7.Future>.value( + <_i6.ParticipantData>[], + ), + returnValueForMissingStub: + _i7.Future>.value( + <_i6.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() => From d44966d191dfbed7415347dde31c3c50d9201913 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 11:34:07 +0200 Subject: [PATCH 63/94] test(integration): force local+debug so the join-flow test never skips The join-study integration test guarded on deployment-mode and called markTestSkipped when not local, so a plain 'flutter test integration_test' (defaulting to production) silently skipped it - looking like a pass. It now forces DeploymentMode.local and DebugLevel.debug internally, so it always runs the real local flow with diagnosable logs regardless of how it is launched. Add a matching VS Code launch config. Refs #604 --- .vscode/launch.json | 16 ++++++++++++++++ integration_test/join_study_flow_test.dart | 10 ++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 570ef544..6b49dbc8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -52,6 +52,22 @@ "debug-level=debug" ], "flutterMode": "debug" + }, + { + // end-to-end join-study flow on a device/simulator (local deployment). + // The test also forces local + debug internally, so it runs correctly + // even without these defines. + "name": "Integration - join study flow (LOCAL)", + "request": "launch", + "type": "dart", + "program": "integration_test/join_study_flow_test.dart", + "toolArgs": [ + "--dart-define", + "deployment-mode=local", + "--dart-define", + "debug-level=debug" + ], + "flutterMode": "debug" } ] } \ No newline at end of file diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart index 1dd2e071..c9ce9e2e 100644 --- a/integration_test/join_study_flow_test.dart +++ b/integration_test/join_study_flow_test.dart @@ -116,10 +116,12 @@ void main() { 'join study flow: deploy, consent gate, configure, start sensing', timeout: const Timeout(Duration(minutes: 5)), (tester) async { - if (AppConfig().deploymentMode != DeploymentMode.local) { - markTestSkipped('This test must run with --dart-define=deployment-mode=local'); - return; - } + // This exercises the local-mode join flow, so force local deployment and + // debug logging regardless of how the suite is launched - otherwise a run + // without --dart-define=deployment-mode=local defaults to production and + // the test would silently skip. Set before bloc.initialize() reads them. + AppConfig().deploymentMode = DeploymentMode.local; + AppConfig().debugLevel = DebugLevel.debug; mockSimulatorPlatformChannels(); From e3a3b1d81c301f2b4c403a6f62b88152638d7131 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 12:58:58 +0200 Subject: [PATCH 64/94] chore(test): scrub leaked API keys from deployment fixtures These orphaned (unreferenced) fixtures embedded the WeatherService and AirQualityService API keys - as apiKey fields and, in deployment_db.json, also as the service registration deviceId. Blank/redact the values so the keys are no longer served from HEAD. The keys remain in git history and must be rotated regardless. --- test/json/deployment_caws.json | 4 ++-- test/json/deployment_db.json | 8 ++++---- test/json/deployment_status.json | 4 ++-- test/json/deployment_status_running.json | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/test/json/deployment_caws.json b/test/json/deployment_caws.json index a9b6828d..43ba6f69 100644 --- a/test/json/deployment_caws.json +++ b/test/json/deployment_caws.json @@ -23,14 +23,14 @@ }, { "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "apiKey": "12b6e28582eb9298577c734a31ba9f4f", + "apiKey": "", "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {} }, { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77", + "apiKey": "", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {} diff --git a/test/json/deployment_db.json b/test/json/deployment_db.json index ac7bc2c2..ec2119c9 100644 --- a/test/json/deployment_db.json +++ b/test/json/deployment_db.json @@ -55,14 +55,14 @@ "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {}, - "apiKey": "12b6e28582eb9298577c734a31ba9f4f" + "apiKey": "" }, { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {}, - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77" + "apiKey": "" }, { "__type": "dk.cachet.carp.common.application.devices.PolarDevice", @@ -74,7 +74,7 @@ "connectedDeviceRegistrations": { "Weather Service": { "__type": "dk.cachet.carp.common.application.devices.DefaultDeviceRegistration", - "deviceId": "12b6e28582eb9298577c734a31ba9f4f", + "deviceId": "REDACTED", "deviceDisplayName": "Weather Service (OW)", "registrationCreatedOn": "2024-05-06T09:09:44.524050Z" }, @@ -86,7 +86,7 @@ }, "Air Quality Service": { "__type": "dk.cachet.carp.common.application.devices.DefaultDeviceRegistration", - "deviceId": "9e538456b2b85c92647d8b65090e29f957638c77", + "deviceId": "REDACTED", "deviceDisplayName": "Air Quality Service (WAQI)", "registrationCreatedOn": "2024-05-06T09:09:44.595861Z" } diff --git a/test/json/deployment_status.json b/test/json/deployment_status.json index 6cb0f89a..aad2028d 100644 --- a/test/json/deployment_status.json +++ b/test/json/deployment_status.json @@ -60,7 +60,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "apiKey": "12b6e28582eb9298577c734a31ba9f4f", + "apiKey": "", "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {} @@ -73,7 +73,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77", + "apiKey": "", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {} diff --git a/test/json/deployment_status_running.json b/test/json/deployment_status_running.json index 3788c415..c625d604 100644 --- a/test/json/deployment_status_running.json +++ b/test/json/deployment_status_running.json @@ -31,7 +31,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.WeatherService", - "apiKey": "12b6e28582eb9298577c734a31ba9f4f", + "apiKey": "", "roleName": "Weather Service", "isOptional": true, "defaultSamplingConfiguration": {} @@ -44,7 +44,7 @@ "__type": "dk.cachet.carp.deployments.application.DeviceDeploymentStatus.Registered", "device": { "__type": "dk.cachet.carp.common.application.devices.AirQualityService", - "apiKey": "9e538456b2b85c92647d8b65090e29f957638c77", + "apiKey": "", "roleName": "Air Quality Service", "isOptional": true, "defaultSamplingConfiguration": {} From 0dad8428838f5a9b2f717b990ee1070df47bcb2c Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 12:59:12 +0200 Subject: [PATCH 65/94] test: add key-free 1.x/2.x protocol fixtures; enable migration tests Re-add the migration fixtures with the apiKey fields blanked (so no secrets are committed) and un-skip the parse tests. They verify CAMS 2.x deserializes protocols in both the old device namespace (dk.cachet.carp.common.application.devices.*, via the Sensing aliases) and the native 2.x namespace (dk.carp.cams.devices.*). Refs #604 --- test/cams_app_test.dart | 14 +- test/json/protocol_cams_1x.json | 1210 +++++++++++++++++++++++++++++++ test/json/protocol_cams_2x.json | 1210 +++++++++++++++++++++++++++++++ 3 files changed, 2428 insertions(+), 6 deletions(-) create mode 100644 test/json/protocol_cams_1x.json create mode 100644 test/json/protocol_cams_2x.json diff --git a/test/cams_app_test.dart b/test/cams_app_test.dart index f58028c1..2d716de4 100644 --- a/test/cams_app_test.dart +++ b/test/cams_app_test.dart @@ -24,14 +24,15 @@ void main() { }); group("Study protocol deserialization", () { + // Fixtures are real study protocols with the apiKey fields blanked. SmartphoneStudyProtocol parse(String path) => SmartphoneStudyProtocol.fromJson(json.decode(File(path).readAsStringSync()) as Map); - // SKIPPED: the 1.x/2.x protocol fixtures were removed because the source - // demo protocol embeds live WeatherService/AirQualityService API keys. - // Re-enable once key-free fixtures are committed (regenerate with the - // apiKey fields blanked). - test('parses a CAMS 1.x protocol (old device namespace)', skip: true, () { + // CAMS 2.x must parse protocols serialized under the old (CAMS 1.x) device + // namespace 'dk.cachet.carp.common.application.devices.*', resolved via the + // aliases registered in Sensing. The configurations repo still generates + // protocols with CAMS 1.x, so this is the format the app receives today. + test('parses a CAMS 1.x protocol (old device namespace)', () { final protocol = parse('test/json/protocol_cams_1x.json'); expect(protocol.primaryDevices, isNotEmpty); @@ -39,7 +40,8 @@ void main() { expect(protocol.tasks, isNotEmpty); }); - test('parses a CAMS 2.x protocol (new device namespace)', skip: true, () { + // ...and natively under the CAMS 2.x namespace 'dk.carp.cams.devices.*'. + test('parses a CAMS 2.x protocol (new device namespace)', () { final protocol = parse('test/json/protocol_cams_2x.json'); expect(protocol.primaryDevices, isNotEmpty); diff --git a/test/json/protocol_cams_1x.json b/test/json/protocol_cams_1x.json new file mode 100644 index 00000000..b38023f6 --- /dev/null +++ b/test/json/protocol_cams_1x.json @@ -0,0 +1,1210 @@ +{ + "id": "dcda6ca6-f9ab-41e5-b642-b5a349d2ab01", + "createdOn": "2025-04-09T10:59:58.258Z", + "version": 0, + "ownerId": "9c354cd9-0fd9-49a4-910d-46b28ea43997", + "name": "CARP Demo Protocol", + "description": "", + "primaryDevices": [ + { + "__type": "dk.cachet.carp.common.application.devices.Smartphone", + "isPrimaryDevice": true, + "roleName": "Primary Phone" + } + ], + "connectedDevices": [ + { + "__type": "dk.cachet.carp.common.application.devices.LocationService", + "accuracy": "balanced", + "distance": 10, + "interval": 60000000, + "roleName": "Location Service", + "isOptional": true, + "defaultSamplingConfiguration": {}, + "notificationOnTapBringToFront": false + }, + { + "__type": "dk.cachet.carp.common.application.devices.WeatherService", + "apiKey": "", + "roleName": "Weather Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.AirQualityService", + "apiKey": "", + "roleName": "Air Quality Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.HealthService", + "roleName": "Health Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.PolarDevice", + "roleName": "Polar HR Sensor", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.cachet.carp.common.application.devices.MovesenseDevice", + "roleName": "Movesense ECG Device", + "deviceType": "UNKNOWN", + "isOptional": true, + "defaultSamplingConfiguration": {} + } + ], + "connections": [ + { + "roleName": "Location Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Weather Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Air Quality Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Health Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Polar HR Sensor", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Movesense ECG Device", + "connectedToRoleName": "Primary Phone" + } + ], + "tasks": [ + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #7", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #8", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #9", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #10", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #11", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.ambientlight" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.stepcount" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.freememory" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.deviceinformation" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.batterystate" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.screenevent" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.activity" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #12", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.location" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.mobility" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #13", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.weather" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #14", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.airquality" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #15", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #16", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #17", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.polar.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #18", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #19", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.state" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #20", + "type": "one_time_sensing", + "title": "environment.title", + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.weather", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "environment.description", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #21", + "type": "survey", + "title": "survey.demographics.title", + "expire": 432000000000, + "rpTask": { + "steps": [ + { + "title": "survey.demographics.question.sex", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.femal", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.male", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.other", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.age", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.2", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.under_20", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "20-29", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "30-39", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "40-49", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "50-59", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "60-69", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "70-79", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "80-89", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.90_above", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.smoke", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.3", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.smoke.never", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.ex", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1-10", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.11-20", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.21+", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "demo_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "survey.demographics.description", + "instructions": "", + "notification": false, + "minutesToComplete": 2 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #22", + "type": "survey", + "title": "Symptoms", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Do you have any of the following symptoms today?", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "sym_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "None", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Fever (warmer than usual)", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dry cough", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Wet cough", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Sore throat, runny or blocked nose", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Loss of taste and smell", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Difficulty breathing or feeling short of breath", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Tightness in your chest", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dizziness, confusion or vertigo", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Headache", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Muscle aches", + "value": 11, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Chills", + "value": 12, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Prefer not to say", + "value": 13, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "MultipleChoice", + "questionType": "MultipleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "symptoms_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A short 1-item survey on your daily symptoms.", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #23", + "type": "audio", + "title": "reading.title", + "measures": [ + { + "type": "dk.cachet.carp.audio", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "once": true, + "__type": "dk.cachet.carp.common.application.sampling.LocationSamplingConfiguration" + } + } + ], + "description": "reading.description", + "instructions": "reading.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #24", + "type": "image", + "title": "wound.title", + "measures": [ + { + "type": "dk.cachet.carp.image", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "wound.description", + "instructions": "wound.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #25", + "type": "cognition", + "title": "Parkinsons Assessment", + "rpTask": { + "steps": [ + { + "text": "In the following pages, you will be asked to solve two simple test which will help assess your symptoms on a daily basis. Each test has an instruction page, which you should read carefully before starting the test.\n\nPlease sit down comfortably and hold the phone in one hand while performing the test with the other.", + "title": "Parkinsons Disease Assessment", + "__type": "RPInstructionStep", + "optional": false, + "identifier": "parkinsons_instruction" + }, + { + "title": "RPActivityStep", + "__type": "RPFlankerActivity", + "optional": false, + "identifier": "flanker_1", + "lengthOfTest": 30, + "numberOfCards": 10, + "includeResults": true, + "includeInstructions": true + }, + { + "title": "RPActivityStep", + "__type": "RPTappingActivity", + "optional": false, + "identifier": "tapping_1", + "lengthOfTest": 10, + "includeResults": true, + "includeInstructions": true + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_assessment", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A simple task assessing cognitive functioning and finger tapping speed.", + "instructions": "", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.HealthAppTask", + "name": "Task #27", + "type": "health", + "title": "Physical Health Data", + "types": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ], + "measures": [ + { + "type": "dk.cachet.carp.health", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "past": 2592000000000, + "__type": "dk.cachet.carp.common.application.sampling.HealthSamplingConfiguration", + "future": 86400000000, + "healthDataTypes": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ] + } + } + ], + "description": "This task will collect your weight, height, and other bodily data from the Health database on the phone.", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #26", + "type": "survey", + "title": "Parkinsons Disease Activities of Daily Living Scale", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Please tick one of the descriptions that best describes how your Parkinsons disease has affected your day-to-day activities in the last month.", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "parkinsons_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "No difficulties with day-to-day activities.", + "value": 1, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease at present is not affecting your daily living.", + "isFreeText": false + }, + { + "text": "Mild difficulties with day-to-day activities.", + "value": 2, + "__type": "RPChoice", + "detailText": "For example: Slowness with some aspects of housework, gardening or shopping. Able to dress and manage personal hygiene completely independently but rate is slower. You may feel that your medication is not quite effective as it was.", + "isFreeText": false + }, + { + "text": "Moderate difficulties with day-to-day activities.", + "value": 3, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease is interfering with your daily activities. It is increasingly difficult to do simple activities without some help such as rising from a chair, washing, dressing, shopping, housework. You may have some difficulties walking and may require assistance. Difficulties with recreational activities or the ability to drive a car. The medication is now less effective.", + "isFreeText": false + }, + { + "text": "High levels of difficulties with day-to-day activities.", + "value": 4, + "__type": "RPChoice", + "detailText": "For example: You now require much more assistance with activities of daily living such as washing, dressing, housework or feeding yourself. You may have greater difficulties with mobility and find you are becoming more dependent for assistance from others or aids and appliances. Your medication appears to be significantly less effective.", + "isFreeText": false + }, + { + "text": "Extreme difficulties with day-to-day activities.", + "value": 5, + "__type": "RPChoice", + "detailText": "For example: You require assistance in all daily activities. These may include dressing, washing, feeding yourself or walking unaided. You may now be housebound and obtain little or no benefit from your medication.", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A new simple and brief subjective measure of disability in Parkinsons disease", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + } + ], + "triggers": { + "0": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "1": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "2": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Weather Service" + }, + "3": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Air Quality Service" + }, + "4": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "5": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "6": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Weather Service" + }, + "7": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Air Quality Service" + }, + "8": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Health Service" + }, + "9": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "10": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "11": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "12": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "13": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #20", + "sourceDeviceRoleName": "Primary Phone" + }, + "14": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #21", + "sourceDeviceRoleName": "Primary Phone" + }, + "15": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #22", + "sourceDeviceRoleName": "Primary Phone" + }, + "16": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #23", + "sourceDeviceRoleName": "Primary Phone" + }, + "17": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #24", + "sourceDeviceRoleName": "Primary Phone" + }, + "18": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #25", + "sourceDeviceRoleName": "Primary Phone" + }, + "19": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #27", + "sourceDeviceRoleName": "Primary Phone" + }, + "20": { + "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", + "taskName": "Task #25", + "triggerCondition": "done", + "sourceDeviceRoleName": "Primary Phone" + } + }, + "taskControls": [ + { + "triggerId": 0, + "taskName": "Task #7", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 1, + "taskName": "Task #8", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 2, + "taskName": "Task #9", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 3, + "taskName": "Task #10", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 4, + "taskName": "Task #11", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 5, + "taskName": "Task #12", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 6, + "taskName": "Task #13", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 7, + "taskName": "Task #14", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 8, + "taskName": "Task #15", + "destinationDeviceRoleName": "Health Service", + "control": "Start" + }, + { + "triggerId": 9, + "taskName": "Task #16", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 10, + "taskName": "Task #17", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 11, + "taskName": "Task #18", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 12, + "taskName": "Task #19", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 13, + "taskName": "Task #20", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 14, + "taskName": "Task #21", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 15, + "taskName": "Task #22", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 16, + "taskName": "Task #23", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 17, + "taskName": "Task #24", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 18, + "taskName": "Task #25", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 19, + "taskName": "Task #27", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 20, + "taskName": "Task #26", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + } + ], + "participantRoles": [ + { + "role": "Participant", + "isOptional": false + } + ], + "expectedParticipantData": [ + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.address" + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.full_name" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.cachet.carp.input.sex" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.informed_consent" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + } + ], + "applicationData": { + "dataEndPoint": { + "name": "CARP Web Service", + "type": "CAWS", + "__type": "CarpDataEndPoint", + "compress": true, + "dataFormat": "dk.cachet.carp", + "uploadMethod": "stream", + "uploadInterval": 10, + "onlyUploadOnWiFi": false, + "deleteWhenUploaded": true + }, + "studyDescription": { + "title": "study.description.title", + "__type": "StudyDescription", + "purpose": "study.description.purpose", + "description": "study.description.description", + "responsible": { + "id": "study.responsible.id", + "name": "study.responsible.name", + "email": "study.responsible.email", + "title": "study.responsible.title", + "__type": "StudyResponsible", + "address": "study.responsible.address", + "affiliation": "study.responsible.affiliation" + }, + "privacyPolicyUrl": "study.description.privacy", + "studyDescriptionUrl": "study.description.url" + } + } +} \ No newline at end of file diff --git a/test/json/protocol_cams_2x.json b/test/json/protocol_cams_2x.json new file mode 100644 index 00000000..79720137 --- /dev/null +++ b/test/json/protocol_cams_2x.json @@ -0,0 +1,1210 @@ +{ + "id": "dcda6ca6-f9ab-41e5-b642-b5a349d2ab01", + "createdOn": "2025-04-09T10:59:58.258Z", + "version": 0, + "ownerId": "9c354cd9-0fd9-49a4-910d-46b28ea43997", + "name": "CARP Demo Protocol", + "description": "", + "primaryDevices": [ + { + "__type": "dk.carp.cams.devices.Smartphone", + "isPrimaryDevice": true, + "roleName": "Primary Phone" + } + ], + "connectedDevices": [ + { + "__type": "dk.carp.cams.devices.LocationService", + "accuracy": "balanced", + "distance": 10, + "interval": 60000000, + "roleName": "Location Service", + "isOptional": true, + "defaultSamplingConfiguration": {}, + "notificationOnTapBringToFront": false + }, + { + "__type": "dk.carp.cams.devices.WeatherService", + "apiKey": "", + "roleName": "Weather Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.AirQualityService", + "apiKey": "", + "roleName": "Air Quality Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.HealthService", + "roleName": "Health Service", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.PolarDevice", + "roleName": "Polar HR Sensor", + "isOptional": true, + "defaultSamplingConfiguration": {} + }, + { + "__type": "dk.carp.cams.devices.MovesenseDevice", + "roleName": "Movesense ECG Device", + "deviceType": "UNKNOWN", + "isOptional": true, + "defaultSamplingConfiguration": {} + } + ], + "connections": [ + { + "roleName": "Location Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Weather Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Air Quality Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Health Service", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Polar HR Sensor", + "connectedToRoleName": "Primary Phone" + }, + { + "roleName": "Movesense ECG Device", + "connectedToRoleName": "Primary Phone" + } + ], + "tasks": [ + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #7", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #8", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #9", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #10", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #11", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.ambientlight" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.stepcount" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.freememory" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.deviceinformation" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.batterystate" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.screenevent" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.activity" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #12", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.location" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.mobility" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #13", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.weather" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #14", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.airquality" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #15", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #16", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #17", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.polar.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #18", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.error" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.triggeredtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedtask" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.heartbeat" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.completedapptask" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #19", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.state" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.movesense.hr" + } + ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #20", + "type": "one_time_sensing", + "title": "environment.title", + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.weather", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "environment.description", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #21", + "type": "survey", + "title": "survey.demographics.title", + "expire": 432000000000, + "rpTask": { + "steps": [ + { + "title": "survey.demographics.question.sex", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.femal", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.male", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.other", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.age", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.2", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.under_20", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "20-29", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "30-39", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "40-49", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "50-59", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "60-69", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "70-79", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "80-89", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.90_above", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + }, + { + "title": "survey.demographics.question.smoke", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "survey.demographics.3", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "survey.demographics.smoke.never", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.ex", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.1-10", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.11-20", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.smoke.21+", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "survey.demographics.prefer_not", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "demo_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "survey.demographics.description", + "instructions": "", + "notification": false, + "minutesToComplete": 2 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #22", + "type": "survey", + "title": "Symptoms", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Do you have any of the following symptoms today?", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "sym_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "None", + "value": 1, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Fever (warmer than usual)", + "value": 2, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dry cough", + "value": 3, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Wet cough", + "value": 4, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Sore throat, runny or blocked nose", + "value": 5, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Loss of taste and smell", + "value": 6, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Difficulty breathing or feeling short of breath", + "value": 7, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Tightness in your chest", + "value": 8, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Dizziness, confusion or vertigo", + "value": 9, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Headache", + "value": 10, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Muscle aches", + "value": 11, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Chills", + "value": 12, + "__type": "RPChoice", + "isFreeText": false + }, + { + "text": "Prefer not to say", + "value": 13, + "__type": "RPChoice", + "isFreeText": false + } + ], + "answerStyle": "MultipleChoice", + "questionType": "MultipleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "symptoms_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A short 1-item survey on your daily symptoms.", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #23", + "type": "audio", + "title": "reading.title", + "measures": [ + { + "type": "dk.cachet.carp.audio", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "once": true, + "__type": "dk.cachet.carp.common.application.sampling.LocationSamplingConfiguration" + } + } + ], + "description": "reading.description", + "instructions": "reading.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.AppTask", + "name": "Task #24", + "type": "image", + "title": "wound.title", + "measures": [ + { + "type": "dk.cachet.carp.image", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "wound.description", + "instructions": "wound.instructions", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #25", + "type": "cognition", + "title": "Parkinsons Assessment", + "rpTask": { + "steps": [ + { + "text": "In the following pages, you will be asked to solve two simple test which will help assess your symptoms on a daily basis. Each test has an instruction page, which you should read carefully before starting the test.\n\nPlease sit down comfortably and hold the phone in one hand while performing the test with the other.", + "title": "Parkinsons Disease Assessment", + "__type": "RPInstructionStep", + "optional": false, + "identifier": "parkinsons_instruction" + }, + { + "title": "RPActivityStep", + "__type": "RPFlankerActivity", + "optional": false, + "identifier": "flanker_1", + "lengthOfTest": 30, + "numberOfCards": 10, + "includeResults": true, + "includeInstructions": true + }, + { + "title": "RPActivityStep", + "__type": "RPTappingActivity", + "optional": false, + "identifier": "tapping_1", + "lengthOfTest": 10, + "includeResults": true, + "includeInstructions": true + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_assessment", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A simple task assessing cognitive functioning and finger tapping speed.", + "instructions": "", + "notification": false, + "minutesToComplete": 3 + }, + { + "__type": "dk.cachet.carp.common.application.tasks.HealthAppTask", + "name": "Task #27", + "type": "health", + "title": "Physical Health Data", + "types": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ], + "measures": [ + { + "type": "dk.cachet.carp.health", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "overrideSamplingConfiguration": { + "past": 2592000000000, + "__type": "dk.cachet.carp.common.application.sampling.HealthSamplingConfiguration", + "future": 86400000000, + "healthDataTypes": [ + "WEIGHT", + "HEIGHT", + "BODY_FAT_PERCENTAGE", + "BODY_TEMPERATURE" + ] + } + } + ], + "description": "This task will collect your weight, height, and other bodily data from the Health database on the phone.", + "instructions": "", + "notification": false + }, + { + "__type": "dk.cachet.carp.common.application.tasks.RPAppTask", + "name": "Task #26", + "type": "survey", + "title": "Parkinsons Disease Activities of Daily Living Scale", + "expire": 86400000000, + "rpTask": { + "steps": [ + { + "title": "Please tick one of the descriptions that best describes how your Parkinsons disease has affected your day-to-day activities in the last month.", + "__type": "RPQuestionStep", + "timeout": 0, + "autoSkip": false, + "optional": false, + "autoFocus": false, + "identifier": "parkinsons_1", + "answerFormat": { + "__type": "RPChoiceAnswerFormat", + "choices": [ + { + "text": "No difficulties with day-to-day activities.", + "value": 1, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease at present is not affecting your daily living.", + "isFreeText": false + }, + { + "text": "Mild difficulties with day-to-day activities.", + "value": 2, + "__type": "RPChoice", + "detailText": "For example: Slowness with some aspects of housework, gardening or shopping. Able to dress and manage personal hygiene completely independently but rate is slower. You may feel that your medication is not quite effective as it was.", + "isFreeText": false + }, + { + "text": "Moderate difficulties with day-to-day activities.", + "value": 3, + "__type": "RPChoice", + "detailText": "For example: Your Parkinsons disease is interfering with your daily activities. It is increasingly difficult to do simple activities without some help such as rising from a chair, washing, dressing, shopping, housework. You may have some difficulties walking and may require assistance. Difficulties with recreational activities or the ability to drive a car. The medication is now less effective.", + "isFreeText": false + }, + { + "text": "High levels of difficulties with day-to-day activities.", + "value": 4, + "__type": "RPChoice", + "detailText": "For example: You now require much more assistance with activities of daily living such as washing, dressing, housework or feeding yourself. You may have greater difficulties with mobility and find you are becoming more dependent for assistance from others or aids and appliances. Your medication appears to be significantly less effective.", + "isFreeText": false + }, + { + "text": "Extreme difficulties with day-to-day activities.", + "value": 5, + "__type": "RPChoice", + "detailText": "For example: You require assistance in all daily activities. These may include dressing, washing, feeding yourself or walking unaided. You may now be housebound and obtain little or no benefit from your medication.", + "isFreeText": false + } + ], + "answerStyle": "SingleChoice", + "questionType": "SingleChoice" + } + } + ], + "__type": "RPOrderedTask", + "identifier": "parkinsons_survey", + "closeAfterFinished": true + }, + "measures": [ + { + "type": "dk.cachet.carp.location", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + }, + { + "type": "dk.cachet.carp.survey", + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream" + } + ], + "description": "A new simple and brief subjective measure of disability in Parkinsons disease", + "instructions": "", + "notification": true, + "minutesToComplete": 1 + } + ], + "triggers": { + "0": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "1": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "2": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Weather Service" + }, + "3": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Air Quality Service" + }, + "4": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Primary Phone" + }, + "5": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Location Service" + }, + "6": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Weather Service" + }, + "7": { + "__type": "dk.cachet.carp.common.application.triggers.PeriodicTrigger", + "period": 1800000000, + "sourceDeviceRoleName": "Air Quality Service" + }, + "8": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Health Service" + }, + "9": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "10": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Polar HR Sensor" + }, + "11": { + "__type": "dk.cachet.carp.common.application.triggers.NoOpTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "12": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Movesense ECG Device" + }, + "13": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #20", + "sourceDeviceRoleName": "Primary Phone" + }, + "14": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #21", + "sourceDeviceRoleName": "Primary Phone" + }, + "15": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #22", + "sourceDeviceRoleName": "Primary Phone" + }, + "16": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #23", + "sourceDeviceRoleName": "Primary Phone" + }, + "17": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #24", + "sourceDeviceRoleName": "Primary Phone" + }, + "18": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #25", + "sourceDeviceRoleName": "Primary Phone" + }, + "19": { + "__type": "dk.cachet.carp.common.application.triggers.NoUserTaskTrigger", + "taskName": "Task #27", + "sourceDeviceRoleName": "Primary Phone" + }, + "20": { + "__type": "dk.cachet.carp.common.application.triggers.UserTaskTrigger", + "taskName": "Task #25", + "triggerCondition": "done", + "sourceDeviceRoleName": "Primary Phone" + } + }, + "taskControls": [ + { + "triggerId": 0, + "taskName": "Task #7", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 1, + "taskName": "Task #8", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 2, + "taskName": "Task #9", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 3, + "taskName": "Task #10", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 4, + "taskName": "Task #11", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 5, + "taskName": "Task #12", + "destinationDeviceRoleName": "Location Service", + "control": "Start" + }, + { + "triggerId": 6, + "taskName": "Task #13", + "destinationDeviceRoleName": "Weather Service", + "control": "Start" + }, + { + "triggerId": 7, + "taskName": "Task #14", + "destinationDeviceRoleName": "Air Quality Service", + "control": "Start" + }, + { + "triggerId": 8, + "taskName": "Task #15", + "destinationDeviceRoleName": "Health Service", + "control": "Start" + }, + { + "triggerId": 9, + "taskName": "Task #16", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 10, + "taskName": "Task #17", + "destinationDeviceRoleName": "Polar HR Sensor", + "control": "Start" + }, + { + "triggerId": 11, + "taskName": "Task #18", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 12, + "taskName": "Task #19", + "destinationDeviceRoleName": "Movesense ECG Device", + "control": "Start" + }, + { + "triggerId": 13, + "taskName": "Task #20", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 14, + "taskName": "Task #21", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 15, + "taskName": "Task #22", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 16, + "taskName": "Task #23", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 17, + "taskName": "Task #24", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 18, + "taskName": "Task #25", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 19, + "taskName": "Task #27", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + }, + { + "triggerId": 20, + "taskName": "Task #26", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" + } + ], + "participantRoles": [ + { + "role": "Participant", + "isOptional": false + } + ], + "expectedParticipantData": [ + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.address" + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.full_name" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.cachet.carp.input.sex" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + }, + { + "attribute": { + "__type": "dk.cachet.carp.common.application.users.ParticipantAttribute.DefaultParticipantAttribute", + "inputDataType": "dk.carp.webservices.input.informed_consent" + }, + "assignedTo": { + "__type": "dk.cachet.carp.common.application.users.AssignedTo.Roles", + "roleNames": [ + "Participant" + ] + } + } + ], + "applicationData": { + "dataEndPoint": { + "name": "CARP Web Service", + "type": "CAWS", + "__type": "CarpDataEndPoint", + "compress": true, + "dataFormat": "dk.cachet.carp", + "uploadMethod": "stream", + "uploadInterval": 10, + "onlyUploadOnWiFi": false, + "deleteWhenUploaded": true + }, + "studyDescription": { + "title": "study.description.title", + "__type": "StudyDescription", + "purpose": "study.description.purpose", + "description": "study.description.description", + "responsible": { + "id": "study.responsible.id", + "name": "study.responsible.name", + "email": "study.responsible.email", + "title": "study.responsible.title", + "__type": "StudyResponsible", + "address": "study.responsible.address", + "affiliation": "study.responsible.affiliation" + }, + "privacyPolicyUrl": "study.description.privacy", + "studyDescriptionUrl": "study.description.url" + } + } +} \ No newline at end of file From 25019be4e1376b5cef18ac8a56647b3935358913 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 13:04:06 +0200 Subject: [PATCH 66/94] test: assert concrete devices/services in 1.x and 2.x protocols Strengthen the migration tests beyond non-empty checks: both fixtures must parse to a Smartphone primary device plus the six connected services/devices (LocationService, WeatherService, AirQualityService, HealthService, PolarDevice, MovesenseDevice) resolved to their concrete runtime classes with the expected role names, plus 21 tasks / 21 task controls. A shared expectation holds the old-namespace (alias) and native 2.x paths to identical results, so a regression in either device type's registration surfaces as a wrong/missing type rather than passing. Refs #604 --- test/cams_app_test.dart | 50 ++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/test/cams_app_test.dart b/test/cams_app_test.dart index 2d716de4..cc46794f 100644 --- a/test/cams_app_test.dart +++ b/test/cams_app_test.dart @@ -28,25 +28,55 @@ void main() { SmartphoneStudyProtocol parse(String path) => SmartphoneStudyProtocol.fromJson(json.decode(File(path).readAsStringSync()) as Map); + // The demo protocol's devices, by concrete runtime class and role name. + // Asserting the runtime type (not just "non-empty") is what proves each + // device's '__type' resolved to its real class - via the Sensing aliases + // for the 1.x namespace, and natively for 2.x. A regression in either path + // would surface as a missing/wrong type here (or a SerializationException). + const expectedConnectedTypes = { + 'LocationService', + 'WeatherService', + 'AirQualityService', + 'HealthService', + 'PolarDevice', + 'MovesenseDevice', + }; + const expectedConnectedRoles = { + 'Location Service', + 'Weather Service', + 'Air Quality Service', + 'Health Service', + 'Polar HR Sensor', + 'Movesense ECG Device', + }; + + void expectDemoProtocol(SmartphoneStudyProtocol protocol) { + // Primary device: a single Smartphone. + expect(protocol.primaryDevices.map((d) => d.runtimeType.toString()), ['Smartphone']); + expect(protocol.primaryDevices.single.roleName, 'Primary Phone'); + + // Connected devices/services: all six resolved to their concrete classes. + final connected = protocol.connectedDevices!; + expect(connected.length, 6); + expect(connected.map((d) => d.runtimeType.toString()).toSet(), expectedConnectedTypes); + expect(connected.map((d) => d.roleName).toSet(), expectedConnectedRoles); + + // Task graph is fully parsed. + expect(protocol.tasks.length, 21); + expect(protocol.taskControls.length, 21); + } + // CAMS 2.x must parse protocols serialized under the old (CAMS 1.x) device // namespace 'dk.cachet.carp.common.application.devices.*', resolved via the // aliases registered in Sensing. The configurations repo still generates // protocols with CAMS 1.x, so this is the format the app receives today. test('parses a CAMS 1.x protocol (old device namespace)', () { - final protocol = parse('test/json/protocol_cams_1x.json'); - - expect(protocol.primaryDevices, isNotEmpty); - expect(protocol.connectedDevices, isNotEmpty); - expect(protocol.tasks, isNotEmpty); + expectDemoProtocol(parse('test/json/protocol_cams_1x.json')); }); // ...and natively under the CAMS 2.x namespace 'dk.carp.cams.devices.*'. test('parses a CAMS 2.x protocol (new device namespace)', () { - final protocol = parse('test/json/protocol_cams_2x.json'); - - expect(protocol.primaryDevices, isNotEmpty); - expect(protocol.connectedDevices, isNotEmpty); - expect(protocol.tasks, isNotEmpty); + expectDemoProtocol(parse('test/json/protocol_cams_2x.json')); }); }); } From 5fb2637f45a000a777247f1bf42b36a9344a77c3 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 13:12:16 +0200 Subject: [PATCH 67/94] test: verify declared data-collection types are registered (1.x/2.x) Beyond device resolution, assert the protocol declares all expected sensor/service/app-task Measure types and that each is provided by a registered sampling package (SamplingPackageRegistry().dataTypes) - so the app can actually collect every declared type. Tolerant of the dynamic per-task 'completedapptask.' lifecycle measures. Runs for both the 1.x (alias) and native 2.x namespaces, so a data-type registration regression in any package fails the test. Refs #604 --- test/cams_app_test.dart | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/cams_app_test.dart b/test/cams_app_test.dart index cc46794f..06115c6b 100644 --- a/test/cams_app_test.dart +++ b/test/cams_app_test.dart @@ -50,6 +50,40 @@ void main() { 'Movesense ECG Device', }; + // The data-collection (Measure) types the protocol declares across all + // tasks - the sensors/services actually sampled, plus CAMS control types. + const expectedDataTypes = { + // device & sensor sampling + 'dk.cachet.carp.ambientlight', + 'dk.cachet.carp.stepcount', + 'dk.cachet.carp.freememory', + 'dk.cachet.carp.deviceinformation', + 'dk.cachet.carp.batterystate', + 'dk.cachet.carp.screenevent', + 'dk.cachet.carp.activity', + // context services + 'dk.cachet.carp.location', + 'dk.cachet.carp.mobility', + 'dk.cachet.carp.weather', + 'dk.cachet.carp.airquality', + // wearables + 'dk.cachet.carp.polar.hr', + 'dk.cachet.carp.movesense.hr', + 'dk.cachet.carp.movesense.state', + // health + 'dk.cachet.carp.health', + // app tasks / media / survey + 'dk.cachet.carp.audio', + 'dk.cachet.carp.image', + 'dk.cachet.carp.survey', + // CAMS control & app-task lifecycle + 'dk.cachet.carp.error', + 'dk.cachet.carp.heartbeat', + 'dk.cachet.carp.triggeredtask', + 'dk.cachet.carp.completedtask', + 'dk.cachet.carp.completedapptask', + }; + void expectDemoProtocol(SmartphoneStudyProtocol protocol) { // Primary device: a single Smartphone. expect(protocol.primaryDevices.map((d) => d.runtimeType.toString()), ['Smartphone']); @@ -64,6 +98,24 @@ void main() { // Task graph is fully parsed. expect(protocol.tasks.length, 21); expect(protocol.taskControls.length, 21); + + // Data-collection types: the protocol declares all the expected sensor/ + // service/app-task measures (it also adds dynamic per-task + // 'completedapptask.' lifecycle measures, which we don't pin), and + // every expected type is provided by a registered sampling package - i.e. + // the app can actually collect it. A namespace/registration regression in + // any package would drop a type from one side or the other. + final declaredTypes = { + for (final task in protocol.tasks) ...?task.measures?.map((m) => m.type), + }; + expect(declaredTypes, containsAll(expectedDataTypes)); + + final supportedTypes = SamplingPackageRegistry().dataTypes.map((d) => d.type).toSet(); + expect( + expectedDataTypes.difference(supportedTypes), + isEmpty, + reason: 'these declared data types are not registered in any sampling package', + ); } // CAMS 2.x must parse protocols serialized under the old (CAMS 1.x) device From c19da4521c2fb5fdf64b1f47208eebc78050d356 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 13:14:59 +0200 Subject: [PATCH 68/94] fix: dart format --- test/cams_app_test.dart | 4 +- test/heart_rate_data_model_test.mocks.dart | 85 ++--- test/services_test.mocks.dart | 393 +++++---------------- 3 files changed, 117 insertions(+), 365 deletions(-) diff --git a/test/cams_app_test.dart b/test/cams_app_test.dart index 06115c6b..6354d7dd 100644 --- a/test/cams_app_test.dart +++ b/test/cams_app_test.dart @@ -105,9 +105,7 @@ void main() { // every expected type is provided by a registered sampling package - i.e. // the app can actually collect it. A namespace/registration regression in // any package would drop a type from one side or the other. - final declaredTypes = { - for (final task in protocol.tasks) ...?task.measures?.map((m) => m.type), - }; + final declaredTypes = {for (final task in protocol.tasks) ...?task.measures?.map((m) => m.type)}; expect(declaredTypes, containsAll(expectedDataTypes)); final supportedTypes = SamplingPackageRegistry().dataTypes.map((d) => d.type).toSet(); 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 90acdf60..eca70298 100644 --- a/test/services_test.mocks.dart +++ b/test/services_test.mocks.dart @@ -30,46 +30,36 @@ 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); } /// A class which mocks [CarpBackend]. @@ -81,10 +71,7 @@ class MockCarpBackend extends _i1.Mock implements _i5.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); @@ -93,10 +80,7 @@ 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); @@ -105,10 +89,7 @@ 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); @@ -116,24 +97,14 @@ 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 @@ -146,23 +117,15 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { as List<_i6.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, - ); + 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() => @@ -195,9 +158,7 @@ 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, [])), ), @@ -217,39 +178,28 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: - _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), - returnValueForMissingStub: - _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), + returnValue: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), ) as _i7.Future>); @override - _i7.Future<_i6.InformedConsentInput?> uploadInformedConsent( - _i8.RPTaskResult? consent, - ) => + _i7.Future<_i6.InformedConsentInput?> uploadInformedConsent(_i8.RPTaskResult? consent) => (super.noSuchMethod( Invocation.method(#uploadInformedConsent, [consent]), returnValue: _i7.Future<_i6.InformedConsentInput?>.value(), - returnValueForMissingStub: - _i7.Future<_i6.InformedConsentInput?>.value(), + returnValueForMissingStub: _i7.Future<_i6.InformedConsentInput?>.value(), ) as _i7.Future<_i6.InformedConsentInput?>); @override - _i7.Future<_i6.InformedConsentInput?>? getInformedConsentByRole( - String? studyDeploymentId, - String? role, - ) => + _i7.Future<_i6.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?>?); @@ -261,25 +211,15 @@ class MockCarpBackend extends _i1.Mock implements _i5.CarpBackend { class MockAuthService extends _i1.Mock implements _i5.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 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); @@ -287,14 +227,8 @@ 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); @@ -303,10 +237,7 @@ 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); @@ -332,14 +263,12 @@ class MockAuthService extends _i1.Mock implements _i5.AuthService { _i7.Future> getInvitations() => (super.noSuchMethod( Invocation.method(#getInvitations, []), - returnValue: - _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), - returnValueForMissingStub: - _i7.Future>.value( - <_i6.ActiveParticipationInvitation>[], - ), + returnValue: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), + returnValueForMissingStub: _i7.Future>.value( + <_i6.ActiveParticipationInvitation>[], + ), ) as _i7.Future>); @@ -412,10 +341,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); @@ -424,10 +350,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); @@ -436,10 +359,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); @@ -448,10 +368,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); @@ -459,14 +376,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); @@ -474,14 +385,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); @@ -489,24 +394,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 @@ -514,10 +409,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); @@ -526,10 +418,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); @@ -544,11 +433,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 @@ -573,14 +458,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>); @@ -588,92 +467,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(_i6.Data? value) => super.noSuchMethod( - Invocation.setter(#result, value), - returnValueForMissingStub: null, - ); + set result(_i6.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( @@ -682,10 +523,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]. @@ -694,20 +532,11 @@ class MockUserTask extends _i1.Mock implements _i4.UserTask { class MockStudyService extends _i1.Mock implements _i5.StudyService { @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 @@ -721,12 +550,7 @@ class MockStudyService extends _i1.Mock implements _i5.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<_i5.DeviceViewModel> get deploymentDevices => @@ -738,18 +562,15 @@ class MockStudyService extends _i1.Mock implements _i5.StudyService { as Iterable<_i5.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() => (super.noSuchMethod( Invocation.method(#refreshDeploymentStatus, []), returnValue: _i7.Future<_i6.StudyDeploymentStatus?>.value(), - returnValueForMissingStub: - _i7.Future<_i6.StudyDeploymentStatus?>.value(), + returnValueForMissingStub: _i7.Future<_i6.StudyDeploymentStatus?>.value(), ) as _i7.Future<_i6.StudyDeploymentStatus?>); @@ -781,58 +602,36 @@ 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(_i6.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() => + _i7.Future> getParticipantDataListFromDeployment() => (super.noSuchMethod( Invocation.method(#getParticipantDataListFromDeployment, []), - returnValue: _i7.Future>.value( - <_i6.ParticipantData>[], - ), - returnValueForMissingStub: - _i7.Future>.value( - <_i6.ParticipantData>[], - ), + returnValue: _i7.Future>.value(<_i6.ParticipantData>[]), + returnValueForMissingStub: _i7.Future>.value(<_i6.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() => From 4924b9199aa5b94f76892516b8fac650d90e2f4c Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 13:44:10 +0200 Subject: [PATCH 69/94] refactor: make AppConfig a static config holder AppConfig is just compile-time-derived config read globally (bloc, Sensing, CarpBackend), so there is no reason to instantiate it. Convert it to an abstract class with static fields and drop the singleton factory. Services no longer take an AppConfig 'config' parameter and the bloc no longer holds a 'config' instance - everything reads AppConfig.deploymentMode / .debugLevel / .localization directly. (Keeps the explicit const on String.fromEnvironment so --dart-define still applies.) No behavior change: the static fields hold the same global mutable state the singleton did. Verified by the full unit suite and the join-study integration test. --- integration_test/join_study_flow_test.dart | 4 +-- lib/blocs/app_bloc.dart | 19 ++++++------- lib/blocs/app_config.dart | 33 ++++++++++------------ lib/blocs/sensing.dart | 15 +++++----- lib/carp_study_app.dart | 6 ++-- lib/data/carp_backend.dart | 4 +-- lib/services/consent_service.dart | 9 ++---- lib/services/resource_manager_factory.dart | 6 +--- lib/services/study_service.dart | 9 ++---- test/app_bloc_test.dart | 2 +- test/services_test.dart | 14 ++++----- test/view_models_test.dart | 2 +- 12 files changed, 52 insertions(+), 71 deletions(-) diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart index c9ce9e2e..14687a77 100644 --- a/integration_test/join_study_flow_test.dart +++ b/integration_test/join_study_flow_test.dart @@ -120,8 +120,8 @@ void main() { // debug logging regardless of how the suite is launched - otherwise a run // without --dart-define=deployment-mode=local defaults to production and // the test would silently skip. Set before bloc.initialize() reads them. - AppConfig().deploymentMode = DeploymentMode.local; - AppConfig().debugLevel = DebugLevel.debug; + AppConfig.deploymentMode = DeploymentMode.local; + AppConfig.debugLevel = DebugLevel.debug; mockSimulatorPlatformChannels(); diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 5e4badb9..75d79bd1 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -32,11 +32,8 @@ class StudyAppBLoC extends ChangeNotifier { final CarpStudyAppViewModel _appViewModel = CarpStudyAppViewModel(); StreamSubscription? _userTaskNotificationSubscription; - /// App-wide configuration (deployment mode, debug level, localization). - final AppConfig config = AppConfig(); - /// The resource managers matching the current deployment mode. - late final ResourceManagerFactory resources = ResourceManagerFactory(config: config); + late final ResourceManagerFactory resources = ResourceManagerFactory(); /// Device- and platform-level checks. late final SystemInfoService system = SystemInfoService(); @@ -45,13 +42,13 @@ class StudyAppBLoC extends ChangeNotifier { late final AuthService auth = AuthService(); /// The study running on this phone and its deployment. - late final StudyService study = StudyService(config: config, resources: resources); + late final StudyService study = StudyService(resources: resources); /// The messages shown in the app, kept refreshed by polling. late final MessageService messages = MessageService(resources.messageManager); /// The informed consent flow. - late final ConsentService consent = ConsentService(resources.informedConsentManager, study, config: config); + late final ConsentService consent = ConsentService(resources.informedConsentManager, study); /// The state of this BloC. StudyAppState get state => _state; @@ -71,8 +68,8 @@ class StudyAppBLoC extends ChangeNotifier { StudyAppBLoC() : super() { info( '$runtimeType created. ' - 'DeploymentMode: ${config.deploymentMode.name}, ' - 'DebugLevel: ${config.debugLevel.name}', + 'DeploymentMode: ${AppConfig.deploymentMode.name}, ' + 'DebugLevel: ${AppConfig.debugLevel.name}', ); // The coordinator is the sole router-notifier - forward service changes. @@ -104,14 +101,14 @@ class StudyAppBLoC extends ChangeNotifier { Future initialize() async { if (isInitialized) return; - Settings().debugLevel = config.debugLevel; + Settings().debugLevel = AppConfig.debugLevel; await Settings().init(); CarpResourceManager().initialize(); Sensing(); - if (config.deploymentMode != DeploymentMode.local) { + if (AppConfig.deploymentMode != DeploymentMode.local) { // Initialize and use the CAWS backend if not in local deployment mode if (await system.checkConnectivity()) { await auth.initialize(); @@ -123,7 +120,7 @@ class StudyAppBLoC extends ChangeNotifier { _state = StudyAppState.initialized; notifyListeners(); - debug('$runtimeType initialized - deployment mode: ${config.deploymentMode.name}'); + debug('$runtimeType initialized - deployment mode: ${AppConfig.deploymentMode.name}'); // For a returning user, check consent - via [_onConsentChanged] this // routes to the consent page or configures and starts the study. diff --git a/lib/blocs/app_config.dart b/lib/blocs/app_config.dart index f3f09e65..ab5bfd94 100644 --- a/lib/blocs/app_config.dart +++ b/lib/blocs/app_config.dart @@ -15,10 +15,11 @@ enum DeploymentMode { dev, } -/// App-wide configuration parsed from compile-time environment variables. +/// App-wide configuration parsed once from compile-time environment variables. /// -/// Deliberately dependency-free so that lower layers ([Sensing], [CarpBackend]) -/// can read configuration without depending on the global [bloc]. +/// A static, dependency-free holder so that lower layers ([Sensing], +/// [CarpBackend]) can read configuration without depending on the global +/// [bloc] - and without anything needing to instantiate it. /// /// The configuration is set using two environment variables: /// @@ -29,24 +30,20 @@ enum DeploymentMode { /// option in `flutter run`. For example: /// /// `flutter run --dart-define=deployment-mode=local,debug-level=info` -class AppConfig { - static final AppConfig _instance = AppConfig._(); - factory AppConfig() => _instance; - - AppConfig._() { - const dep = String.fromEnvironment('deployment-mode', defaultValue: 'production'); - deploymentMode = DeploymentMode.values.firstWhere((mode) => mode.name == dep); - - const deb = String.fromEnvironment('debug-level', defaultValue: 'info'); - debugLevel = DebugLevel.values.firstWhere((level) => level.name == deb); - } - +/// +/// Note: `String.fromEnvironment` only reads the `--dart-define` value in a +/// const context, hence the explicit `const` below. +abstract class AppConfig { /// What kind of deployment are we running? - late DeploymentMode deploymentMode; + static DeploymentMode deploymentMode = DeploymentMode.values.firstWhere( + (mode) => mode.name == const String.fromEnvironment('deployment-mode', defaultValue: 'production'), + ); /// Debug level for the app and CAMS. - late DebugLevel debugLevel; + static DebugLevel debugLevel = DebugLevel.values.firstWhere( + (level) => level.name == const String.fromEnvironment('debug-level', defaultValue: 'info'), + ); /// The localization (language) of this app. - RPLocalizations? localization; + static RPLocalizations? localization; } diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index b269cb0f..98d5e6ab 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -26,7 +26,7 @@ class Sensing { /// The deployment service used in this app. DeploymentService get deploymentService => - AppConfig().deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); + AppConfig.deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); /// The study running on this phone. /// Only available after [addStudy] is called. @@ -112,7 +112,7 @@ class Sensing { /// Initialize and set up sensing. Future initialize() async { - info('Initializing $runtimeType - mode: ${AppConfig().deploymentMode}'); + info('Initializing $runtimeType - mode: ${AppConfig.deploymentMode}'); // Set up the devices available on this phone DeviceController().registerAllAvailableDevices(); @@ -178,11 +178,10 @@ class Sensing { /// Translate the title and description of all AppTask in the study protocol /// of the current master deployment. void translateStudyProtocol([RPLocalizations? localization]) { - final config = AppConfig(); - config.localization ??= localization; + AppConfig.localization ??= localization; // Fast out if no localization - if (config.localization == null) return; + if (AppConfig.localization == null) return; // Fast out, if not deployed or no protocol. if (!(study?.isDeployed ?? false) || controller?.deployment == null) { @@ -191,11 +190,11 @@ class Sensing { for (var task in controller!.deployment!.tasks) { if (task is AppTask) { - task.title = config.localization!.translate(task.title); - task.description = config.localization!.translate(task.description); + task.title = AppConfig.localization!.translate(task.title); + task.description = AppConfig.localization!.translate(task.description); } } - info("$runtimeType - Study protocol translated to locale '${config.localization!.locale}'"); + info("$runtimeType - Study protocol translated to locale '${AppConfig.localization!.locale}'"); } } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index 2d23a168..a79a6231 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -35,7 +35,7 @@ class CarpStudyAppState extends State { final loc = state.matchedLocation; // 1) Not authenticated → login page. - if (bloc.config.deploymentMode != DeploymentMode.local && !bloc.auth.isAuthenticated) { + if (AppConfig.deploymentMode != DeploymentMode.local && !bloc.auth.isAuthenticated) { return LoginPage.route; } @@ -230,7 +230,7 @@ class CarpStudyAppState extends State { } return supportedLocales.first; // default to EN }, - locale: bloc.config.localization?.locale, + locale: AppConfig.localization?.locale, theme: carpTheme.copyWith( extensions: [carpTheme.extension()!.copyWith(primary: studyAppColors?.primary)], ), @@ -249,7 +249,7 @@ class _AppLocalizationsDelegate extends RPLocalizationsDelegate { @override Future load(Locale locale) async { final localizations = await super.load(locale); - AppConfig().localization = localizations; + AppConfig.localization = localizations; return localizations; } } diff --git a/lib/data/carp_backend.dart b/lib/data/carp_backend.dart index fbad551a..752bf357 100644 --- a/lib/data/carp_backend.dart +++ b/lib/data/carp_backend.dart @@ -28,14 +28,14 @@ class CarpBackend { } /// The URI of the CAWS server - depending on deployment mode. - Uri get uri => Uri(scheme: 'https', host: uris[AppConfig().deploymentMode]); + Uri get uri => Uri(scheme: 'https', host: uris[AppConfig.deploymentMode]); /// The URI of the CAWS authentication service. /// /// Of the form: /// https://dev.carp.dk/auth/realms/Carp/ Uri get authUri => - Uri(scheme: 'https', host: uris[AppConfig().deploymentMode], pathSegments: ['auth', 'realms', 'Carp']); + Uri(scheme: 'https', host: uris[AppConfig.deploymentMode], pathSegments: ['auth', 'realms', 'Carp']); /// The CAWS app configuration. late final CarpApp _app = CarpApp(name: "CAWS @ DTU", uri: uri); diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart index 7619ac3f..eb5bffdb 100644 --- a/lib/services/consent_service.dart +++ b/lib/services/consent_service.dart @@ -6,13 +6,10 @@ part of carp_study_app; /// Notifies its listeners when consent is accepted. Listen via the global /// [bloc], which chains service notifications to the router. class ConsentService extends ChangeNotifier { - ConsentService(this._manager, this._studyService, {AppConfig? config, CarpBackend? backend}) - : _config = config ?? AppConfig(), - _backend = backend ?? CarpBackend(); + ConsentService(this._manager, this._studyService, {CarpBackend? backend}) : _backend = backend ?? CarpBackend(); final InformedConsentManager _manager; final StudyService _studyService; - final AppConfig _config; final CarpBackend _backend; bool? _accepted; @@ -41,7 +38,7 @@ class ConsentService extends ChangeNotifier { /// backend and falls back to the locally stored flag. Future get hasBeenAccepted async { final study = _studyService.study; - if (_config.deploymentMode == DeploymentMode.local || study == null) { + if (AppConfig.deploymentMode == DeploymentMode.local || study == null) { return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; } try { @@ -61,7 +58,7 @@ class ConsentService extends ChangeNotifier { var participant = LocalSettings().participant; participant?.hasInformedConsentBeenAccepted = true; LocalSettings().participant = participant; - if (result != null && _config.deploymentMode != DeploymentMode.local) { + if (result != null && AppConfig.deploymentMode != DeploymentMode.local) { await _backend.uploadInformedConsent(result); } _accepted = true; diff --git a/lib/services/resource_manager_factory.dart b/lib/services/resource_manager_factory.dart index d905f0f1..5c0b9705 100644 --- a/lib/services/resource_manager_factory.dart +++ b/lib/services/resource_manager_factory.dart @@ -5,11 +5,7 @@ part of carp_study_app; /// /// Instances are created once and cached. class ResourceManagerFactory { - ResourceManagerFactory({AppConfig? config}) : _config = config ?? AppConfig(); - - final AppConfig _config; - - bool get _local => _config.deploymentMode == DeploymentMode.local; + bool get _local => AppConfig.deploymentMode == DeploymentMode.local; late final LocalizationManager localizationManager = (_local ? LocalResourceManager() : CarpResourceManager()) as LocalizationManager; diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart index 905bb0b0..e40640a4 100644 --- a/lib/services/study_service.dart +++ b/lib/services/study_service.dart @@ -7,11 +7,8 @@ part of carp_study_app; /// in [LocalSettings] and the CAWS service copies are seeded from the [study] /// setter - do not set them directly. class StudyService { - StudyService({AppConfig? config, ResourceManagerFactory? resources}) - : _config = config ?? AppConfig(), - _resources = resources ?? ResourceManagerFactory(); + StudyService({ResourceManagerFactory? resources}) : _resources = resources ?? ResourceManagerFactory(); - final AppConfig _config; final ResourceManagerFactory _resources; /// The study running on this phone, typically set based on an invitation. @@ -23,7 +20,7 @@ class StudyService { set study(SmartphoneStudy? study) { if (study == null) return; LocalSettings().study = study; - if (_config.deploymentMode != DeploymentMode.local) CarpBackend().study = study; + if (AppConfig.deploymentMode != DeploymentMode.local) CarpBackend().study = study; } /// Has a study been selected on this phone (i.e., an invitation accepted)? @@ -142,7 +139,7 @@ class StudyService { /// This method will deploy the protocol in the local SmartphoneDeploymentService /// which later will be used for deployment. See [Sensing.deploymentService]. Future deployLocalProtocol() async { - if (_config.deploymentMode != DeploymentMode.local) return; + if (AppConfig.deploymentMode != DeploymentMode.local) return; if (hasStudy) { info( diff --git a/test/app_bloc_test.dart b/test/app_bloc_test.dart index 462eab10..f236ca8a 100644 --- a/test/app_bloc_test.dart +++ b/test/app_bloc_test.dart @@ -10,7 +10,7 @@ void main() { ResearchPackage.ensureInitialized(); CognitionPackage.ensureInitialized(); await initTestSettings(); - AppConfig().deploymentMode = DeploymentMode.local; + AppConfig.deploymentMode = DeploymentMode.local; }); group('StudyAppBLoC.configureStudy', () { diff --git a/test/services_test.dart b/test/services_test.dart index f922c5c3..84be8c61 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -75,15 +75,13 @@ void main() { }); setUp(() { - AppConfig().deploymentMode = DeploymentMode.local; + AppConfig.deploymentMode = DeploymentMode.local; }); group('AppConfig', () { - test('defaults to production/info when no environment is given', () { - // The singleton was created without --dart-define values in tests. - expect(AppConfig(), same(AppConfig())); - expect(AppConfig().debugLevel, DebugLevel.info); - expect(AppConfig().localization, isNull); + test('defaults to info debug level when no environment is given', () { + // No --dart-define values are set in tests, so the default applies. + expect(AppConfig.debugLevel, DebugLevel.info); }); }); @@ -253,7 +251,7 @@ void main() { test('non-local mode asks the backend and is false when it fails', () async { LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); - AppConfig().deploymentMode = DeploymentMode.test; + AppConfig.deploymentMode = DeploymentMode.test; when(backend.getInformedConsentByRole('dep-1', null)).thenAnswer((_) async => throw Exception('offline')); expect(await consent.hasBeenAccepted, isFalse); @@ -261,7 +259,7 @@ void main() { test('non-local mode is true when the backend has a consent document', () async { LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); - AppConfig().deploymentMode = DeploymentMode.test; + AppConfig.deploymentMode = DeploymentMode.test; when( backend.getInformedConsentByRole('dep-1', null), ).thenAnswer((_) async => InformedConsentInput(userId: '42', name: 'jdoe', consent: '{}', signatureImage: '')); diff --git a/test/view_models_test.dart b/test/view_models_test.dart index 496211b6..e26ca071 100644 --- a/test/view_models_test.dart +++ b/test/view_models_test.dart @@ -36,7 +36,7 @@ void main() { ResearchPackage.ensureInitialized(); CognitionPackage.ensureInitialized(); await initTestSettings(); - AppConfig().deploymentMode = DeploymentMode.local; + AppConfig.deploymentMode = DeploymentMode.local; }); group('StudyPageViewModel', () { From ddc5bc73e95196abdac49406cafcac74fd0cfd80 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 14:07:51 +0200 Subject: [PATCH 70/94] refactor(consent): pass the study into ConsentService instead of injecting StudyService ConsentService only needed StudyService to read the current study in the backend consent check, and that check is reached only via refreshStatus, which is called only by the bloc (the coordinator that owns StudyService). Drop the service-to-service dependency: hasBeenAccepted/refreshStatus now take the study as a parameter and the bloc supplies it. ConsentService is now a focused leaf (consent manager + backend), independently testable, with no inter-service edge - and no study plumbing through the view models. --- integration_test/join_study_flow_test.dart | 4 ++-- lib/blocs/app_bloc.dart | 6 +++--- lib/services/consent_service.dart | 15 +++++++-------- test/services_test.dart | 18 ++++++++---------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart index 14687a77..44e74bb8 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, isFalse); + expect(await bloc.consent.hasBeenAccepted(bloc.study.study), 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, isTrue); + expect(await bloc.consent.hasBeenAccepted(bloc.study.study), isTrue); expect(bloc.study.isDeployed, isTrue); expect(bloc.study.deployment?.studyDeploymentId, status.studyDeploymentId); diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 75d79bd1..fbd01505 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -48,7 +48,7 @@ class StudyAppBLoC extends ChangeNotifier { late final MessageService messages = MessageService(resources.messageManager); /// The informed consent flow. - late final ConsentService consent = ConsentService(resources.informedConsentManager, study); + late final ConsentService consent = ConsentService(resources.informedConsentManager); /// The state of this BloC. StudyAppState get state => _state; @@ -124,7 +124,7 @@ class StudyAppBLoC extends ChangeNotifier { // For a returning user, check consent - via [_onConsentChanged] this // routes to the consent page or configures and starts the study. - if (study.hasStudy) unawaited(consent.refreshStatus()); + if (study.hasStudy) unawaited(consent.refreshStatus(study.study)); } /// Set the active study in the app based on an [invitation]. @@ -148,7 +148,7 @@ class StudyAppBLoC extends ChangeNotifier { // 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()); + unawaited(consent.refreshStatus(study.study)); } /// Configure the study deployment and start sensing. diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart index eb5bffdb..5b06fb5b 100644 --- a/lib/services/consent_service.dart +++ b/lib/services/consent_service.dart @@ -6,10 +6,9 @@ part of carp_study_app; /// Notifies its listeners when consent is accepted. Listen via the global /// [bloc], which chains service notifications to the router. class ConsentService extends ChangeNotifier { - ConsentService(this._manager, this._studyService, {CarpBackend? backend}) : _backend = backend ?? CarpBackend(); + ConsentService(this._manager, {CarpBackend? backend}) : _backend = backend ?? CarpBackend(); final InformedConsentManager _manager; - final StudyService _studyService; final CarpBackend _backend; bool? _accepted; @@ -21,9 +20,10 @@ class ConsentService extends ChangeNotifier { /// which needs a synchronous answer. bool? get isAccepted => _accepted; - /// Check [hasBeenAccepted], cache the answer in [isAccepted], and notify. - Future refreshStatus() async { - _accepted = await hasBeenAccepted; + /// Check [hasBeenAccepted] for [study], cache the answer in [isAccepted], + /// and notify. The coordinator supplies the active study. + Future refreshStatus(SmartphoneStudy? study) async { + _accepted = await hasBeenAccepted(study); notifyListeners(); return _accepted!; } @@ -31,13 +31,12 @@ class ConsentService extends ChangeNotifier { /// Forget the cached consent status, e.g. when leaving a study. void reset() => _accepted = null; - /// Has the informed consent been accepted by the user? + /// Has the informed consent been accepted by the user 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 get hasBeenAccepted async { - final study = _studyService.study; + Future hasBeenAccepted(SmartphoneStudy? study) async { if (AppConfig.deploymentMode == DeploymentMode.local || study == null) { return LocalSettings().participant?.hasInformedConsentBeenAccepted ?? false; } diff --git a/test/services_test.dart b/test/services_test.dart index 84be8c61..0659fb6d 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -203,14 +203,12 @@ void main() { group('ConsentService', () { late _FakeConsentManager manager; late MockCarpBackend backend; - late StudyService studyService; late ConsentService consent; setUp(() { manager = _FakeConsentManager(); backend = MockCarpBackend(); - studyService = StudyService(); - consent = ConsentService(manager, studyService, backend: backend); + consent = ConsentService(manager, backend: backend); }); test('getDocument delegates to the consent manager', () async { @@ -220,17 +218,17 @@ void main() { test('local mode falls back to the locally stored participant flag', () async { LocalSettings().participant = Participant(studyDeploymentId: 'dep-1'); - expect(await consent.hasBeenAccepted, isFalse); + expect(await consent.hasBeenAccepted(null), isFalse); await consent.accept(); - expect(await consent.hasBeenAccepted, isTrue); + 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(), isFalse); + expect(await consent.refreshStatus(null), isFalse); expect(consent.isAccepted, isFalse); await consent.accept(); @@ -250,21 +248,21 @@ void main() { }); test('non-local mode asks the backend and is false when it fails', () async { - LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); AppConfig.deploymentMode = DeploymentMode.test; + final study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); when(backend.getInformedConsentByRole('dep-1', null)).thenAnswer((_) async => throw Exception('offline')); - expect(await consent.hasBeenAccepted, isFalse); + expect(await consent.hasBeenAccepted(study), isFalse); }); test('non-local mode is true when the backend has a consent document', () async { - LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-1', deviceRoleName: 'phone'); AppConfig.deploymentMode = DeploymentMode.test; + 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, isTrue); + expect(await consent.hasBeenAccepted(study), isTrue); }); }); From 8bfb454ec74f471a49cf8e954a7a3661b4d5b150 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 14:44:56 +0200 Subject: [PATCH 71/94] refactor(auth): expose isAnonymous via AuthService, not LocalSettings isAnonymous is an authentication fact (set by CarpBackend's authenticate vs authenticateWithMagicLink), so it belongs behind AuthService. Add the getter and route the StudyPage/ProfilePage view-models and profile page through it, removing the direct LocalSettings().isAnonymous reads from the UI/VM layer. --- lib/services/auth_service.dart | 4 ++++ lib/ui/pages/profile_page.dart | 6 +++--- lib/view_models/profile_page_model.dart | 3 +++ lib/view_models/study_page_model.dart | 17 ++++++++++++----- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 90776084..49bb86ca 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -13,6 +13,10 @@ class AuthService { /// Has the user been authenticated? bool get isAuthenticated => _backend.isAuthenticated; + /// Did the user authenticate anonymously (via a magic link / QR code) + /// rather than a full CAWS login? + bool get isAnonymous => LocalSettings().isAnonymous; + /// The signed in user. Returns null if no user is signed in. CarpUser? get user => _backend.user; diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index 531c0e42..e7f31526 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -56,7 +56,7 @@ class ProfilePageState extends State { ], ), ), - LocalSettings().isAnonymous ? AnonymousCard() : SizedBox.shrink(), + widget.model.isAnonymous ? AnonymousCard() : SizedBox.shrink(), Flexible( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 15), @@ -68,13 +68,13 @@ class ProfilePageState extends State { _buildListTile(locale.translate('pages.profile.account_id'), widget.model.userId), _buildListTile( locale.translate('pages.profile.full_name'), - LocalSettings().isAnonymous + widget.model.isAnonymous ? locale.translate('pages.about.anonymous.anonymous') : widget.model.fullName, ), _buildListTile( locale.translate('pages.profile.email'), - LocalSettings().isAnonymous + widget.model.isAnonymous ? locale.translate('pages.about.anonymous.anonymous') : widget.model.email, ), diff --git a/lib/view_models/profile_page_model.dart b/lib/view_models/profile_page_model.dart index 829e0876..ee9e9706 100644 --- a/lib/view_models/profile_page_model.dart +++ b/lib/view_models/profile_page_model.dart @@ -14,6 +14,9 @@ class ProfilePageViewModel extends ViewModel { StudyService get _study => _studyService ?? bloc.study; SystemInfoService get _system => _systemInfoService ?? bloc.system; + /// Did the user authenticate anonymously (magic link / QR)? + bool get isAnonymous => _auth.isAnonymous; + /// Is the phone connected to the internet? Future checkConnectivity() => _system.checkConnectivity(); diff --git a/lib/view_models/study_page_model.dart b/lib/view_models/study_page_model.dart index 5167d148..f8e6f9a4 100644 --- a/lib/view_models/study_page_model.dart +++ b/lib/view_models/study_page_model.dart @@ -3,20 +3,27 @@ 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. class StudyPageViewModel extends ViewModel { - StudyPageViewModel({StudyService? studyService, MessageService? messageService, SystemInfoService? systemInfoService}) - : _studyService = studyService, - _messageService = messageService, - _systemInfoService = systemInfoService; + StudyPageViewModel({ + StudyService? studyService, + MessageService? messageService, + SystemInfoService? systemInfoService, + AuthService? authService, + }) : _studyService = studyService, + _messageService = messageService, + _systemInfoService = systemInfoService, + _authService = authService; final StudyService? _studyService; final MessageService? _messageService; final SystemInfoService? _systemInfoService; + final AuthService? _authService; bool _attachedToApp = false; bool _appUpdateAvailable = false; StudyService get _study => _studyService ?? bloc.study; MessageService get _messages => _messageService ?? bloc.messages; SystemInfoService get _system => _systemInfoService ?? bloc.system; + AuthService get _auth => _authService ?? bloc.auth; // Relay app-state changes (e.g. configuration completing) to the page, so // it only needs to listen to this view model. Attached lazily since the @@ -34,7 +41,7 @@ class StudyPageViewModel extends ViewModel { bool get isConfigured => bloc.isConfigured; /// Is the user signed in anonymously? - bool get isAnonymousUser => LocalSettings().isAnonymous; + bool get isAnonymousUser => _auth.isAnonymous; /// Is a newer version of this app available? Checked once on [init]. bool get appUpdateAvailable => _appUpdateAvailable; From c53b3e93aa8882af6a1383f1ecca7fb8e54001e7 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 15 Jun 2026 14:59:43 +0200 Subject: [PATCH 72/94] refactor(sensing): drop redundant old-namespace device aliases The manual loop registering device types under the pre-2.x namespace ('dk.cachet.carp.common.application.devices.*') is no longer needed: the current CAMS packages already register that backward-compat alias themselves - carp_mobile_sensing 2.1.2 -> Smartphone carp_context_package 2.0.1 -> Location/Weather/AirQuality services carp_health_package 4.0.1 -> HealthService carp_polar_package 2.0.1 -> PolarDevice carp_movesense_package 2.0.1 -> MovesenseDevice all under DeviceConfiguration.DEVICE_NAMESPACE. The original dev failure was on earlier package versions that lacked this; the workaround is now dead code. The 1.x/2.x protocol parse tests still pass, so they now guard the packages' backward-compat instead of our alias. --- lib/blocs/sensing.dart | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 98d5e6ab..55dd18c5 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -86,21 +86,6 @@ class Sensing { SamplingPackageRegistry().register(PolarSamplingPackage()); SamplingPackageRegistry().register(MovesenseSamplingPackage()); - // CAWS deployments created before CAMS 2.x serialize device types in the - // old namespace - register the device types under it as well. - const oldDeviceNamespace = 'dk.cachet.carp.common.application.devices'; - for (final device in [ - Smartphone(), - LocationService(), - WeatherService(apiKey: ''), - AirQualityService(apiKey: ''), - HealthService(), - PolarDevice(), - MovesenseDevice(), - ]) { - FromJsonFactory().register(device, type: '$oldDeviceNamespace.${device.runtimeType}'); - } - // Create and register external data managers. // The CARP data manager is needed in both LOCAL and CARP deployments, // since a local study protocol may still upload to CAWS. From ad38afae7e293d007f3a4f9ba7b7233d804be921 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Fri, 19 Jun 2026 08:41:02 +0200 Subject: [PATCH 73/94] refactor(sensing): reduce Sensing to the sensing infrastructure layer Sensing had grown into a god class owning the study lifecycle (deployment, status), protocol translation, and study removal on top of its actual job. This duplicated StudyService, which claimed to be the single owner of the active study yet delegated everything back. Move deployment orchestration (addStudy/tryDeployment/removeStudy), deployment-status fetching, the deployment-service selection, the runtime controller, and protocol translation into StudyService, the genuine study owner. Sensing now only registers sampling packages, data managers, and task factories and configures the client manager with a deployment service passed in by StudyService. StudyController equality is value-based (deployment id + role), so the persisted study resolves the same controller the client manager holds. --- lib/blocs/app_bloc.dart | 4 +- lib/blocs/sensing.dart | 143 +++----------------------------- lib/services/study_service.dart | 100 ++++++++++++++++++---- test/services_test.dart | 2 +- 4 files changed, 98 insertions(+), 151 deletions(-) diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index fbd01505..1c4b670c 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -177,7 +177,7 @@ class StudyAppBLoC extends ChangeNotifier { rethrow; } - appViewModel.init(Sensing().controller!); + appViewModel.init(study.controller!); messages.start(); @@ -244,7 +244,7 @@ class StudyAppBLoC extends ChangeNotifier { @override void dispose() { messages.dispose(); - Sensing().controller?.dispose(); + study.controller?.dispose(); super.dispose(); } } diff --git a/lib/blocs/sensing.dart b/lib/blocs/sensing.dart index 55dd18c5..3dc69c9e 100644 --- a/lib/blocs/sensing.dart +++ b/lib/blocs/sensing.dart @@ -7,72 +7,19 @@ part of carp_study_app; -/// This class implements the sensing layer. +/// The sensing layer. /// -/// Call [initialize] to setup a deployment using a CARP deployment. -/// Once initialized, use the [addStudy] method to add the study to this runtime. -/// The runtime [controller] can be used to control the study execution -/// (i.e., start or stop). +/// Registers the sampling packages, data managers, and task factories used by +/// the app, and configures the CAMS client manager via [initialize]. +/// +/// This is infrastructure only - it owns no study. The study lifecycle +/// (deployment, status, translation, runtime) is owned by [StudyService]. /// /// Note that this class is a singleton and only one sensing layer is used. -/// The current assumption at the moment is that this Study App only -/// runs one study at a time, even though CAMS supports that several studies -/// added to the [client]. class Sensing { static final Sensing _instance = Sensing._(); - StudyDeploymentStatus? _status; - SmartphoneStudyController? _controller; - SmartphoneStudy? _study; - - /// The deployment service used in this app. - DeploymentService get deploymentService => - AppConfig.deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); - - /// The study running on this phone. - /// Only available after [addStudy] is called. - SmartphoneStudy? get study => _study; - - /// The deployment running on this phone. - /// Only available after [addStudy] is called. - SmartphoneDeployment? get deployment => _controller?.deployment; - - /// The latest status of the study deployment. - StudyDeploymentStatus? get status => _controller?.deploymentStatus; - - /// The role name of this device in the deployed study. - String? get deviceRoleName => _study?.deviceRoleName; - - /// The study deployment id of the deployment running on this phone. - String? get studyDeploymentId => _study?.studyDeploymentId; - - /// The study runtime for this deployment. - SmartphoneStudyController? get controller => _controller; - - /// Is sensing running, i.e. has the study executor been resumed? - bool get isRunning => (controller != null) && controller!.executor.state == ExecutorState.Resumed; - /// The list of running - i.e. used - probes in this study. - List get runningProbes => (_controller != null) ? _controller!.executor.probes : []; - - /// The list of all device managers used in the current deployment. - /// - /// Note that not all available devices on this phone may be used in the - /// current deployment. Hence, this method returns the list of device managers - /// used in the current deployment. - List get deploymentDevices => deployment != null - ? SmartPhoneClientManager().deviceController.devices.values - .where((manager) => deployment!.devices.any((element) => element.type == manager.deviceType)) - .toList() - : []; - - /// The smartphone (primary device) manager. - SmartphoneDeviceManager get smartphoneDeviceManager => - SmartPhoneClientManager().deviceController.smartphoneDeviceManager; - - /// The list of connected devices. - List? get connectedDevices => SmartPhoneClientManager().deviceController.connectedDevices; - - /// The singleton sensing instance + /// The singleton sensing instance. factory Sensing() => _instance; Sensing._() { @@ -95,9 +42,10 @@ class Sensing { AppTaskController().registerUserTaskFactory(AppUserTaskFactory()); } - /// Initialize and set up sensing. - Future initialize() async { - info('Initializing $runtimeType - mode: ${AppConfig.deploymentMode}'); + /// Register the available devices and configure the CAMS client manager + /// with the given [deploymentService]. + Future initialize(DeploymentService deploymentService) async { + info('Initializing $runtimeType'); // Set up the devices available on this phone DeviceController().registerAllAvailableDevices(); @@ -113,73 +61,4 @@ class Sensing { info('$runtimeType initialized'); } - - /// Add the [study] to the client manager and deploy it. - Future addStudy(SmartphoneStudy study) async { - assert( - SmartPhoneClientManager().isConfigured, - 'The client manager is not yet configured. Call SmartPhoneClientManager().configure() before adding a study.', - ); - - _study = await SmartPhoneClientManager().addStudy(study); - - return await tryDeployment(); - } - - /// Try to deploy the study. - /// - /// Note that if the study has already been deployed on this phone it has - /// been cached locally and the local version will be used pr. default. - /// If not deployed before the study deployment will be fetched from the - /// deployment service. - Future tryDeployment() async { - assert(study != null, 'No study is provided. Cannot deploy w/o a study. Call addStudy() first.'); - - StudyStatus status = await SmartPhoneClientManager().tryDeployment(study!.studyDeploymentId, study!.deviceRoleName); - - _controller = SmartPhoneClientManager().getStudyController(_study!); - - translateStudyProtocol(); - - info('$runtimeType - Study added, deployment id: $studyDeploymentId'); - return status; - } - - Future removeStudy() async { - if (study == null) return; - await SmartPhoneClientManager().removeStudy(study!.studyDeploymentId, study!.deviceRoleName); - } - - /// Get the last known status for the current study deployment. - /// Use [getStudyDeploymentStatus] to refresh the status from CAWS. - /// Returns null if the study is not yet deployed on this phone. - StudyDeploymentStatus? get studyDeploymentStatus => _status; - - /// Get the status for the current study deployment. - /// Returns null if the study is not yet deployed on this phone. - Future getStudyDeploymentStatus() async => - studyDeploymentId != null ? _status = await deploymentService.getStudyDeploymentStatus(studyDeploymentId!) : null; - - /// Translate the title and description of all AppTask in the study protocol - /// of the current master deployment. - void translateStudyProtocol([RPLocalizations? localization]) { - AppConfig.localization ??= localization; - - // Fast out if no localization - if (AppConfig.localization == null) return; - - // Fast out, if not deployed or no protocol. - if (!(study?.isDeployed ?? false) || controller?.deployment == null) { - return; - } - - for (var task in controller!.deployment!.tasks) { - if (task is AppTask) { - task.title = AppConfig.localization!.translate(task.title); - task.description = AppConfig.localization!.translate(task.description); - } - } - - info("$runtimeType - Study protocol translated to locale '${AppConfig.localization!.locale}'"); - } } diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart index e40640a4..32218638 100644 --- a/lib/services/study_service.dart +++ b/lib/services/study_service.dart @@ -10,6 +10,12 @@ class StudyService { StudyService({ResourceManagerFactory? resources}) : _resources = resources ?? ResourceManagerFactory(); final ResourceManagerFactory _resources; + SmartphoneStudyController? _controller; + StudyDeploymentStatus? _status; + + /// The deployment service used in this app, selected by the deployment mode. + DeploymentService get deploymentService => + AppConfig.deploymentMode == DeploymentMode.local ? SmartphoneDeploymentService() : CarpDeploymentService(); /// The study running on this phone, typically set based on an invitation. /// Returns null if no study has been selected (yet). @@ -33,9 +39,13 @@ class StudyService { /// deployment been received and validated? bool get isDeployed => deployment != null; + /// The study runtime controller for the current study. + /// Only available after the study has been added via [configure]. + SmartphoneStudyController? get controller => _controller; + /// The deployment running on this phone. /// Returns null if the study has not (yet) been deployed. - SmartphoneDeployment? get deployment => Sensing().controller?.deployment; + SmartphoneDeployment? get deployment => _controller?.deployment; /// When was this study deployed on this phone. DateTime? get studyStartTimestamp => deployment?.deployed; @@ -44,11 +54,14 @@ class StudyService { /// Refresh and return the status of the current study deployment from the /// deployment service. Returns null if no study has been deployed. - Future refreshDeploymentStatus() => Sensing().getStudyDeploymentStatus(); + Future refreshDeploymentStatus() async { + final id = study?.studyDeploymentId; + return id != null ? _status = await deploymentService.getStudyDeploymentStatus(id) : null; + } /// The last known status of the study deployment, without contacting the /// deployment service. Use [refreshDeploymentStatus] to refresh it. - StudyDeploymentStatus? get cachedDeploymentStatus => Sensing().studyDeploymentStatus; + StudyDeploymentStatus? get cachedDeploymentStatus => _status; /// Initialize sensing and deploy the [study] on this phone. /// @@ -57,22 +70,46 @@ class StudyService { Future configure() async { if (study == null) throw StateError('No study set - cannot configure a study deployment.'); - await Sensing().initialize(); - final status = await Sensing().addStudy(study!); + await Sensing().initialize(deploymentService); + final status = await addStudy(study!); if (!isDeployed) throw StateError('Study deployment did not succeed - status: $status.'); } - /// Re-attempt deployment of the current study, e.g. on a pull-to-refresh. - /// Returns null if the study has not been added to the sensing runtime yet. - Future tryDeployment() async => Sensing().study == null ? null : await Sensing().tryDeployment(); + /// Add the [study] to the client manager and deploy it. + Future addStudy(SmartphoneStudy study) async { + assert( + SmartPhoneClientManager().isConfigured, + 'The client manager is not yet configured. Call Sensing().initialize() before adding a study.', + ); + + await SmartPhoneClientManager().addStudy(study); + return await tryDeployment(); + } + + /// Try to deploy the current study. + /// + /// Note that if the study has already been deployed on this phone it has + /// been cached locally and the local version will be used pr. default. + /// If not deployed before the study deployment will be fetched from the + /// deployment service. Returns null if no study has been selected yet. + Future tryDeployment() async { + if (study == null) return null; + + final status = await SmartPhoneClientManager().tryDeployment(study!.studyDeploymentId, study!.deviceRoleName); + _controller = SmartPhoneClientManager().getStudyController(study!); + translateProtocol(); + + info('$runtimeType - Study added, deployment id: ${study!.studyDeploymentId}'); + return status; + } /// Is sensing running, i.e. has the study executor been resumed? - bool get isRunning => Sensing().isRunning; + bool get isRunning => (_controller != null) && _controller!.executor.state == ExecutorState.Resumed; /// Start sensing, if the study is deployed and not permanently stopped. Future start() async { - final controller = Sensing().controller; + final controller = _controller; if (controller == null || !isDeployed || controller.study.status == StudyStatus.Stopped) { warning( '$runtimeType - Cannot start sensing - the study is not deployed ' @@ -99,11 +136,17 @@ class StudyService { } /// Add [measurement] to the stream of collected measurements. - void addMeasurement(Measurement measurement) => Sensing().controller?.executor.addMeasurement(measurement); + void addMeasurement(Measurement measurement) => _controller?.executor.addMeasurement(measurement); - /// The list of all devices in this deployment. - Iterable get deploymentDevices => - Sensing().deploymentDevices.map((device) => DeviceViewModel(device)); + /// The list of all devices used in the current deployment. + /// + /// Note that not all available devices on this phone may be used in the + /// current deployment. + Iterable get deploymentDevices => deployment == null + ? [] + : SmartPhoneClientManager().deviceController.devices.values + .where((manager) => deployment!.devices.any((device) => device.type == manager.deviceType)) + .map((manager) => DeviceViewModel(manager)); /// Does this [deployment] have any measures (besides app tasks)? bool hasMeasures() => (deployment == null) @@ -122,6 +165,27 @@ class StudyService { /// Does this [deployment] have any user tasks? bool hasUserTasks() => (deployment == null) ? false : deployment!.tasks.whereType().isNotEmpty; + /// Translate the title and description of all [AppTask]s in the current + /// deployment using [AppConfig.localization]. + void translateProtocol([RPLocalizations? localization]) { + AppConfig.localization ??= localization; + + // Fast out if no localization + if (AppConfig.localization == null) return; + + // Fast out, if not deployed or no protocol. + if (!(study?.isDeployed ?? false) || deployment == null) return; + + for (var task in deployment!.tasks) { + if (task is AppTask) { + task.title = AppConfig.localization!.translate(task.title); + task.description = AppConfig.localization!.translate(task.description); + } + } + + info("$runtimeType - Study protocol translated to locale '${AppConfig.localization!.locale}'"); + } + /// Get the participant data for the current deployment. Future> getParticipantDataListFromDeployment() async => (deployment == null) ? [] @@ -137,7 +201,7 @@ class StudyService { /// assets/carp/resources/protocol.json /// /// This method will deploy the protocol in the local SmartphoneDeploymentService - /// which later will be used for deployment. See [Sensing.deploymentService]. + /// which later will be used for deployment. See [deploymentService]. Future deployLocalProtocol() async { if (AppConfig.deploymentMode != DeploymentMode.local) return; @@ -175,7 +239,11 @@ class StudyService { /// Stop sensing and remove all study deployment information from this phone. Future remove() async { - await Sensing().removeStudy(); + if (study != null) { + await SmartPhoneClientManager().removeStudy(study!.studyDeploymentId, study!.deviceRoleName); + } + _controller = null; + _status = null; await LocalSettings().eraseStudyDeployment(); } } diff --git a/test/services_test.dart b/test/services_test.dart index 0659fb6d..632828a5 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -268,7 +268,7 @@ void main() { group('Old-namespace CAWS compatibility', () { test('a pre-CAMS-2.x deployment status with CAMS device types deserializes', () { - Sensing(); // registers the sampling packages and old-namespace device aliases + Sensing(); // registers the sampling packages // The shape CAWS returns for deployments created before CAMS 2.x - // device types are in the old 'dk.cachet.carp.common.application.devices' From 5903f2218c4a075b76654d20459c3376fe1f0cbd Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 22 Jun 2026 13:46:10 +0200 Subject: [PATCH 74/94] fix: small refactor to invitation details --- lib/main.dart | 2 +- .../{invitation_page.dart => invitation_details_page.dart} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/ui/pages/{invitation_page.dart => invitation_details_page.dart} (100%) diff --git a/lib/main.dart b/lib/main.dart index bf578866..1d37041a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -125,7 +125,7 @@ part 'ui/tasks/audio_task_page.dart'; part 'ui/tasks/audio_page.dart'; part 'ui/pages/study_details_page.dart'; part 'ui/pages/message_details_page.dart'; -part 'ui/pages/invitation_page.dart'; +part 'ui/pages/invitation_details_page.dart'; part 'ui/pages/invitation_list_page.dart'; part 'ui/pages/process_message_page.dart'; part 'ui/tasks/camera_task_page.dart'; diff --git a/lib/ui/pages/invitation_page.dart b/lib/ui/pages/invitation_details_page.dart similarity index 100% rename from lib/ui/pages/invitation_page.dart rename to lib/ui/pages/invitation_details_page.dart From 382131558f6c6cbbb50d69d232a2476f97ffa79f Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 22 Jun 2026 15:17:08 +0200 Subject: [PATCH 75/94] fix: --- lib/blocs/app_bloc.dart | 25 +++++++- lib/blocs/app_log.dart | 64 +++++++++++++++++++++ lib/carp_study_app.dart | 6 +- lib/main.dart | 1 + lib/services/consent_service.dart | 2 + lib/services/study_service.dart | 2 + lib/ui/pages/login_page.dart | 4 ++ lib/view_models/invitations_view_model.dart | 11 +++- lib/view_models/login_page_model.dart | 5 +- 9 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 lib/blocs/app_log.dart diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 1c4b670c..0676bd3a 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -78,6 +78,7 @@ class StudyAppBLoC extends ChangeNotifier { } void _onConsentChanged() { + logAppState('StudyAppBLoC._onConsentChanged() - consent.isAccepted=${consent.isAccepted}'); notifyListeners(); if (consent.isAccepted == true) unawaited(tryConfigureStudy()); } @@ -86,9 +87,11 @@ class StudyAppBLoC extends ChangeNotifier { /// throwing. Used by the setup flow and retry affordances, where no /// caller can handle the error. Future tryConfigureStudy() async { + logAppState('StudyAppBLoC.tryConfigureStudy() START'); try { await configureStudy(); } catch (error) { + logAppState('StudyAppBLoC.tryConfigureStudy() FAILED - $error'); final context = scaffoldKey.currentContext; final locale = context != null ? RPLocalizations.of(context) : null; scaffoldKey.currentState?.showSnackBar( @@ -100,6 +103,7 @@ class StudyAppBLoC extends ChangeNotifier { /// Initialize this BLOC. Called before being used for anything. Future initialize() async { if (isInitialized) return; + logAppState('StudyAppBLoC.initialize() START'); Settings().debugLevel = AppConfig.debugLevel; await Settings().init(); @@ -122,9 +126,14 @@ class StudyAppBLoC extends ChangeNotifier { notifyListeners(); debug('$runtimeType initialized - deployment mode: ${AppConfig.deploymentMode.name}'); + logAppState('StudyAppBLoC.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) unawaited(consent.refreshStatus(study.study)); + if (study.hasStudy) { + logApp('StudyAppBLoC.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]. @@ -132,6 +141,11 @@ class StudyAppBLoC extends ChangeNotifier { /// The study translations are re-loaded by the app, which listens for the /// study change. void setStudyInvitation(ActiveParticipationInvitation invitation) { + logApp( + 'StudyAppBLoC.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); @@ -145,6 +159,7 @@ class StudyAppBLoC extends ChangeNotifier { notifyListeners(); info('Invitation received - study: ${study.study}'); + logAppState('StudyAppBLoC.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. @@ -164,7 +179,11 @@ class StudyAppBLoC extends ChangeNotifier { /// to surface. Future configureStudy() async { // early out if already configuring or configured - if (_state == StudyAppState.configuring || isConfigured) return; + if (_state == StudyAppState.configuring || isConfigured) { + logApp('StudyAppBLoC.configureStudy() - skipped, already ${_state.name}'); + return; + } + logAppState('StudyAppBLoC.configureStudy() START'); final previousState = _state; _state = StudyAppState.configuring; @@ -174,6 +193,7 @@ class StudyAppBLoC extends ChangeNotifier { } catch (error) { _state = previousState; warning('$runtimeType - Study configuration failed - $error'); + logAppState('StudyAppBLoC.configureStudy() FAILED - study.configure() threw - $error'); rethrow; } @@ -187,6 +207,7 @@ class StudyAppBLoC extends ChangeNotifier { _state = StudyAppState.configured; notifyListeners(); + logAppState('StudyAppBLoC.configureStudy() DONE - now starting sensing'); await study.start(); } diff --git a/lib/blocs/app_log.dart b/lib/blocs/app_log.dart new file mode 100644 index 00000000..43599aae --- /dev/null +++ b/lib/blocs/app_log.dart @@ -0,0 +1,64 @@ +part of carp_study_app; + +/// Tag prefixed to every app-specific log line. Filter device logs to just the +/// study app's own flow with `flutter logs | grep CARP_STUDY_APP` (these lines +/// are intentionally separate from the framework's `[CAMS ...]` logs). +const String appLogTag = 'CARP_STUDY_APP'; + +/// Log an app-specific [message] under the [appLogTag] tag. +/// +/// Use for tracing the app's own flow (e.g. login → study) without dumping the +/// full state snapshot - reach for [logAppState] when the state matters. +void logApp(String message) => debugPrint('[$appLogTag] $message'); + +/// Log [message] followed by a one-line-per-field snapshot of the current app +/// state: bloc state, auth, backend, the active study/participant ids, consent, +/// and what is persisted in [LocalSettings]. +/// +/// Call this at the key transitions in the login → study flow to answer +/// "where am I and what do I have" at that exact point. +void logAppState(String message) => debugPrint('[$appLogTag] $message\n${_appStateSnapshot()}'); + +/// Evaluate [getter], returning a placeholder instead of throwing - state +/// logging must never crash the flow it is meant to observe. +Object? _safe(Object? Function() getter) { + try { + return getter(); + } catch (error) { + return ''; + } +} + +/// A one-line-per-field dump of the current app state, used by [logAppState]. +String _appStateSnapshot() { + final buffer = StringBuffer(); + void line(String key, Object? value) => buffer.writeln(' $key = $value'); + + SmartphoneStudy? study; + try { + study = bloc.study.study; + } catch (_) {} + final settings = LocalSettings(); + + line('bloc.state', _safe(() => bloc.state.name)); + line('isInitialized/isConfiguring/isConfigured', _safe(() => '${bloc.isInitialized}/${bloc.isConfiguring}/${bloc.isConfigured}')); + line('deploymentMode', _safe(() => AppConfig.deploymentMode.name)); + line('authenticated', _safe(() => bloc.auth.isAuthenticated)); + line('anonymous', _safe(() => bloc.auth.isAnonymous)); + line('user', _safe(() => bloc.auth.username)); + line('backend.study (seeded in CAWS)', _safe(() => CarpService().study?.studyDeploymentId)); + line('study.hasStudy', _safe(() => bloc.study.hasStudy)); + line('study.isDeployed', _safe(() => bloc.study.isDeployed)); + line('study.deploymentId', study?.studyDeploymentId); + line('study.participantId', study?.participantId); + line('study.participantRole', study?.participantRoleName); + line('study.deviceRole', study?.deviceRoleName); + line('consent.isAccepted', _safe(() => bloc.consent.isAccepted)); + line('LocalSettings.user', _safe(() => settings.user?.username)); + line('LocalSettings.study.deploymentId', _safe(() => settings.studyDeploymentId)); + line('LocalSettings.participant.id', _safe(() => settings.participant?.participantId)); + line('LocalSettings.participant.consentAccepted', _safe(() => settings.participant?.hasInformedConsentBeenAccepted)); + line('LocalSettings.isAnonymous', _safe(() => settings.isAnonymous)); + + return buffer.toString().trimRight(); +} diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index a79a6231..e047d8a9 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -27,15 +27,17 @@ class CarpStudyAppState extends State { // changes notify the router via refreshListenable, which re-evaluates the // redirect at the current location and moves the user automatically. final GoRouter _router = GoRouter( - initialLocation: StudyPage.route, + initialLocation: homeRoute, navigatorKey: _rootNavigatorKey, refreshListenable: bloc, 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; } @@ -43,8 +45,10 @@ class CarpStudyAppState 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; } diff --git a/lib/main.dart b/lib/main.dart index 1d37041a..2d4fc558 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -61,6 +61,7 @@ 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'; diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart index 5b06fb5b..007fb60c 100644 --- a/lib/services/consent_service.dart +++ b/lib/services/consent_service.dart @@ -23,7 +23,9 @@ class ConsentService extends ChangeNotifier { /// 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!; } diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart index 32218638..51d75388 100644 --- a/lib/services/study_service.dart +++ b/lib/services/study_service.dart @@ -69,10 +69,12 @@ 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}'); await Sensing().initialize(deploymentService); 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 0b1cbaf5..2e1d9bb9 100644 --- a/lib/ui/pages/login_page.dart +++ b/lib/ui/pages/login_page.dart @@ -58,6 +58,10 @@ 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/invitations_view_model.dart b/lib/view_models/invitations_view_model.dart index 75e1da07..a314d99c 100644 --- a/lib/view_models/invitations_view_model.dart +++ b/lib/view_models/invitations_view_model.dart @@ -30,11 +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'); try { _invitations = await _auth.getInvitations(); _error = null; + logApp( + 'InvitationsViewModel.loadInvitations() DONE - ${_invitations!.length} invitation(s): ' + '${_invitations!.map((i) => i.studyDeploymentId).toList()}', + ); } catch (error) { warning('$runtimeType - Could not load invitations - $error'); + logApp('InvitationsViewModel.loadInvitations() FAILED - $error'); _error = error; } notifyListeners(); @@ -52,7 +58,10 @@ class InvitationsViewModel extends ViewModel { invitations.firstWhere((invitation) => invitation.participation.participantId == invitationId); /// Accept [invitation] and make it the active study in the app. - void accept(ActiveParticipationInvitation invitation) => bloc.setStudyInvitation(invitation); + void accept(ActiveParticipationInvitation invitation) { + logApp('InvitationsViewModel.accept() - deploymentId=${invitation.studyDeploymentId}'); + bloc.setStudyInvitation(invitation); + } /// Sign out and return to the login page. Future signOut() => bloc.signOutAndLeaveStudy(); diff --git a/lib/view_models/login_page_model.dart b/lib/view_models/login_page_model.dart index 9f82c1d6..37d1b812 100644 --- a/lib/view_models/login_page_model.dart +++ b/lib/view_models/login_page_model.dart @@ -20,13 +20,16 @@ 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(); await _auth.authenticate(); notifyListeners(); - return _auth.isAuthenticated ? SignInResult.success : SignInResult.failed; + final result = _auth.isAuthenticated ? SignInResult.success : SignInResult.failed; + logAppState('LoginViewModel.signIn() DONE - result=${result.name}'); + return result; } /// Sign in anonymously using a scanned magic link. From 60b8104d0b7491f48d317af020dba1a51d7ccca1 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Mon, 22 Jun 2026 15:17:22 +0200 Subject: [PATCH 76/94] fix> update launch.json --- .vscode/launch.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6b49dbc8..951bf8fc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,6 +27,32 @@ ], "flutterMode": "debug" }, + { + // profile mode using the DEV CAWS instance - for measuring jank/perf + "name": "DEV (profile) - carp-studies-app", + "request": "launch", + "type": "dart", + "toolArgs": [ + "--dart-define", + "deployment-mode=dev", + "--dart-define", + "debug-level=info" + ], + "flutterMode": "profile" + }, + { + // release mode using the DEV CAWS instance - production-speed sanity check + "name": "DEV (release) - carp-studies-app", + "request": "launch", + "type": "dart", + "toolArgs": [ + "--dart-define", + "deployment-mode=dev", + "--dart-define", + "debug-level=info" + ], + "flutterMode": "release" + }, { // debug mode using the TEST CAWS instance "name": "TEST - carp-studies-app", From 1ef0573ec80b6da40a5a5f3392d4e82c73812782 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 23 Jun 2026 09:27:25 +0200 Subject: [PATCH 77/94] fix: moving sensing initialize to init --- lib/blocs/app_bloc.dart | 2 +- lib/services/study_service.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index 0676bd3a..f93ea8f1 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -110,7 +110,7 @@ class StudyAppBLoC extends ChangeNotifier { CarpResourceManager().initialize(); - Sensing(); + await Sensing().initialize(study.deploymentService); if (AppConfig.deploymentMode != DeploymentMode.local) { // Initialize and use the CAWS backend if not in local deployment mode diff --git a/lib/services/study_service.dart b/lib/services/study_service.dart index 51d75388..5fcc2df6 100644 --- a/lib/services/study_service.dart +++ b/lib/services/study_service.dart @@ -71,7 +71,6 @@ class StudyService { if (study == null) throw StateError('No study set - cannot configure a study deployment.'); logApp('StudyService.configure() - deploymentId=${study!.studyDeploymentId}, deviceRole=${study!.deviceRoleName}'); - await Sensing().initialize(deploymentService); final status = await addStudy(study!); logApp('StudyService.configure() - addStudy returned status=$status, isDeployed=$isDeployed'); From 0df09b6a1929cc3750fb75dcb1378ea1f7f6b7da Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Tue, 23 Jun 2026 09:30:04 +0200 Subject: [PATCH 78/94] fix: dart format --- lib/blocs/app_log.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/blocs/app_log.dart b/lib/blocs/app_log.dart index 43599aae..267cf81c 100644 --- a/lib/blocs/app_log.dart +++ b/lib/blocs/app_log.dart @@ -41,7 +41,10 @@ String _appStateSnapshot() { final settings = LocalSettings(); line('bloc.state', _safe(() => bloc.state.name)); - line('isInitialized/isConfiguring/isConfigured', _safe(() => '${bloc.isInitialized}/${bloc.isConfiguring}/${bloc.isConfigured}')); + 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)); From 855315f06cce256c11b4eb903e8f7f6c3188a3b4 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 24 Jun 2026 12:54:10 +0200 Subject: [PATCH 79/94] refactor(bloc): drop StudyApp prefix from bloc, state, view-model --- integration_test/join_study_flow_test.dart | 2 +- lib/blocs/app_bloc.dart | 59 +++++++++++----------- lib/carp_study_app.dart | 12 ++--- lib/data/local_settings.dart | 2 +- lib/main.dart | 2 +- lib/ui/pages/error_page.dart | 2 +- lib/ui/pages/home_page.dart | 4 +- lib/ui/pages/informed_consent_page.dart | 4 +- lib/ui/pages/message_details_page.dart | 2 +- lib/ui/pages/profile_page.dart | 2 +- lib/ui/pages/study_details_page.dart | 2 +- lib/ui/pages/study_page.dart | 2 +- lib/ui/tasks/audio_task_page.dart | 2 +- lib/view_models/view_model.dart | 4 +- test/app_bloc_test.dart | 10 ++-- 15 files changed, 55 insertions(+), 56 deletions(-) diff --git a/integration_test/join_study_flow_test.dart b/integration_test/join_study_flow_test.dart index 44e74bb8..4ffe33c2 100644 --- a/integration_test/join_study_flow_test.dart +++ b/integration_test/join_study_flow_test.dart @@ -191,7 +191,7 @@ void main() { // invitation flow. await bloc.signOutAndLeaveStudy(); - expect(bloc.state, StudyAppState.initialized); + expect(bloc.state, AppState.initialized); expect(bloc.study.hasStudy, isFalse); expect(bloc.study.isRunning, isFalse); expect(bloc.auth.isAuthenticated, isFalse); diff --git a/lib/blocs/app_bloc.dart b/lib/blocs/app_bloc.dart index f93ea8f1..7d8b9c8d 100644 --- a/lib/blocs/app_bloc.dart +++ b/lib/blocs/app_bloc.dart @@ -1,7 +1,7 @@ part of carp_study_app; -/// The state of the [StudyAppBLoC]. -enum StudyAppState { +/// The state of the [AppBloc]. +enum AppState { /// The BLoC is created but not ready for use. created, @@ -27,9 +27,9 @@ enum StudyAppState { /// /// Works as a [ChangeNotifier] and will notify its listeners (incl. the /// router) on important changes. -class StudyAppBLoC extends ChangeNotifier { - StudyAppState _state = StudyAppState.created; - final CarpStudyAppViewModel _appViewModel = CarpStudyAppViewModel(); +class AppBloc extends ChangeNotifier { + AppState _state = AppState.created; + final AppViewModel _appViewModel = AppViewModel(); StreamSubscription? _userTaskNotificationSubscription; /// The resource managers matching the current deployment mode. @@ -51,21 +51,21 @@ class StudyAppBLoC extends ChangeNotifier { late final ConsentService consent = ConsentService(resources.informedConsentManager); /// The state of this BloC. - StudyAppState get state => _state; + AppState get state => _state; - bool get isInitialized => _state.index >= StudyAppState.initialized.index; - bool get isConfiguring => _state.index >= StudyAppState.configuring.index; - bool get isConfigured => _state.index >= StudyAppState.configured.index; + bool get isInitialized => _state.index >= AppState.initialized.index; + bool get isConfiguring => _state.index >= AppState.configuring.index; + bool get isConfigured => _state.index >= AppState.configured.index; /// The overall data model for this app - CarpStudyAppViewModel get appViewModel => _appViewModel; + AppViewModel get appViewModel => _appViewModel; // ScaffoldMessenger for showing snack bars final _scaffoldKey = GlobalKey(); GlobalKey get scaffoldKey => _scaffoldKey; /// Create the BLoC for the app. - StudyAppBLoC() : super() { + AppBloc() : super() { info( '$runtimeType created. ' 'DeploymentMode: ${AppConfig.deploymentMode.name}, ' @@ -78,7 +78,7 @@ class StudyAppBLoC extends ChangeNotifier { } void _onConsentChanged() { - logAppState('StudyAppBLoC._onConsentChanged() - consent.isAccepted=${consent.isAccepted}'); + logAppState('AppBloc._onConsentChanged() - consent.isAccepted=${consent.isAccepted}'); notifyListeners(); if (consent.isAccepted == true) unawaited(tryConfigureStudy()); } @@ -87,11 +87,11 @@ class StudyAppBLoC extends ChangeNotifier { /// throwing. Used by the setup flow and retry affordances, where no /// caller can handle the error. Future tryConfigureStudy() async { - logAppState('StudyAppBLoC.tryConfigureStudy() START'); + logAppState('AppBloc.tryConfigureStudy() START'); try { await configureStudy(); } catch (error) { - logAppState('StudyAppBLoC.tryConfigureStudy() FAILED - $error'); + logAppState('AppBloc.tryConfigureStudy() FAILED - $error'); final context = scaffoldKey.currentContext; final locale = context != null ? RPLocalizations.of(context) : null; scaffoldKey.currentState?.showSnackBar( @@ -103,15 +103,13 @@ class StudyAppBLoC extends ChangeNotifier { /// Initialize this BLOC. Called before being used for anything. Future initialize() async { if (isInitialized) return; - logAppState('StudyAppBLoC.initialize() START'); + logAppState('AppBloc.initialize() START'); Settings().debugLevel = AppConfig.debugLevel; await Settings().init(); CarpResourceManager().initialize(); - await Sensing().initialize(study.deploymentService); - if (AppConfig.deploymentMode != DeploymentMode.local) { // Initialize and use the CAWS backend if not in local deployment mode if (await system.checkConnectivity()) { @@ -121,17 +119,18 @@ class StudyAppBLoC extends ChangeNotifier { // Deploy the local protocol if running in local mode await study.deployLocalProtocol(); } + await Sensing().initialize(study.deploymentService); - _state = StudyAppState.initialized; + _state = AppState.initialized; notifyListeners(); debug('$runtimeType initialized - deployment mode: ${AppConfig.deploymentMode.name}'); - logAppState('StudyAppBLoC.initialize() DONE'); + 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('StudyAppBLoC.initialize() - returning user has a persisted study, refreshing consent status'); + logApp('AppBloc.initialize() - returning user has a persisted study, refreshing consent status'); unawaited(consent.refreshStatus(study.study)); } } @@ -142,7 +141,7 @@ class StudyAppBLoC extends ChangeNotifier { /// study change. void setStudyInvitation(ActiveParticipationInvitation invitation) { logApp( - 'StudyAppBLoC.setStudyInvitation() - accepting invitation: ' + 'AppBloc.setStudyInvitation() - accepting invitation: ' 'deploymentId=${invitation.studyDeploymentId}, participantId=${invitation.participation.participantId}', ); @@ -159,7 +158,7 @@ class StudyAppBLoC extends ChangeNotifier { notifyListeners(); info('Invitation received - study: ${study.study}'); - logAppState('StudyAppBLoC.setStudyInvitation() DONE - study set, refreshing consent status'); + 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. @@ -179,21 +178,21 @@ class StudyAppBLoC extends ChangeNotifier { /// to surface. Future configureStudy() async { // early out if already configuring or configured - if (_state == StudyAppState.configuring || isConfigured) { - logApp('StudyAppBLoC.configureStudy() - skipped, already ${_state.name}'); + if (_state == AppState.configuring || isConfigured) { + logApp('AppBloc.configureStudy() - skipped, already ${_state.name}'); return; } - logAppState('StudyAppBLoC.configureStudy() START'); + logAppState('AppBloc.configureStudy() START'); final previousState = _state; - _state = StudyAppState.configuring; + _state = AppState.configuring; try { await study.configure(); } catch (error) { _state = previousState; warning('$runtimeType - Study configuration failed - $error'); - logAppState('StudyAppBLoC.configureStudy() FAILED - study.configure() threw - $error'); + logAppState('AppBloc.configureStudy() FAILED - study.configure() threw - $error'); rethrow; } @@ -205,9 +204,9 @@ class StudyAppBLoC extends ChangeNotifier { info('Study configuration done.'); - _state = StudyAppState.configured; + _state = AppState.configured; notifyListeners(); - logAppState('StudyAppBLoC.configureStudy() DONE - now starting sensing'); + logAppState('AppBloc.configureStudy() DONE - now starting sensing'); await study.start(); } @@ -247,7 +246,7 @@ class StudyAppBLoC extends ChangeNotifier { // stop sensing and remove all deployment info await study.remove(); - _state = StudyAppState.initialized; + _state = AppState.initialized; notifyListeners(); } diff --git a/lib/carp_study_app.dart b/lib/carp_study_app.dart index e047d8a9..4147cba6 100644 --- a/lib/carp_study_app.dart +++ b/lib/carp_study_app.dart @@ -8,15 +8,15 @@ class CarpStudyApp extends StatefulWidget { /// Reload language translations and re-build the entire app. static void reloadLocale(BuildContext context) async { - CarpStudyAppState? state = context.findAncestorStateOfType(); + CarpAppState? state = context.findAncestorStateOfType(); state?.reloadLocale(); } @override - CarpStudyAppState createState() => CarpStudyAppState(); + CarpAppState createState() => CarpAppState(); } -class CarpStudyAppState extends State { +class CarpAppState extends State { /// The landing page once the onboarding process is done. static const String homeRoute = '/'; @@ -174,7 +174,7 @@ class CarpStudyAppState extends State { loaders: [const AssetLocalizationLoader(), bloc.resources.localizationLoader], ); - StudyAppState _previousBlocState = bloc.state; + AppState _previousBlocState = bloc.state; String? _previousDeploymentId = bloc.study.study?.studyDeploymentId; @override @@ -192,11 +192,11 @@ class CarpStudyAppState extends State { /// Re-load translations when a (new) study is set or has been configured, /// since both make new study-specific translations available. void _onAppStateChanged() { - final configured = bloc.state == StudyAppState.configured; + final configured = bloc.state == AppState.configured; final deploymentId = bloc.study.study?.studyDeploymentId; if (mounted && - ((configured && _previousBlocState != StudyAppState.configured) || deploymentId != _previousDeploymentId)) { + ((configured && _previousBlocState != AppState.configured) || deploymentId != _previousDeploymentId)) { reloadLocale(); } diff --git a/lib/data/local_settings.dart b/lib/data/local_settings.dart index fe97f94b..120013d7 100644 --- a/lib/data/local_settings.dart +++ b/lib/data/local_settings.dart @@ -56,7 +56,7 @@ class LocalSettings { /// study by using a specific device. /// /// The [participant] is typically set based on an invitation set in the - /// [StudyAppBLoC.setStudyInvitation] method. + /// [AppBloc.setStudyInvitation] method. Participant? get participant { if (_participant == null) { String? userString = Settings().preferences!.getString(participantKey); diff --git a/lib/main.dart b/lib/main.dart index 2d4fc558..335a4d9d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -176,4 +176,4 @@ void main() async { } /// The singleton BLoC. -final bloc = StudyAppBLoC(); +final bloc = AppBloc(); diff --git a/lib/ui/pages/error_page.dart b/lib/ui/pages/error_page.dart index cbe99566..76dde662 100644 --- a/lib/ui/pages/error_page.dart +++ b/lib/ui/pages/error_page.dart @@ -13,7 +13,7 @@ class ErrorPage extends StatelessWidget { children: [ const Text("Error", style: TextStyle(fontSize: 18.0)), const SizedBox(height: 16.0), - ElevatedButton(onPressed: () => context.go(CarpStudyAppState.homeRoute), child: const Text('Go back')), + ElevatedButton(onPressed: () => context.go(CarpAppState.homeRoute), child: const Text('Go back')), ], ), ), diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index 84caa65a..31f21a0d 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -4,7 +4,7 @@ part of carp_study_app; /// /// Shown once the onboarding process is done. All setup orchestration /// (consent gating, study configuration, starting sensing) is owned by the -/// [StudyAppBLoC] and the router redirect - not this page. +/// [AppBloc] and the router redirect - not this page. class HomePage extends StatefulWidget { final HomePageViewModel model; final Widget child; @@ -109,7 +109,7 @@ class HomePageState extends State { context.go(DeviceListPage.route); break; case -1: - context.go(CarpStudyAppState.homeRoute); + context.go(CarpAppState.homeRoute); break; } } diff --git a/lib/ui/pages/informed_consent_page.dart b/lib/ui/pages/informed_consent_page.dart index d839ee02..d9afa95c 100644 --- a/lib/ui/pages/informed_consent_page.dart +++ b/lib/ui/pages/informed_consent_page.dart @@ -23,7 +23,7 @@ class InformedConsentState extends State { _submitted = true; await widget.model.informedConsentHasBeenAccepted(result); if (!mounted) return; - context.go(CarpStudyAppState.homeRoute); + context.go(CarpAppState.homeRoute); } @override @@ -49,7 +49,7 @@ class InformedConsentState extends State { if (document == null && !_submitted) { _submitted = true; await widget.model.acceptWithoutDocument(); - if (mounted) context.go(CarpStudyAppState.homeRoute); + if (mounted) context.go(CarpAppState.homeRoute); } return document; }), diff --git a/lib/ui/pages/message_details_page.dart b/lib/ui/pages/message_details_page.dart index a0444e3c..20893829 100644 --- a/lib/ui/pages/message_details_page.dart +++ b/lib/ui/pages/message_details_page.dart @@ -31,7 +31,7 @@ class MessageDetailsPage extends StatelessWidget { if (context.canPop()) { context.pop(); } else { - context.go(CarpStudyAppState.homeRoute); + context.go(CarpAppState.homeRoute); } }, ), diff --git a/lib/ui/pages/profile_page.dart b/lib/ui/pages/profile_page.dart index e7f31526..67cdb267 100644 --- a/lib/ui/pages/profile_page.dart +++ b/lib/ui/pages/profile_page.dart @@ -253,7 +253,7 @@ class ProfilePageState extends State { if (builderContext.mounted) { await widget.model.signOutAndLeaveStudy(); builderContext.pop(); - builderContext.go(CarpStudyAppState.homeRoute); + builderContext.go(CarpAppState.homeRoute); } }, ), diff --git a/lib/ui/pages/study_details_page.dart b/lib/ui/pages/study_details_page.dart index 978cee7a..a8c8453c 100644 --- a/lib/ui/pages/study_details_page.dart +++ b/lib/ui/pages/study_details_page.dart @@ -29,7 +29,7 @@ class StudyDetailsPage extends StatelessWidget { if (context.canPop()) { context.pop(); } else { - context.go(CarpStudyAppState.homeRoute); + context.go(CarpAppState.homeRoute); } }, ), diff --git a/lib/ui/pages/study_page.dart b/lib/ui/pages/study_page.dart index 072a8bce..55bf68aa 100644 --- a/lib/ui/pages/study_page.dart +++ b/lib/ui/pages/study_page.dart @@ -459,7 +459,7 @@ extension CopyWithAdditional on DateTime { } } -/// Placeholder shown while [StudyAppBLoC.configureStudy] is running. +/// Placeholder shown while [AppBloc.configureStudy] is running. class _ConfiguringStudyLoader extends StatelessWidget { const _ConfiguringStudyLoader(); diff --git a/lib/ui/tasks/audio_task_page.dart b/lib/ui/tasks/audio_task_page.dart index 2fdeb482..ae27aabf 100644 --- a/lib/ui/tasks/audio_task_page.dart +++ b/lib/ui/tasks/audio_task_page.dart @@ -110,7 +110,7 @@ class AudioTaskPageState extends State { TextButton( child: Text(locale.translate("YES")), onPressed: () { - context.pushReplacement(CarpStudyAppState.homeRoute); + context.pushReplacement(CarpAppState.homeRoute); }, ), ], diff --git a/lib/view_models/view_model.dart b/lib/view_models/view_model.dart index 9820cdb2..716e0515 100644 --- a/lib/view_models/view_model.dart +++ b/lib/view_models/view_model.dart @@ -186,7 +186,7 @@ class HourlyMeasure { } /// The view model for the entire app. -class CarpStudyAppViewModel extends ViewModel { +class AppViewModel extends ViewModel { final HomePageViewModel _homePageViewModel = HomePageViewModel(); final LoginViewModel _loginViewModel = LoginViewModel(); final DataVisualizationPageViewModel _dataVisualizationPageViewModel = DataVisualizationPageViewModel(); @@ -198,7 +198,7 @@ class CarpStudyAppViewModel extends ViewModel { final InformedConsentViewModel _informedConsentViewModel = InformedConsentViewModel(); final ParticipantDataPageViewModel _participantDataPageViewModel = ParticipantDataPageViewModel(); - CarpStudyAppViewModel() : super(); + AppViewModel() : super(); HomePageViewModel get homePageViewModel => _homePageViewModel; LoginViewModel get loginViewModel => _loginViewModel; diff --git a/test/app_bloc_test.dart b/test/app_bloc_test.dart index f236ca8a..87da0f97 100644 --- a/test/app_bloc_test.dart +++ b/test/app_bloc_test.dart @@ -13,25 +13,25 @@ void main() { AppConfig.deploymentMode = DeploymentMode.local; }); - group('StudyAppBLoC.configureStudy', () { + group('AppBloc.configureStudy', () { test('is atomic and retryable - a failed configuration resets the state', () async { - final bloc = StudyAppBLoC(); + final bloc = AppBloc(); LocalSettings().study = SmartphoneStudy(studyDeploymentId: 'dep-bloc-1', deviceRoleName: 'phone'); // Sensing cannot be set up in a unit test environment, so the // configuration fails - the state must roll back instead of being // stuck in `configuring` (which would block any retry forever). await expectLater(bloc.configureStudy(), throwsA(anything)); - expect(bloc.state, StudyAppState.created); + expect(bloc.state, AppState.created); expect(bloc.isConfiguring, isFalse); // A retry must run (and fail) again - not silently return. await expectLater(bloc.configureStudy(), throwsA(anything)); - expect(bloc.state, StudyAppState.created); + expect(bloc.state, AppState.created); }); test('throws when no study has been set', () async { - final bloc = StudyAppBLoC(); + final bloc = AppBloc(); await LocalSettings().eraseStudyDeployment(); await expectLater(bloc.configureStudy(), throwsStateError); From f46cdc839a2808f2f2298a5130f05e074ff69892 Mon Sep 17 00:00:00 2001 From: Zeroupper Date: Wed, 8 Jul 2026 14:30:51 +0200 Subject: [PATCH 80/94] fix(android): minimize Health Connect read permissions Remove the Health Connect READ_* permissions flagged by Google Play as excessive, keeping only steps, heart rate and exercise. Data types are driven by the server-side study protocol, so no source changes needed. Refs #392 --- android/app/src/main/AndroidManifest.xml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0356ad36..69e77363 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -69,29 +69,9 @@ - - - - - - - - - - - - - - - - - - - - - @@ -68,9 +66,37 @@ + + + + + + + + + + + + - + + + + + + + + + + + + + + + + +