Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,60 @@
## 2.5.0

* Localization now supports nested translation keys (flattened to dot-separated
paths), `{{placeholder}}` interpolation via the new `args` argument of
`translate()`, and plurals via its new `count` argument, using the `_zero`,
`_one`, `_two`, `_few`, `_many` and `_other` key suffixes with the CLDR rules
of the locale. `translate('key')` behaves exactly as before.
* `LocalizationLoader.load` now returns `Future<Map<String, dynamic>>` so a
loader may return nested translations. Loaders returning
`Future<Map<String, String>>` need no change.
* Added support for requesting OS permissions in context during informed consent
(issue #171). A consent section declares what its text explains the need for
via `RPConsentSection.permissions`, and leaving that section prompts for them —
but only when the step opts in with `RPVisualConsentStep.askPermission: true`.
New: `RPPermissionType`, `RPPermissionStatus`, `RPPermissionResult` (added to
the task result under the identifier of the step) and `RPPermissions.request()`
for asking outside a consent flow. A denied permission is recorded but never
blocks the participant.
* Health data is asked for through the `health` package rather than
`permission_handler`, since HealthKit and Health Connect authorise each data
type separately: a section listing `RPPermissionType.health` must also list the
types in the new `RPConsentSection.healthDataTypes`, or it resolves to
`unsupported`. `HealthDataType` is re-exported and
`RPPermissions.requestHealthData()` is public. On iOS the status is optimistic —
HealthKit does not disclose read access, so `granted` means the sheet was shown
without error.
* Added a "BACK" button to `RPUIVisualConsentStep`, hidden on the first section
and on one which is still going to open a permission alert.
* Added `RPUITask.carouselBarBuilder` and `RPUITask.bottomNavigationBuilder` for
replacing the carousel bar and the BACK/NEXT row with custom widgets; return
`const SizedBox.shrink()` to hide either entirely. The bottom builder is handed
an `RPTaskNavigation` — `onNext` (null while the step is not ready), `onBack`
(null on the first step, and offered in linear tasks, where the default row has
no BACK button), `onCancel` (confirms first), plus `canProceed`, `currentStep`,
`stepIndex` and `stepCount` — and is called on every step, including the ones
the default row hides itself on.
* Added `RPUITask.nextButtonText`, `RPStep.nextButtonText` (which wins over it)
and `RPUITask.nextButtonStyle` for labelling and styling the Next button.
* The bottom navigation row now centres the Next button when there is no Back
button, instead of pushing it to the trailing edge.
* **Breaking:** an informed consent flow — an `RPOrderedTask` containing an
`RPConsentReviewStep` — no longer shows the close button in the top bar, nor a
"CANCEL" button on the visual consent step. Apple requires that a screen
explaining an upcoming permission request offers no way out other than the
system alert it leads to. Such a task is now left with "DISAGREE" on the review
step, which — unlike the old "CANCEL" — does call `RPUITask.onCancel`. Tasks
which are not consent tasks keep their close button.
* **Breaking:** `RPUIVisualConsentStep` now takes the `RPVisualConsentStep` as
`step:` instead of `consentDocument:`. This only affects code instantiating the
widget directly.
* New dependencies: `health: '>=13.0.0 <14.0.0'`, which **raises the Android
requirement to `minSdkVersion 26` for every app using research_package**;
`permission_handler: '>=12.0.0 <13.0.0'`; and `intl: '>=0.19.0 <0.21.0'`. Apps
which ask for permissions must declare them natively, and health data also
needs `FlutterFragmentActivity` and the Health Connect manifest entries — see
the platform setup in the README.

## 2.4.1

* Migrated to `carp_themes_package` 0.2.0: widgets now read colors and text
Expand Down
185 changes: 185 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,191 @@ There is a set of tutorials, describing:

The [Research Package Flutter API](https://pub.dev/documentation/research_package/latest/) is available (and maintained) as part of the package release at pub.dev.

## Localization

Translations live in `assets/lang/<languageCode>.json` and are looked up with
`RPLocalizations.of(context)?.translate('key')`. A key which is not translated is returned as-is, so
it is safe to pass text which may be either a key or a literal.

**Nested keys.** Translations may be nested and are addressed with a dot-separated path. These two
files are equivalent, so nested and dot-separated files can be mixed freely and existing flat files
keep working untouched:

```json
{ "pages": { "task_list": { "title": "Tasks", "description": "Your tasks" } } }
{ "pages.task_list.title": "Tasks", "pages.task_list.description": "Your tasks" }
```

```dart
locale.translate('pages.task_list.title'); // 'Tasks'
```

**Interpolation.** `{{placeholder}}` values are filled in from `args`. A placeholder with no
matching argument is left in place, so a forgotten argument is visible rather than silently blank. A
single brace is always literal.

```json
{ "greeting": "Hello {{name}}, you have {{n}} messages" }
```

```dart
locale.translate('greeting', args: {'name': 'Bo', 'n': 3});
// 'Hello Bo, you have 3 messages'
```

**Plurals.** Give a key one variant per plural category using the `_zero`, `_one`, `_two`, `_few`,
`_many` and `_other` suffixes, and pass a `count`. The category is picked using the CLDR rules of the
locale, so a language which needs `_few` and `_many` gets them. `count` is also available to the
translation as `{{count}}` without passing it in `args`.

```json
{
"tasks_zero": "All done",
"tasks_one": "{{count}} task left",
"tasks_other": "{{count}} tasks left"
}
```

```dart
locale.translate('tasks', count: 0); // 'All done'
locale.translate('tasks', count: 1); // '1 task left'
locale.translate('tasks', count: 5); // '5 tasks left'
```

Which categories apply depends on the language — English only ever uses `_one` and `_other`. The one
exception is `_zero`, which is used for a `count` of exactly 0 in any language when present. A
category which is not translated falls back to `_other`, and a key with no plural variants at all
falls back to the key itself.

## OS permissions

A consent section can declare the OS permissions its text explains the need for. When the visual
consent step opts in with `askPermission: true`, tapping "NEXT" on that section triggers the native
permission dialog — so the participant is asked in context, while the explanation is on screen,
which is what both Apple and Google ask for.

```dart
RPConsentSection(
type: RPConsentSectionType.Location,
summary: 'We use your location to study how you move around.',
content: 'The longer explanation shown under "Learn more"...',
permissions: [RPPermissionType.location],
);

RPVisualConsentStep(
identifier: 'visualStep',
consentDocument: consentDocument,
askPermission: true, // off by default - nothing is requested without this
);
```

The outcome of every request is collected in an `RPPermissionResult` added to the `RPTaskResult`
under the identifier of the visual consent step. A denied permission is recorded but never blocks the
participant.

### Navigation in a consent flow

Apple requires that a screen explaining an upcoming permission request carries a single button,
leading to the system alert, and offers no way of leaving without seeing that alert — see
[Human Interface Guidelines: Privacy](https://developer.apple.com/design/human-interface-guidelines/privacy).
The consent UI enforces this, so a flow using `askPermission` passes review as it is:

* An informed consent flow — any `RPOrderedTask` containing an `RPConsentReviewStep` — has **no
close button** in the top bar and **no cancel button** on the consent sections. It is left by
pressing "DISAGREE" on the review step, which calls `RPUITask.onCancel` as a cancellation always
has.
* The visual consent step offers a "BACK" button for re-reading earlier sections. It is hidden on
the first section, and on any section which is still going to open a permission alert.
* Once a section's permissions have been asked for, "BACK" reappears on it — the alert has been
seen, whatever the participant answered, so the screen is an ordinary consent section again.

### Health data

Health data is the one entry in `RPPermissionType` which is not a single OS permission. Apple
HealthKit and Android Health Connect authorise each data type on its own — there are over a hundred
of them — so a section which lists `RPPermissionType.health` must also say *which* types it needs,
in `healthDataTypes`. Without them there is nothing to request and the permission resolves to
`RPPermissionStatus.unsupported`.

```dart
RPConsentSection(
type: RPConsentSectionType.Health,
summary: 'We read your steps and sleep to see how your activity changes.',
permissions: [RPPermissionType.health],
healthDataTypes: [HealthDataType.STEPS, HealthDataType.SLEEP_ASLEEP],
);
```

`HealthDataType` comes from the [health](https://pub.dev/packages/health) package and is re-exported
by `research_package`, so it needs no separate import. Read access is requested for every type
listed; types the participant has already authorised are skipped.

**On iOS the recorded status is optimistic.** HealthKit deliberately does not disclose whether read
access was granted — an app cannot tell "not permitted" from "no data" — so `granted` there means
the authorisation sheet was shown without error, not that the participant agreed. Android Health
Connect reports the real outcome. `RPPermissions.requestHealthData()` is public if an app needs to
ask outside a consent flow.

### Platform setup

Only needed by apps which use `askPermission`.

**Android** — declare each permission in `android/app/src/main/AndroidManifest.xml`. An undeclared
permission is reported as permanently denied without showing a dialog.

```xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
```

**iOS** — add a usage description per permission to `ios/Runner/Info.plist`; iOS terminates the app
if one is missing. For example `NSLocationWhenInUseUsageDescription`,
`NSMicrophoneUsageDescription` and `NSMotionUsageDescription` (which also covers
`RPPermissionType.activityRecognition`, since iOS reads activity through CoreMotion).

If the app integrates plugins with **CocoaPods**, each permission additionally has to be enabled in
`ios/Podfile` — `permission_handler` compiles every permission out of the build unless its macro is
set:

```ruby
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_LOCATION_WHENINUSE=1', # use PERMISSION_LOCATION=1 for locationAlways
'PERMISSION_MICROPHONE=1',
'PERMISSION_SENSORS=1', # activityRecognition and sensors
]
end
end
end
```

With **Swift Package Manager** this step is not needed — the macros are derived from the
`Info.plist` keys above.

#### Health data

The `health` plugin is a dependency of `research_package`, so its platform requirements apply to
**every** app using this package, whether or not it asks for health data:

* **Android** — `minSdkVersion 26`. Apps which ask for health data additionally need, in
`AndroidManifest.xml`, a `<uses-permission android:name="android.permission.health.READ_*"/>` per
data type, a `<package android:name="com.google.android.apps.healthdata"/>` entry under
`<queries>`, an `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` intent filter on the main
activity, and the `ViewPermissionUsageActivity` alias. `MainActivity` must extend
`FlutterFragmentActivity` rather than `FlutterActivity`, or Health Connect cannot show its
permission sheet on Android 14 and later.
* **iOS** — `NSHealthShareUsageDescription` and `NSHealthUpdateUsageDescription` in `Info.plist`,
plus the **HealthKit** capability on the Runner target, added under "Signing & Capabilities" in
Xcode. Without the capability the authorisation sheet never appears.

`example/` is set up this way and can be copied from — see its `AndroidManifest.xml`,
`MainActivity.kt`, `build.gradle.kts` and `Info.plist`.

## Example Application

There is an [example app](https://github.com/cph-cachet/research.package/tree/master/example) which demonstrates the different features of Research Package as implemented in a Flutter app.
Expand Down
4 changes: 3 additions & 1 deletion example/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ android {
applicationId = "dk.carp.research_package_example"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
// 26 rather than flutter.minSdkVersion: the health plugin, which
// research_package uses for RPPermissionType.health, requires it.
minSdk = maxOf(flutter.minSdkVersion, 26)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
Expand Down
33 changes: 33 additions & 0 deletions example/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The permissions declared on the consent sections in
lib/research_package_objects/informed_consent.dart. A permission which is
not declared here is reported as permanently denied without a dialog. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
<!-- Health Connect grants read access per data type, so each type listed in
healthSection.healthDataTypes needs its own permission here. -->
<uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_HEART_RATE"/>
<uses-permission android:name="android.permission.health.READ_SLEEP"/>
<application
android:label="research_package_example"
android:name="${applicationName}"
Expand All @@ -24,7 +36,23 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Where Health Connect sends the participant to read why the app
wants their health data. -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
</intent-filter>
</activity>
<!-- Required by Health Connect on Android 14 and later. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".MainActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
</intent-filter>
</activity-alias>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
Expand All @@ -41,5 +69,10 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- So the app can see whether Health Connect is installed. -->
<package android:name="com.google.android.apps.healthdata"/>
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
</intent>
</queries>
</manifest>
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package dk.carp.research_package_example

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterActivity()
// FlutterFragmentActivity rather than FlutterActivity: Health Connect asks for
// permissions through an AndroidX activity result contract, which needs a
// FragmentActivity host on Android 14 and later.
class MainActivity : FlutterFragmentActivity()
43 changes: 43 additions & 0 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}

def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end

File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
use_frameworks!

flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end

post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
13 changes: 13 additions & 0 deletions example/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<!-- Usage descriptions for the permissions declared on the consent sections
in lib/research_package_objects/informed_consent.dart. iOS terminates the
app if a permission is requested without one. -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to study how you move around during the study.</string>
<key>NSMicrophoneUsageDescription</key>
<string>The microphone is used to measure the background noise around you.</string>
<key>NSMotionUsageDescription</key>
<string>Motion data is used to recognize your physical activity during the study.</string>
<key>NSHealthShareUsageDescription</key>
<string>Your health data is read to study how your activity and sleep change during the study.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>The study does not write to Apple Health, but HealthKit requires this description.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
Loading