diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml deleted file mode 100644 index 169e2debe..000000000 --- a/.github/workflows/build-release.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Build Release - -on: - workflow_dispatch: - push: - branches: - - 'release' - -env: - XCODE_VERSION: '14.2.0' - JAVA_VERSION: '19' - - -jobs: - build-shared: - name: Build shared module - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macOS-latest] - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: ${{ env.JAVA_VERSION }} - - - name: Build shared - run: ./gradlew shared:build - - - name: Upload artifacts - uses: actions/upload-artifact@v3 - with: - name: shared-module - path: | - shared/build - - build-android: - name: Build Android App - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ ubuntu-latest, macOS-latest ] - needs: build-shared - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: ${{ env.JAVA_VERSION }} - - - name: Download artifacts - uses: actions/download-artifact@v3 - with: - name: shared-module - - - name: Build Android - run: ./gradlew androidApp:build androidApp:bundleDebug androidApp:bundleRelease androidApp:assembleRelease - - - name: Collect Artifacts - run: | - mkdir -p artifacts - cp androidApp/build/outputs/apk/debug/androidApp-debug.apk artifacts/ - cp androidApp/build/outputs/apk/release/androidApp-release-unsigned.apk artifacts/ - cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab artifacts/ - cp androidApp/build/outputs/bundle/release/androidApp-release.aab artifacts/ - - - name: Upload Release Bundle - uses: actions/upload-artifact@v3 - with: - name: android-release-bundle - path: artifacts/* - - build-ios: - name: Build Ios App - runs-on: macOS-latest - if: false - needs: build-shared - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup MacOS - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ env.XCODE_VERSION }} - - - diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..c233cca33 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,75 @@ +name: Build apps +on: + pull_request: + branches: + - main + - develop + - prehab2rehab_develop + +jobs: + ios: + name: iOS + runs-on: macos-26 + environment: AppStore + env: + FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} + APPLE_CONNECT_KEY_ID: ${{ secrets.APPLE_CONNECT_KEY_ID }} + APPLE_CONNECT_ISSUER_ID: ${{ secrets.APPLE_CONNECT_ISSUER_ID }} + APPLE_CONNECT_KEY_CONTENT: ${{ secrets.APPLE_CONNECT_KEY_CONTENT }} + FASTLANE_KEYCHAIN_PASSWORD: ${{ secrets.FASTLANE_KEYCHAIN_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + FASTLANE_MATCH_SECRET: ${{ secrets.FASTLANE_MATCH_SECRET }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_AUTH }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + + FASTLANE_XCODEBUILD_SETTINGS_RETRIES: ${{ vars.FASTLANE_XCODEBUILD_SETTINGS_RETRIES }} + APP_IDENTIFIERS: ${{ vars.APP_IDENTIFIERS }} + FASTLANE_IOS_SCHEME: ${{ vars.FASTLANE_IOS_BUILD_SCHEME }} + FASTLANE_BUILD_NUMBER: ${{ vars.FASTLANE_BUILD_NUMBER }} + APPLE_CONNECT_KEY_IS_BASE64: ${{ vars.APPLE_CONNECT_KEY_IS_BASE64 }} + CODE_SIGN_IDENTITY: ${{ vars.CODE_SIGN_IDENTITY }} + TARGETS: ${{ vars.TARGETS }} + XCODE_VERSION: ${{ vars.XCODE_VERSION || '26.2' }} + defaults: + run: + working-directory: iosApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup Google Services + run: chmod +x ../setup_google_services.sh && ../setup_google_services.sh + + - name: Set Xcode Version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Build iOS app + run: fastlane build + + android: + name: Android + runs-on: ubuntu-latest + environment: PlayStore + env: + GOOGLE_PLAY_KEY_FILE: ${{ secrets.GOOGLE_PLAY_KEY_FILE }} + GOOGLE_PLAY_KEY_IN_BASE64: ${{ secrets.GOOGLE_PLAY_KEY_IN_BASE64 }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + FASTLANE_BUILD_NUMBER: ${{ vars.FASTLANE_BUILD_NUMBER }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + defaults: + run: + working-directory: androidApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup Google Services + run: chmod +x ../setup_google_services.sh && ../setup_google_services.sh + + - name: Build Android app + run: fastlane build \ No newline at end of file diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml new file mode 100644 index 000000000..02d3fe4fe --- /dev/null +++ b/.github/workflows/deploy-beta.yml @@ -0,0 +1,86 @@ +name: Deploy beta release +on: + push: + branches: + - develop + - main + - prehab2rehab_develop + tags: + - '[0-9]+.[0-9]+.[0-9]+' + +jobs: + ios: + name: iOS + runs-on: macos-26 + environment: AppStore + if: startsWith(github.ref, 'refs/tags/') + env: + FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} + APPLE_CONNECT_KEY_ID: ${{ secrets.APPLE_CONNECT_KEY_ID }} + APPLE_CONNECT_ISSUER_ID: ${{ secrets.APPLE_CONNECT_ISSUER_ID }} + APPLE_CONNECT_KEY_CONTENT: ${{ secrets.APPLE_CONNECT_KEY_CONTENT }} + FASTLANE_KEYCHAIN_PASSWORD: ${{ secrets.FASTLANE_KEYCHAIN_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + FASTLANE_MATCH_SECRET: ${{ secrets.FASTLANE_MATCH_SECRET }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_AUTH }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + + FASTLANE_XCODEBUILD_SETTINGS_RETRIES: ${{ vars.FASTLANE_XCODEBUILD_SETTINGS_RETRIES }} + APP_IDENTIFIERS: ${{ vars.APP_IDENTIFIERS }} + FASTLANE_IOS_SCHEME: ${{ vars.FASTLANE_IOS_BUILD_SCHEME }} + FASTLANE_BUILD_NUMBER: ${{ vars.FASTLANE_BUILD_NUMBER }} + APPLE_CONNECT_KEY_IS_BASE64: ${{ vars.APPLE_CONNECT_KEY_IS_BASE64 }} + CODE_SIGN_IDENTITY: ${{ vars.CODE_SIGN_IDENTITY }} + TARGETS: ${{ vars.TARGETS }} + XCODE_VERSION: ${{ vars.XCODE_VERSION || '26.2' }} + defaults: + run: + working-directory: iosApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup Google Services + run: chmod +x ../setup_google_services.sh && ../setup_google_services.sh + + - name: Set Xcode Version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Set FASTLANE_BUILD_NUMBER from tag + run: echo "FASTLANE_BUILD_NUMBER=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Deploy to TestFlight + run: fastlane deploy_beta + + + android: + name: Android + runs-on: ubuntu-latest + environment: PlayStore + if: startsWith(github.ref, 'refs/tags/') + env: + GOOGLE_PLAY_KEY_FILE: ${{ secrets.GOOGLE_PLAY_KEY_FILE }} + GOOGLE_PLAY_KEY_IN_BASE64: ${{ secrets.GOOGLE_PLAY_KEY_IN_BASE64 }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + FASTLANE_BUILD_NUMBER: ${{ vars.FASTLANE_BUILD_NUMBER }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + defaults: + run: + working-directory: androidApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup Google Services + run: chmod +x ../setup_google_services.sh && ../setup_google_services.sh + + - name: Set FASTLANE_BUILD_NUMBER from tag + run: echo "FASTLANE_BUILD_NUMBER=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Deploy to Google Play Beta + run: fastlane deploy_beta \ No newline at end of file diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml deleted file mode 100644 index 29e68f436..000000000 --- a/.github/workflows/deploy-release.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy Release - -on: - workflow_dispatch: - push: - branches: - - release - tags: - - 'v*' -# workflow_run: -# workflows: ["Build Release"] -# types: -# - completed -#permissions: {} - -env: - XCODE_VERSION: '14.2.0' - JAVA_VERSION: '17' - -jobs: - deploy-android: - name: Deploy Android App Release to Google Play - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: ${{ env.JAVA_VERSION }} - - - name: Deploy to Store - uses: r0adkll/upload-google-play@v1 - with: -# The contents of your service-account.json - serviceAccountJsonPlainText: ${{ SERVICE_ACCOUNT_JSON }} - packageName: com.redlink.MORE.AndroidApp - releaseFiles: androidApp/build/outputs/bundle/release/androidApp-release.aab - track: production - status: completed - inAppUpdatePriority: 5 - userFraction: 1.0 -# whatsNewDirectory: distribution/whatsnew -# mappingFile: app/build/outputs/mapping/release/mapping.txt -# debugSymbols: app/intermediates/merged_native_libs/release/out/lib diff --git a/.gitignore b/.gitignore index 0791ade10..f95c9d147 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,23 @@ captures .cxx local.properties xcuserdata -shared/src/commonMain/kotlin/generated +/shared/src/commonMain/kotlin/generated /androidApp/release/ +*.ipa +.env +*.mobileprovision +*.cer* +*.p12 +*.zip +*.p8 +.kotlin +*.db +*.apk +*.aab +/androidApp/debug +/shared/schemas +/shared/schemas/ + +google-services.json +GoogleService-Info.plist +report.xml \ No newline at end of file diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleMain.cinteropLibraries b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleMain.cinteropLibraries deleted file mode 100644 index 060dde8b7..000000000 --- a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleMain.cinteropLibraries +++ /dev/null @@ -1,3 +0,0 @@ -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib \ No newline at end of file diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleTest.cinteropLibraries b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleTest.cinteropLibraries deleted file mode 100644 index 060dde8b7..000000000 --- a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleTest.cinteropLibraries +++ /dev/null @@ -1,3 +0,0 @@ -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib \ No newline at end of file diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosMain.cinteropLibraries b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosMain.cinteropLibraries deleted file mode 100644 index 060dde8b7..000000000 --- a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosMain.cinteropLibraries +++ /dev/null @@ -1,3 +0,0 @@ -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib \ No newline at end of file diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosTest.cinteropLibraries b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosTest.cinteropLibraries deleted file mode 100644 index 060dde8b7..000000000 --- a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosTest.cinteropLibraries +++ /dev/null @@ -1,3 +0,0 @@ -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib \ No newline at end of file diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeMain.cinteropLibraries b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeMain.cinteropLibraries deleted file mode 100644 index 060dde8b7..000000000 --- a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeMain.cinteropLibraries +++ /dev/null @@ -1,3 +0,0 @@ -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib \ No newline at end of file diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeTest.cinteropLibraries b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeTest.cinteropLibraries deleted file mode 100644 index 060dde8b7..000000000 --- a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeTest.cinteropLibraries +++ /dev/null @@ -1,3 +0,0 @@ -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib -/Users/jancortiel/Documents/More/LBI/more-multiplatform-app/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib \ No newline at end of file diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib deleted file mode 100644 index ca651ddbb..000000000 Binary files a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib deleted file mode 100644 index 729c60ea8..000000000 Binary files a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib deleted file mode 100644 index 15d6a2081..000000000 Binary files a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-commonMain-1qj7aA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-commonMain-1qj7aA.klib deleted file mode 100644 index 52e411768..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-commonMain-1qj7aA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-ios-BePiUw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-ios-BePiUw.klib deleted file mode 100644 index 8b847fe82..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-ios-BePiUw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-commonMain-UpJokw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-commonMain-UpJokw.klib deleted file mode 100644 index 5a2f649cf..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-commonMain-UpJokw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-darwinMain-EnitAw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-darwinMain-EnitAw.klib deleted file mode 100644 index 4ba5570d1..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-darwinMain-EnitAw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-auth-2.3.12-commonMain-tgNnVQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-auth-2.3.12-commonMain-tgNnVQ.klib deleted file mode 100644 index 98da75590..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-auth-2.3.12-commonMain-tgNnVQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-commonMain-jI37cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-commonMain-jI37cw.klib deleted file mode 100644 index 745c21951..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-commonMain-jI37cw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-posixMain-jI37cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-posixMain-jI37cw.klib deleted file mode 100644 index 47bfdb02c..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-posixMain-jI37cw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-commonMain-FU-9lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-commonMain-FU-9lg.klib deleted file mode 100644 index d5eb44a3a..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-commonMain-FU-9lg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-posixMain-FU-9lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-posixMain-FU-9lg.klib deleted file mode 100644 index f33f7f854..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-posixMain-FU-9lg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-darwin-2.3.12-darwinMain-CnRCQQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-darwin-2.3.12-darwinMain-CnRCQQ.klib deleted file mode 100644 index 27421f95f..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-darwin-2.3.12-darwinMain-CnRCQQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-commonMain-grxlVw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-commonMain-grxlVw.klib deleted file mode 100644 index 1a0f4bd9e..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-commonMain-grxlVw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-posixMain-grxlVw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-posixMain-grxlVw.klib deleted file mode 100644 index a3e08f24b..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-posixMain-grxlVw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-events-2.3.12-commonMain-Q6-xkw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-events-2.3.12-commonMain-Q6-xkw.klib deleted file mode 100644 index d9a2627ac..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-events-2.3.12-commonMain-Q6-xkw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-commonMain-W5sIeA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-commonMain-W5sIeA.klib deleted file mode 100644 index be50fd4d0..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-commonMain-W5sIeA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-posixMain-W5sIeA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-posixMain-W5sIeA.klib deleted file mode 100644 index 8dcf145e1..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-posixMain-W5sIeA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-commonMain-3YsjwQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-commonMain-3YsjwQ.klib deleted file mode 100644 index 4a89b1452..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-commonMain-3YsjwQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-darwinMain-sbySvA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-darwinMain-sbySvA.klib deleted file mode 100644 index b45daa05e..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-darwinMain-sbySvA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-posixMain-3YsjwQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-posixMain-3YsjwQ.klib deleted file mode 100644 index 635a72f6c..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-posixMain-3YsjwQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-2.3.12-commonMain-NxrIfg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-2.3.12-commonMain-NxrIfg.klib deleted file mode 100644 index 265f7193e..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-2.3.12-commonMain-NxrIfg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-commonMain-s53Slg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-commonMain-s53Slg.klib deleted file mode 100644 index 9e5d8b529..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-commonMain-s53Slg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-posixMain-s53Slg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-posixMain-s53Slg.klib deleted file mode 100644 index 9d87792e2..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-posixMain-s53Slg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-commonMain-sJ8SDA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-commonMain-sJ8SDA.klib deleted file mode 100644 index 45f1885ba..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-commonMain-sJ8SDA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-posixMain-sJ8SDA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-posixMain-sJ8SDA.klib deleted file mode 100644 index a327a0289..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-posixMain-sJ8SDA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-commonMain-kEcFvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-commonMain-kEcFvw.klib deleted file mode 100644 index 8eb66ad6d..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-commonMain-kEcFvw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-darwinMain-TE4abA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-darwinMain-TE4abA.klib deleted file mode 100644 index 6deb9542d..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-darwinMain-TE4abA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-nixMain-kEcFvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-nixMain-kEcFvw.klib deleted file mode 100644 index cd74f993e..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-nixMain-kEcFvw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-posixMain-kEcFvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-posixMain-kEcFvw.klib deleted file mode 100644 index a464aafc4..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-posixMain-kEcFvw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websocket-serialization-2.3.12-commonMain-8xBQEg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websocket-serialization-2.3.12-commonMain-8xBQEg.klib deleted file mode 100644 index 160024bca..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websocket-serialization-2.3.12-commonMain-8xBQEg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-commonMain-8-9-_g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-commonMain-8-9-_g.klib deleted file mode 100644 index 6217010f4..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-commonMain-8-9-_g.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-posixMain-8-9-_g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-posixMain-8-9-_g.klib deleted file mode 100644 index a37994710..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-posixMain-8-9-_g.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-commonMain-zZQVnw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-commonMain-zZQVnw.klib deleted file mode 100644 index dee979333..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-commonMain-zZQVnw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-nt9oMQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-nt9oMQ.klib deleted file mode 100644 index 9e495c504..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-nt9oMQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-commonMain-0LVaVg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-commonMain-0LVaVg.klib deleted file mode 100644 index 05ca0842d..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-commonMain-0LVaVg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeDarwin-RwRULA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeDarwin-RwRULA.klib deleted file mode 100644 index 629031e42..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeDarwin-RwRULA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeIos-RwRULA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeIos-RwRULA.klib deleted file mode 100644 index 0b80091ec..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeIos-RwRULA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.0.0-commonMain-2bbUHA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.0.0-commonMain-2bbUHA.klib deleted file mode 100644 index ed2567422..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.0.0-commonMain-2bbUHA.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-annotationsCommonMain-24eTFQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-annotationsCommonMain-24eTFQ.klib deleted file mode 100644 index 250ee0826..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-annotationsCommonMain-24eTFQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-assertionsCommonMain-24eTFQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-assertionsCommonMain-24eTFQ.klib deleted file mode 100644 index 75ffad472..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-assertionsCommonMain-24eTFQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib deleted file mode 100644 index 7a42bd238..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib deleted file mode 100644 index 3c54c5c32..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-commonMain-XanZ2w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-commonMain-XanZ2w.klib deleted file mode 100644 index 7d489eb0c..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-commonMain-XanZ2w.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-concurrentMain-XanZ2w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-concurrentMain-XanZ2w.klib deleted file mode 100644 index 04231f8d0..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-concurrentMain-XanZ2w.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeDarwinMain-sy5nKg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeDarwinMain-sy5nKg.klib deleted file mode 100644 index 0ea2d1c93..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeDarwinMain-sy5nKg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeMain-XanZ2w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeMain-XanZ2w.klib deleted file mode 100644 index 9273ad959..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeMain-XanZ2w.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-commonMain-k5yUlQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-commonMain-k5yUlQ.klib deleted file mode 100644 index 7fea36f1a..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-commonMain-k5yUlQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-darwinMain-bPkWaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-darwinMain-bPkWaQ.klib deleted file mode 100644 index 7073571cf..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-darwinMain-bPkWaQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-nativeMain-k5yUlQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-nativeMain-k5yUlQ.klib deleted file mode 100644 index 36477fc6b..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-nativeMain-k5yUlQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-commonMain-8gwKMQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-commonMain-8gwKMQ.klib deleted file mode 100644 index ea79905dd..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-commonMain-8gwKMQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-nativeMain-8gwKMQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-nativeMain-8gwKMQ.klib deleted file mode 100644 index 514eaa94b..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-nativeMain-8gwKMQ.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-commonMain-Ii3AMw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-commonMain-Ii3AMw.klib deleted file mode 100644 index 0ad0396ec..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-commonMain-Ii3AMw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-nativeMain-Ii3AMw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-nativeMain-Ii3AMw.klib deleted file mode 100644 index 6b5b966cd..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-nativeMain-Ii3AMw.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-commonMain-eKbbTg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-commonMain-eKbbTg.klib deleted file mode 100644 index 36a82e5f7..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-commonMain-eKbbTg.klib and /dev/null differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-iosMain-l8A9Sw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-iosMain-l8A9Sw.klib deleted file mode 100644 index 840465871..000000000 Binary files a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-iosMain-l8A9Sw.klib and /dev/null differ diff --git a/LICENSE.txt b/LICENSE.txt index 0f6b055e5..60a52b6f5 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -19,7 +19,7 @@ whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. -Software: MORE Smartphone Companion Applications for Android and iOS +Software: MORE Smartphone Applications for Android and iOS (currently at https://github.com/MORE-Platform/more-app-multiplatform/). See more-health.at for further information. diff --git a/README.md b/README.md index 71b65a3e9..111b79925 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # More App Multiplatform -This document provides detailed description of how to install, prepare and contribute to the App development as part of the "MORE"-Project. +This document provides detailed description of how to install, prepare and contribute to the App +development as part of the "MORE"-Project. + ## Getting Started This is an example of how you can set up the project locally. @@ -11,123 +13,399 @@ To get a local copy up and running follow these steps. ### Prerequisites -The following prerequisites list contains all the needed software in order to be able to build the app locally. +The following prerequisites list contains all the needed software in order to be able to build the +app locally. **Disclaimer**: To write iOS-specific code and run an iOS application on a simulated or real device, you'll need a Mac with macOS. -This cannot be performed on other operating systems, such as Microsoft Windows. This is an Apple requirement. +This cannot be performed on other operating systems, such as Microsoft Windows. This is an Apple +requirement. -It's recommended that you install the latest stable versions for compatibility and better performance. In order to build the iOS application the version of **iOS** should be at least 14. +It's recommended that you install the latest stable versions for compatibility and better +performance. In order to build the iOS application the version of **iOS** should be at least 14. * [Android Studio](https://developer.android.com/studio) -* [XCode](https://apps.apple.com/us/app/xcode) (Must be of version 14.0 or higher) +* [XCode](https://apps.apple.com/us/app/xcode) (Must be of version 26.0 or higher) * [Command Line Tools](https://developer.apple.com/downloads/) -* [JDK 19](https://www.oracle.com/java/technologies/downloads/) -* [Gradle 8.2+](https://gradle.org) - -Also, it's recommended to install the following plugins in the Android Studio directly: +* [JDK 21](https://www.oracle.com/java/technologies/downloads/) +* [Gradle 8.14.3](https://gradle.org) -[Kotlin Multiplatform Mobile](https://kotlinlang.org/docs/multiplatform-mobile-plugin-releases.html) - In Android Studio, select **Settings/Preferences | Plugins**, search **Marketplace** for Kotlin Multiplatform Mobile, and then install it. +It's recommended to install Xcode via `xcodes` and `aria2` as this is faster and more flexible in +downloading specific versions of Xcode, including beta releases. +To download the latest Xcode version simply enter `xcodes install --latest --experimental-unxip` or +`xcodes install --latest-prerelease --experimental-unxip` for a beta version. +Both `xcodes` and `aria2` are available via Homebrew `brew install xcodes aria2`. -[Kotlin plugin](https://kotlinlang.org/docs/releases.html#update-to-a-new-release) - The Kotlin plugin is bundled with each Android Studio release. However, it still needs to be updated to the latest version to avoid compatibility issues. +Also, it's recommended to install the following plugins in the Android Studio directly: -To update the plugin, on the Android Studio welcome screen, select **Plugins | Installed**. Click **Update** next to Kotlin. You can also check the Kotlin version in **Tools | Kotlin | Configure Kotlin Plugin Updates**. -The Kotlin plugin should be compatible with the Kotlin Multiplatform Mobile plugin. Refer to the [compatibility table](https://kotlinlang.org/docs/multiplatform-mobile-plugin-releases.html#release-details). +[Kotlin Multiplatform Mobile](https://kotlinlang.org/docs/multiplatform-mobile-plugin-releases.html) - +In Android Studio, select **Settings/Preferences | Plugins**, search **Marketplace** for Kotlin +Multiplatform Mobile, and then install it. +[Kotlin plugin](https://kotlinlang.org/docs/releases.html#update-to-a-new-release) - The Kotlin +plugin is bundled with each Android Studio release. However, it still needs to be updated to the +latest version to avoid compatibility issues. +To update the plugin, on the Android Studio welcome screen, select **Plugins | Installed**. Click * +*Update** next to Kotlin. You can also check the Kotlin version in **Tools | Kotlin | Configure +Kotlin Plugin Updates**. +The Kotlin plugin should be compatible with the Kotlin Multiplatform Mobile plugin. Refer to +the [compatibility table](https://kotlinlang.org/docs/multiplatform-mobile-plugin-releases.html#release-details). ### Installation The following is an instruction on how to install and configure the project on your local device. - -1. Clone the repo: - ```sh - git clone https://github.com/MORE-Platform/more-app-multiplatform.git - ``` +1. Clone the repo: + ```sh + git clone https://github.com/MORE-Platform/more-app-multiplatform.git + ``` 2. Open the project in Android Studio. -3. Make sure to sync project with the Gradle Files. Click **File | Sync Project with Gradle Files** and wait until it's done. -4. This project requires **JDK 11** or later. To build the project you need to set your **runtime to JDK 11 or later**, and then under **Project Structure** --> **Modules** set in either **androidApp** and **shared** the **Source Compatibility** and the **Target Compatibility** to at least **$JavaVersion.VERSION_11**. -5. After being upgraded to JDK 11 or a later version, the Settings dialog in Android Studio can be accessed by pressing cmd + , on Mac or Ctrl + Alt + S on Windows/Linux. Then, navigate to "Build, Execution, Deployment > Build Tools > Gradle". The JDK location can be set within that section. -6. Now we can build the project. Go to the terminal and perform the following command in the root folder of the project: +3. To successfully build the iOS and Android apps, you need to integrate the Google API Key files + for each platform. This can be done manually or via a script: + - **Automated setup**: If you have your `GOOGLE_API_KEY` (base64 encoded) as an environment + variable, you can run the following script from the root directory: + ```sh + chmod +x setup_google_services.sh + ./setup_google_services.sh + ``` + - **Manual setup**: + - For Android, place the `google-services.json` file in the `androidApp` directory. + - For iOS, place the `GoogleService-Info.plist` file in the `iosApp/iosApp` directory. + Should + there be any trouble building the iosApp, make sure, that Xcode shows the file in the + project + navigator. If it does not, you need to right click on the project source > "Add Files" > + Search the `GoogleService-Info.plist` file > "Add". +4. Make sure to sync project with the Gradle Files. Click **File | Sync Project with Gradle Files** + and wait until it's done. +5. This project requires **JDK 11** or later. To build the project you need to set your **runtime to + JDK 11 or later**, and then under **Project Structure** --> **Modules** set in either * + *androidApp** and **shared** the **Source Compatibility** and the **Target Compatibility** to at + least **$JavaVersion.VERSION_11**. +6. After being upgraded to JDK 11 or a later version, the Settings dialog in Android Studio can be + accessed by pressing cmd + , on Mac or Ctrl + Alt + S on Windows/Linux. Then, navigate to "Build, + Execution, Deployment > Build Tools > Gradle". The JDK location can be set within that section. +7. **Before** building the project you need to generate the openapi api clients. To do that just run + `./gradlew :shared:generateOpenApiClasses` from the project root. This has to be done, every time + the OpenAPI spec changed, or the build directory was removed. +8. Now we can build the project. Go to the terminal and perform the following command in the root + folder of the project: ```sh ./gradlew build ``` -7. Now you should be good to go. You can create an emulator device and start your application. + +8.Now you should be good to go. You can create an emulator device and start your application. ### Troubleshooting with KDoctor To make sure everything works as expected, install and run the KDoctor tool: -1. In the Android Studio terminal or your command-line tool, run the following command to install the tool using Homebrew: + +1. In the Android Studio terminal or your command-line tool, run the following command to install + the tool using Homebrew: ```sh brew install kdoctor ``` - If you don't have Homebrew yet, [install it](https://brew.sh/) or see the KDoctor [README](https://github.com/Kotlin/kdoctor#installation) for other ways to install it. + If you don't have Homebrew yet, [install it](https://brew.sh/) or see the + KDoctor [README](https://github.com/Kotlin/kdoctor#installation) for other ways to install it. 2. After the installation is completed, call KDoctor in the console: ```sh kdoctor ``` -3. If KDoctor diagnoses any problems while checking your environment, review the output for issues and possible solutions: -* Fix any failed checks `([x])`. You can find problem descriptions and potential solutions after the `*` symbol. -* Check the warnings `([!])` and successful messages `([v])`. They may contain useful notes and tips, as well. +3. If KDoctor diagnoses any problems while checking your environment, review the output for issues + and possible solutions: -_You may ignore KDoctor's warnings regarding the CocoaPods installation. In this project, we use Swift Package Manager and not CocoaPods._ +* Fix any failed checks `([x])`. You can find problem descriptions and potential solutions after the + `*` symbol. +* Check the warnings `([!])` and successful messages `([v])`. They may contain useful notes and + tips, as well. + +_You may ignore KDoctor's warnings regarding the CocoaPods installation. In this project, we use +Swift Package Manager and not CocoaPods._ + +## CI/CD Pipeline + +This project uses GitHub Actions for continuous integration and continuous deployment. The pipeline +is split into two workflows: + +### Build Workflow + +The build workflow (`build.yml`) runs on every push to any branch and on pull requests. It only +builds the apps without deploying them. + +**Trigger:** + +- Push to any branch (excluding tags) +- Pull requests + +**Jobs:** + +- iOS: Builds the iOS app using fastlane +- Android: Builds the Android app using fastlane + +### Deploy Beta Workflow + +The deploy beta workflow (`deploy-beta.yml`) runs only when a tag with the format `x.x.x` (semantic +versioning) is pushed. It builds the apps and deploys them to TestFlight (iOS) and Google Play +Beta (Android). + +**Trigger:** + +- Push of a tag matching the pattern `[0-9]+.[0-9]+.[0-9]+` (e.g., `1.2.3`) + +The tag pattern (e.g., `1.2.3`) directly represents the new app deployment version. The workflow +extracts this version from the tag and sets it as the `FASTLANE_BUILD_NUMBER` environment variable, +which is then used by fastlane to set the build number and version in both iOS and Android apps. + +**Jobs:** + +- iOS: Builds the iOS app and deploys it to TestFlight +- Android: Builds the Android app and deploys it to Google Play Beta + +### Fastlane Integration + +This project uses fastlane for automating the build and deployment processes for both iOS and +Android apps. + +#### iOS Fastlane + +The iOS fastlane configuration includes the following lanes: + +- `setup_google_services`: Automatically creates `GoogleService-Info.plist` if `GOOGLE_API_KEY` is + set. +- `increment_build`: Bumps build number and version to `FASTLANE_BUILD_NUMBER` +- `build`: Builds the app for App Store, including code signing setup +- `deploy_beta`: Deploys a new beta to TestFlight (calls `increment_build` and `build`, then uploads + to TestFlight) + +The iOS build process uses Xcode 16.4 and creates a temporary keychain for secure code signing. It +supports multiple app targets, including notification service extensions. + +#### Android Fastlane + +The Android fastlane configuration includes the following lanes: + +- `setup_google_services`: Automatically creates `google-services.json` if `GOOGLE_API_KEY` is set. +- `test`: Runs all tests +- `build`: Builds the Android app (debug version) +- `deploy_beta`: Builds a release version and deploys it to Google Play Beta + +The Android build process has several important features: + +- **Version Code Calculation**: For release builds, the version code is calculated using a formula + that converts semantic versioning (e.g., 4.0.22) to a 5-digit code: + `major × 10⁴ + minor × 10² + patch`. For example, version 4.0.22 becomes 40022. The system also + checks the latest version code from Google Play and increments it by 1, using the maximum of these + two values to ensure the version code is always increasing. + +- **AAB Format**: The Android app is built as an Android App Bundle (AAB) for release, not an APK. + +- **Firebase Integration**: When not running in CI mode, the build process supports optional + Firebase App Distribution for testing. + +### Environment Variables + +To run the pipeline, you need to set up the following environment variables: + +#### iOS Environment Variables + +**Secrets:** + +- `FASTLANE_TEAM_ID`: Your Apple Developer Team ID +- `APPLE_CONNECT_KEY_ID`: App Store Connect API Key ID +- `APPLE_CONNECT_ISSUER_ID`: App Store Connect API Issuer ID +- `APPLE_CONNECT_KEY_CONTENT`: App Store Connect API Key content (base64 encoded) +- `FASTLANE_MATCH_SECRET`: Password for match repository +- `MATCH_AUTH`: Basic authorization for match Git repository +- `GOOGLE_API_KEY`: Google API Key (GoogleSerivce-Info.plist) for Firebase integration (base64 + encoded) + +**Variables:** + +- `APP_IDENTIFIERS`: Comma-separated list of app bundle identifiers (e.g., " + io.redlink.umm.blendedcare.io.redlink.umm.blendedcare.More-Notification-Service-Extension") +- `TARGETS`: Comma-separated list of Xcode targets corresponding to the app identifiers (e.g., " + BlendedCare,BlendedCare-Notification-Service-Extension") +- `FASTLANE_IOS_BUILD_SCHEME`: Xcode scheme to build +- `FASTLANE_BUILD_NUMBER`: Build number (set automatically from tag in deploy workflow) +- `APPLE_CONNECT_KEY_IS_BASE64`: Whether the APPLE_CONNECT_KEY_CONTENT is base64 encoded ( + true/false) +- `CODE_SIGN_IDENTITY`: The code signing identity to use (e.g., "iPhone Distribution") + +#### Android Environment Variables + +**Secrets:** + +- `GOOGLE_PLAY_KEY_FILE`: Path to Google Play key file +- `GOOGLE_PLAY_KEY_IN_BASE64`: Google Play key file content (base64 encoded) +- `ANDROID_KEYSTORE_BASE64`: Android keystore file (base64 encoded) +- `ANDROID_KEYSTORE_PASSWORD`: Password for the Android keystore +- `ANDROID_KEY_ALIAS`: Alias for the Android signing key +- `ANDROID_KEY_PASSWORD`: Password for the Android signing key +- `FIREBASE_APP_ID`: (Optional) Firebase App ID for Firebase App Distribution +- `GOOGLE_API_KEY`: Google API Key (google-services.json) for Firebase integration (base64 encoded) + +**Variables:** + +- `FASTLANE_BUILD_NUMBER`: Build number (set automatically from tag in deploy workflow) +- `PACKAGE`: Android package name (e.g., "ac.at.lbg.dhp.more") + +### Running the Pipeline Under Other Accounts + +To run the pipeline under your own account, follow these steps: + +1. **Fork the repository** to your GitHub account. + +2. **Set up the required secrets and variables** in your GitHub repository: + - Go to your repository settings + - Navigate to "Secrets and variables" > "Actions" + - Add all the required secrets and variables listed above + +3. **iOS-specific setup:** + - Create an App Store Connect API key in your Apple Developer account + - Set up a match repository for code signing + - Update the bundle identifiers in the project to match your own + +4. **Android-specific setup:** + - Create a Google Play service account and download the key file + - Create a keystore for signing your Android app + - Update the package name in the project to match your own + +5. **Trigger the workflows:** + - For the build workflow: Push to any branch or create a pull request + - For the deploy workflow: Create and push a tag with the format `x.x.x` (e.g., + `git tag 1.0.0 && git push origin 1.0.0`) + +### Troubleshooting + +- **iOS build fails**: Check that all iOS-related environment variables are set correctly and that + your Apple Developer account has the necessary permissions. Ensure that the + `APPLE_CONNECT_KEY_CONTENT` is properly base64 encoded and that the `APPLE_CONNECT_KEY_IS_BASE64` + is set to true. +- **Android build fails**: Verify that the Android keystore and Google Play key are correctly + encoded in base64. The Android build process expects both `ANDROID_KEYSTORE_BASE64` and + `GOOGLE_PLAY_KEY_IN_BASE64` to be properly base64 encoded. +- **Deployment fails**: Ensure that the app identifiers match the ones in your Apple Developer + account or Google Play Console. For iOS, make sure the `TARGETS` variable matches the app + identifiers in the same order. +- **Version code issues**: If you encounter version code conflicts in Google Play, the system will + automatically try to increment the version code based on the latest version in Google Play. If + this fails, it will fall back to the calculated version code based on the semantic version. + ## Usage ### Emulator Configuration -In order to run your application you have to create an emulator device. Follow these steps to create an Android emulator: + +In order to run your application you have to create an emulator device. Follow these steps to create +an Android emulator: 1. Click **Device Manager** in the upper right corner, right next to the build symbol. 2. Click **Create device**. -3. Choose the device you would like to use as an emulator. **Important**: The device should have **Play Store** support! You can see it by the device being marked with a Play Store icon. -4. Choose a system image. It's recommended to use the **Tiramisu** release with the **API Level 33**. +3. Choose the device you would like to use as an emulator. **Important**: The device should have * + *Play Store** support as only these will receive Firebase Push Notifications! You can see it by + the device being marked with a Play Store icon. +4. Choose a system image. It's recommended to use the **Tiramisu** release with the **API Level 33 + **. 5. Next verify configuration and the installation of the image will begin immediately. 6. Now you are all set to run your application on the configured Emulator! ### Running the App -After you have configured the emulator device for your project, you can run the application, which will start the emulator and install your application on it. + +After you have configured the emulator device for your project, you can run the application, which +will start the emulator and install your application on it. After that you can use the emulator to test the app. -Because **More App Multiplatform** supports iOS and Android, you can choose which application and the corresponding emulator you want to run. +Because **More App Multiplatform** supports iOS and Android, you can choose which application and +the corresponding emulator you want to run. #### Android App + 1. In the **Run Configurations** choose **androidApp**. 2. In the **Available Devices** choose your configured **Emulator Device**. 3. Press **Run** arrow. #### iOS App + 1. In the **Run Configurations** choose **ios App**. 2. Press **Run** arrow. +#### Local development with app, studymanager and gateway + +Local setup together +with [more-studymanager-backend](https://github.com/MORE-Platform/more-studymanager-backend), [more-studymanager-frontend](https://github.com/MORE-Platform/more-studymanager-frontend) +and [more-datag-ateway](https://github.com/MORE-Platform/more-data-gateway). + +##### Android App + +The APK from the App Store isn't able to run against your local setup, because it doesn't support +it. To be able to run it with your local setup follow this step-by-step guide: + +1. Open Android Studio + +2. Go to AndroidManifest and add following line to :/api/v1 +``` + +##### iOS App + +The IOS-App can be basically runs with any image, since it supports clear traffic. If you doesn't +have changes in the app, you could even run it directly against your local setup with the App-Store +Version. ## Project Architecture -The purpose of the Kotlin Multiplatform Mobile technology is unifying the development of applications with common logic for Android and iOS platforms. +The purpose of the Kotlin Multiplatform Mobile technology is unifying the development of +applications with common logic for Android and iOS platforms. To make this possible, it uses a mobile-specific structure of Kotlin Multiplatform projects. -To view the complete structure of your mobile multiplatform project, switch the view from **Android** to **Project**. +To view the complete structure of your mobile multiplatform project, switch the view from **Android +** to **Project**. ### Root Project -The root project is a Gradle project that holds the shared module and the Android application as its subprojects. -They are linked together via the [Gradle multi-project mechanism](https://docs.gradle.org/current/userguide/multi_project_builds.html). +The root project is a Gradle project that holds the shared module and the Android application as its +subprojects. +They are linked together via +the [Gradle multi-project mechanism](https://docs.gradle.org/current/userguide/multi_project_builds.html). ![App architecture](https://kotlinlang.org/docs/images/basic-project-structure.png) -The iOS application is produced from an Xcode project. It's stored in a separate directory within the root project. Xcode uses its own build system; thus, the iOS application project isn't connected with other parts of the Multiplatform Mobile project via Gradle. Instead, it uses the shared module as an external artifact – framework. For details on integration between the shared module and the iOS application, see [iOS application](https://kotlinlang.org/docs/multiplatform-mobile-understand-project-structure.html#ios-application). +The iOS application is produced from an Xcode project. It's stored in a separate directory within +the root project. Xcode uses its own build system; thus, the iOS application project isn't connected +with other parts of the Multiplatform Mobile project via Gradle. Instead, it uses the shared module +as an external artifact – framework. For details on integration between the shared module and the +iOS application, +see [iOS application](https://kotlinlang.org/docs/multiplatform-mobile-understand-project-structure.html#ios-application). -The root project does not hold source code. You can use it to store global configuration in its `build.gradle(.kts)` or `gradle.properties`, for example, add repositories or define global configuration variables. +The root project does not hold source code. You can use it to store global configuration in its +`build.gradle(.kts)` or `gradle.properties`, for example, add repositories or define global +configuration variables. ### Shared Module -Shared module contains the core application logic used in both Android and iOS target platforms: classes, functions, and so on. -This is a [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform-get-started.html) module that compiles into an Android library and an iOS framework. It uses the Gradle build system with the Kotlin Multiplatform plugin applied and has targets for Android and iOS. +Shared module contains the core application logic used in both Android and iOS target platforms: +classes, functions, and so on. +This is a [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform-get-started.html) module +that compiles into an Android library and an iOS framework. It uses the Gradle build system with the +Kotlin Multiplatform plugin applied and has targets for Android and iOS. ```kotlin plugins { @@ -141,10 +419,11 @@ kotlin { } ``` - #### Sources sets + The shared module contains the code that is common for Android and iOS applications. -However, to implement the same logic on Android and iOS, you sometimes need to write two platform-specific versions of it. +However, to implement the same logic on Android and iOS, you sometimes need to write two +platform-specific versions of it. To handle such cases, Kotlin offers the expect/actual mechanism. The source code of the shared module is organized in three source sets accordingly: @@ -152,33 +431,61 @@ The source code of the shared module is organized in three source sets according * `androidMain` stores Android-specific parts, including `actual` implementations * `iosMain` stores iOS-specific parts, including `actual` implementations +#### Database changes + +When making changes to the Database Schemas, please *make sure to increase the Database Schema +Version* in the `RealmDatabase.kt` file located unter +`shared/src/commonMain/kotlin/io.redlink.umm.participant/database`. + +*If this version is not upgraded after a schema change, the app will crash on already deployed +systems!* + +#### Deployment + +Currently there is not automatic deployment. This should be implemented in near future, but until +then, these are the steps to ensure a proper deployment of new app versions: + +1. Update the Version name and code of the Android App under `androidApp/build.gradle.kts`. The + version code just needs to be incremented by 1, while the name is x.x.x (e.g. 4.0.26) +2. Update the Version in iOS under the `Target` `More` -> General +3. Update the `Bundle version` and `Bundle version string` under Info with the same system x.x.x ( + e.g. 4.0.26) ## Troubleshooting + * Use **Wipe Data** on your emulator device. * Use **Sync Project with Gradle Files** in the **File** tab. * Use **Invalidate Caches** in the **File** tab. ### Operating System Management of MORE Apps -As the mobile phone operating systems are attempting to limit unintended application data access and background activities for privacy and battery preservation, as well as for overall performance reasons, please consider checking through operating system settings that: + +As the mobile phone operating systems are attempting to limit unintended application data access and +background activities for privacy and battery preservation, as well as for overall performance +reasons, please consider checking through operating system settings that: + * MORE can run without battery saving limitations as a background application -* Access to the required sensing APIs is available (particularly GPS, accellerometry and wider physical activity according to your study needs) and ideally not limited to episodes of active (foreground) application use only -* If pairing with further sensing devices is intended, please assure that Bluetooth is enabled with appropriate access rights and consider resetting the connection or manually linking devices through operating system functionalities if the integrated pairing in MORE fails +* Access to the required sensing APIs is available (particularly GPS, accellerometry and wider + physical activity according to your study needs) and ideally not limited to episodes of active ( + foreground) application use only +* If pairing with further sensing devices is intended, please assure that Bluetooth is enabled with + appropriate access rights and consider resetting the connection or manually linking devices + through operating system functionalities if the integrated pairing in MORE fails ## Useful links + * https://kotlinlang.org/docs/multiplatform-mobile-setup.html * https://kotlinlang.org/docs/multiplatform-mobile-understand-project-structure.html * https://kotlinlang.org/docs/multiplatform-mobile-integrate-in-existing-app.html * https://kotlinlang.org/docs/multiplatform-mobile-ktor-sqldelight.html - - ## License Apache 2.0 with Commons Clause; see LICENSE.txt for further details -## Contact -Ludwig Boltzmann Institute for Digital Health and Prevention - [more-health.at](https://more-health.at/) - more@dhp.lbg.ac.at +## Contact +Ludwig Boltzmann Institute for Digital Health and +Prevention - [more-health.at](https://more-health.at/) - more@dhp.lbg.ac.at diff --git a/androidApp/Gemfile b/androidApp/Gemfile new file mode 100644 index 000000000..f5dcf3b14 --- /dev/null +++ b/androidApp/Gemfile @@ -0,0 +1,3 @@ + source "https://rubygems.org" + +gem "fastlane" diff --git a/androidApp/Gemfile.lock b/androidApp/Gemfile.lock new file mode 100644 index 000000000..a935bfc9d --- /dev/null +++ b/androidApp/Gemfile.lock @@ -0,0 +1,230 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1188.0) + aws-sdk-core (3.239.2) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.205.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + bigdecimal (3.3.1) + 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.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.1) + 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.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.229.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.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) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.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.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.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.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + 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.31.0) + google-apis-core (>= 0.11.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.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.16.0) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.17.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + optparse (0.8.0) + os (1.1.4) + plist (3.7.2) + public_suffix (7.0.0) + rake (13.3.1) + 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.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + 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.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.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.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-24 + ruby + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.7.2 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index b4f1ad76a..9813b7380 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,33 +1,135 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.util.Base64 +import java.util.Properties + plugins { id("com.android.application") id("com.google.gms.google-services") kotlin("android") - id("io.realm.kotlin") version "1.14.1" + id("org.jetbrains.kotlin.plugin.compose") id("com.google.firebase.crashlytics") + id("com.google.devtools.ksp") + } +fun loadEnvFromFile(): Properties { + val envProps = Properties() + val envFiles = listOf( + File(rootProject.rootDir, ".env"), + File(rootProject.rootDir, "local.properties"), + File(project.projectDir, ".env"), + File(project.projectDir, "signing.properties") + ) + + envFiles.forEach { envFile -> + if (envFile.exists()) { + println("Loading environment variables from: ${envFile.absolutePath}") + try { + envFile.inputStream().use { input -> + envProps.load(input) + } + } catch (e: Exception) { + println("Failed to load ${envFile.name}: ${e.message}") + } + } + } + + val googleServicesApiKey = getEnvOrProperty("GOOGLE_API_KEY", envProps) + val googleServicesFile = File(project.projectDir, "google-services.json") + if (!googleServicesFile.exists() && !googleServicesApiKey.isNullOrEmpty()) { + println("google-services.json not found, creating from GOOGLE_API_KEY environment variable") + try { + val decodedBytes = + Base64.getDecoder().decode(googleServicesApiKey.trim().removeSurrounding("\"")) + googleServicesFile.writeBytes(decodedBytes) + println("Created google-services.json from environment variable") + } catch (e: Exception) { + println("Failed to decode GOOGLE_API_KEY: ${e.message}") + } + } + + return envProps +} + +fun getEnvOrProperty(key: String, envProps: Properties): String? { + return System.getenv(key) ?: envProps.getProperty(key) +} + +val envProps = loadEnvFromFile() + android { namespace = "io.redlink.more.app.android" - compileSdk = 34 + compileSdk = 36 defaultConfig { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 - targetSdk = 34 - versionCode = 18 - versionName = "4.0.18" + targetSdk = 36 + versionCode = 37 + versionName = "5.0.0" } buildFeatures { compose = true buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.11" - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + signingConfigs { + create("release") { + val keystorePath = getEnvOrProperty("ANDROID_KEYSTORE_PATH", envProps) ?: "" + val keystoreBase64 = getEnvOrProperty("ANDROID_KEYSTORE_BASE64", envProps) ?: "" + this.storePassword = getEnvOrProperty("ANDROID_KEYSTORE_PASSWORD", envProps) ?: "" + this.keyAlias = getEnvOrProperty("ANDROID_KEY_ALIAS", envProps) ?: "" + this.keyPassword = getEnvOrProperty("ANDROID_KEY_PASSWORD", envProps) ?: "" + + println("Keystore path: ${keystorePath.length}\n keystoreBase64: ${keystoreBase64.length}\n keystorePassword: ${this.storePassword?.length}\n keyAlias: ${this.keyAlias?.length}\n keyPassword: ${this.keyPassword?.length}") + + val storeFile: File? = if (keystorePath.isNotEmpty()) { + val keystoreFile = File(keystorePath) + if (keystoreFile.exists()) { + println("Using keystore path from configuration: $keystorePath") + keystoreFile + } else { + println("Keystore file does not exist at: $keystorePath") + null + } + } else if (keystoreBase64.isNotEmpty()) { + try { + println("Using keystore from base64 configuration") + val decodedBytes = Base64.getDecoder().decode(keystoreBase64) + val file = File.createTempFile("keystore", ".jks") + file.deleteOnExit() + file.writeBytes(decodedBytes) + + file + } catch (e: Exception) { + println("Failed to decode base64 keystore: ${e.message}") + null + } + } else { + null + } + storeFile?.let { + this.storeFile = it + } ?: run { + println("Keystore file not found for release signing. Leaving release signing unconfigured.") + this.storeFile = null + this.storePassword = null + this.keyAlias = null + this.keyPassword = null + } + } + } + buildTypes { debug { buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") @@ -36,24 +138,48 @@ android { release { buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") + + val releaseSigningConfig = signingConfigs.getByName("release") + signingConfig = if (releaseSigningConfig.storeFile != null) { + releaseSigningConfig + } else { + println("Warning: No release keystore configured. Falling back to default debug signing.") + signingConfigs.getByName("debug") + } + + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "11" +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } -val composeVersion = "1.6.8" -val workVersion = "2.9.0" -val navVersion = "2.7.7" -val polarSDKVersion = "5.6.0" +// ... rest of your dependencies ... + +val composeVersion = "1.6.0" +val workVersion = "2.10.3" +val navVersion = "2.9.3" +val polarSDKVersion = "6.7.0" +val ktorVersion = "3.4.0" +val roomVersion = "2.8.4" +val koinVersion = "4.1.1" +val cameraVersion = "1.4.2" dependencies { implementation(project(":shared")) + implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("androidx.compose.ui:ui:$composeVersion") implementation("androidx.compose.ui:ui-tooling:$composeVersion") implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion") @@ -63,7 +189,6 @@ dependencies { implementation("androidx.compose.material:material-icons-extended:$composeVersion") implementation("androidx.fragment:fragment:1.8.2") implementation("androidx.activity:activity-compose:1.9.1") - implementation("io.realm.kotlin:library-base:1.13.0") implementation("androidx.navigation:navigation-compose:$navVersion") implementation("androidx.work:work-runtime-ktx:$workVersion") implementation("com.google.android.gms:play-services-location:21.3.0") @@ -71,14 +196,28 @@ dependencies { implementation("com.google.firebase:firebase-messaging-ktx:24.0.0") implementation("io.github.aakira:napier:2.7.1") implementation("com.github.polarofficial:polar-ble-sdk:${polarSDKVersion}") - implementation("io.reactivex.rxjava3:rxjava:3.1.8") + implementation("io.reactivex.rxjava3:rxjava:3.1.11") implementation("io.reactivex.rxjava3:rxandroid:3.0.2") implementation(platform("com.google.firebase:firebase-bom:33.1.2")) implementation("com.google.firebase:firebase-analytics") implementation("com.google.firebase:firebase-crashlytics-ktx") implementation("com.google.firebase:firebase-inappmessaging-ktx") implementation("com.google.firebase:firebase-inappmessaging-display-ktx") - implementation("com.google.code.gson:gson:2.10.1") + implementation("com.google.code.gson:gson:2.13.2") implementation("com.github.acsbendi:Android-Request-Inspector-WebView:1.0.3") - implementation("androidx.lifecycle:lifecycle-process:2.8.4") + implementation("androidx.lifecycle:lifecycle-process:2.9.3") + //Google ML Kit for QR Scanning + implementation("com.google.mlkit:barcode-scanning:17.3.0") + implementation("androidx.camera:camera-camera2:$cameraVersion") + implementation("androidx.camera:camera-lifecycle:$cameraVersion") + implementation("androidx.camera:camera-view:$cameraVersion") + + implementation("androidx.room:room-runtime:$roomVersion") + + ksp("androidx.room:room-compiler:$roomVersion") + + implementation(platform("io.insert-koin:koin-bom:$koinVersion")) + implementation("io.insert-koin:koin-core") + implementation("io.insert-koin:koin-android") + } diff --git a/androidApp/fastlane/Appfile b/androidApp/fastlane/Appfile new file mode 100644 index 000000000..7bd5b5bcc --- /dev/null +++ b/androidApp/fastlane/Appfile @@ -0,0 +1 @@ +package_name "ac.at.lbg.dhp.more" diff --git a/androidApp/fastlane/Fastfile b/androidApp/fastlane/Fastfile new file mode 100644 index 000000000..47816f9d2 --- /dev/null +++ b/androidApp/fastlane/Fastfile @@ -0,0 +1,383 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +default_platform(:android) + +if ENV["CI"] + update_fastlane +end + +platform :android do + def decode_base64_to_temp_file(base64_content, file_extension = '.json', prefix = 'temp_file', is_file_path = false) + require 'base64' + require 'tempfile' + + if is_file_path && File.exist?(File.expand_path(base64_content)) + UI.message("Reading base64 content from file: #{base64_content}") + base64_content = File.read(File.expand_path(base64_content)) + end + + decoded_content = Base64.decode64(base64_content) + temp_file = Tempfile.new([prefix, file_extension]) + temp_file.write(decoded_content) + temp_file.close + + UI.success("Created temporary file with decoded content at: #{temp_file.path}") + return temp_file.path + end + + def setup_android_signing + if ENV["ANDROID_KEYSTORE_BASE64"] && !ENV["ANDROID_KEYSTORE_BASE64"].empty? + UI.message("Setting up Android signing with keystore from environment variable") + + keystore_path = decode_base64_to_temp_file( + ENV["ANDROID_KEYSTORE_BASE64"], + '.jks', + 'android_keystore' + ) + + ENV["ANDROID_KEYSTORE_PATH"] = keystore_path + + UI.success("Android signing environment set up successfully") + return true + else + UI.important("ANDROID_KEYSTORE_BASE64 not provided, skipping signing setup") + return false + end + end + + def get_aab_path(project_dir = "..") + project_root = File.expand_path(project_dir) + + aab_path = File.join(project_root, "build", "outputs", "bundle", "release", "androidApp-release.aab") + + unless File.exist?(aab_path) + UI.error("AAB file not found at: #{aab_path}") + raise "AAB file not found. Make sure the release build has been created." + end + + UI.success("Found AAB at: #{aab_path}") + return aab_path + end + + def get_apk_path(project_dir = "..", build_type = "debug") + project_root = File.expand_path(project_dir) + + apk_path = File.join(project_root, "build", "outputs", "apk", build_type, "androidApp-#{build_type}.apk") + + unless File.exist?(apk_path) + UI.error("APK file not found at: #{apk_path}") + raise "APK file not found. Make sure the #{build_type} build has been created." + end + + UI.success("Found APK at: #{apk_path}") + return apk_path + end + + lane :setup_google_services do + google_services_path = File.expand_path("../google-services.json") + if !File.exist?(google_services_path) && ENV["GOOGLE_API_KEY"] && !ENV["GOOGLE_API_KEY"].empty? + UI.message("google-services.json not found, creating from GOOGLE_API_KEY environment variable") + require 'base64' + File.write(google_services_path, Base64.decode64(ENV["GOOGLE_API_KEY"].gsub(/^"(.*)"$/, '\1'))) + UI.success("Created google-services.json from environment variable") + elsif File.exist?(google_services_path) + UI.message("google-services.json already exists, skipping creation from environment variable") + else + UI.important("google-services.json not found and GOOGLE_API_KEY not provided") + end + end + + + desc "Run all tests" + lane :test do + gradle( + task: ":shared:testDebugUnitTest", + project_dir: ".." + ) + end + + desc "Build the Android app (debug)" + lane :build do + begin + setup_google_services + setup_android_signing + + generate_openapi + gradle( + task: "assemble", + build_type: "Debug", + project_dir: ".." + ) + + test + + if !ENV['CI'] + begin + if Gem::Specification.find_all_by_name('fastlane-plugin-firebase_app_distribution').any? && + ENV["FIREBASE_APP_ID"] && !ENV["FIREBASE_APP_ID"].empty? + + apk_path = get_apk_path("..", "debug") + + firebase_app_distribution( + app: ENV["FIREBASE_APP_ID"], + groups: "testers", + release_notes: "Debug build", + apk_path: apk_path + ) + else + if !Gem::Specification.find_all_by_name('fastlane-plugin-firebase_app_distribution').any? + UI.important("Firebase App Distribution plugin not available. Skipping Crashlytics upload.") + elsif !ENV["FIREBASE_APP_ID"] || ENV["FIREBASE_APP_ID"].empty? + UI.important("FIREBASE_APP_ID not set. Skipping Crashlytics upload.") + end + end + rescue => e + UI.error("Error uploading to Crashlytics: #{e.message}") + end + end + + UI.success("Android build completed successfully") + rescue => e + UI.error("Error building Android app: #{e.message}") + raise e if ENV['CI'] + end + end + + private_lane :generate_openapi do + gradle( + task: ":shared:generateOpenApiClasses", + project_dir: ".." + ) + end + + desc "Deploy to Google Play Beta" + lane :deploy_beta do + begin + setup_google_services + signing_configured = setup_android_signing + unless signing_configured + UI.user_error!("Missing ANDROID_KEYSTORE_BASE64 (release keystore). Refusing to build/upload a Release AAB unsigned or debug-signed.") + end + + if ENV["FASTLANE_BUILD_NUMBER"].nil? || ENV["FASTLANE_BUILD_NUMBER"].empty? + UI.error("FASTLANE_BUILD_NUMBER environment variable is not set or is empty") + raise "FASTLANE_BUILD_NUMBER environment variable is required" + end + + name = ENV.fetch("FASTLANE_BUILD_NUMBER") + UI.message("Building version #{name}") + + generate_openapi + gradle( + task: "bundle", + build_type: "Release", + project_dir: ".." + ) + + test + + begin + major, minor, patch = name.split('.').map(&:to_i) + + # Build a 5-digit code: MMmmpp + # major × 10⁴ + minor × 10² + patch + # → 4.0.22 becomes 4*10000 + 0*100 + 22 = 40022 + base_code = major * 10_000 + minor * 100 + patch + + if ENV["GOOGLE_PLAY_KEY_IN_BASE64"].nil? || ENV["GOOGLE_PLAY_KEY_IN_BASE64"].empty? + UI.important("GOOGLE_PLAY_KEY_IN_BASE64 environment variable is not set or is empty") + UI.important("Will attempt to use default authentication method for version code retrieval") + latest_version_code = google_play_track_version_codes( + track: "internal" + ).max || base_code + else + key_base64 = ENV["GOOGLE_PLAY_KEY_IN_BASE64"] + key_file_path = "" + if !key_base64.nil? && !key_base64.empty? + key_file_path = decode_base64_to_temp_file(key_base64) + end + + if File.exist?(File.expand_path(key_file_path)) + UI.success("Using Google Play key file for version code retrieval: #{key_file_path}") + latest_version_code = google_play_track_version_codes( + track: "internal", + json_key: File.expand_path(key_file_path) + ).max || base_code + else + UI.error("Google Play key file not found at: #{key_file_path}") + raise "Google Play key file not found. Please check the GOOGLE_PLAY_KEY_IN_BASE64 environment variable." + end + end + + code = [base_code, latest_version_code + 1].max + UI.message("Base version code: #{base_code}") + UI.message("Latest version code from Google Play: #{latest_version_code}") + UI.message("Using incremented version code: #{code}") + rescue => e + UI.error("Error processing version number: #{e.message}") + UI.important("Falling back to calculated version code without increment") + code = major * 10_000 + minor * 100 + patch + UI.message("Fallback version code: #{code}") + end + + generate_openapi + + gradle( + task: "bundle", + build_type: "Release", + project_dir: "..", + properties: { + "VERSION_CODE" => code, + "VERSION_NAME" => name + } + ) + + if !signing_configured + UI.important("Release build was created without signing configuration. This may cause issues when uploading to Google Play.") + end + + aab_path = get_aab_path("..") + + begin + if ENV["GOOGLE_PLAY_KEY_IN_BASE64"] && !ENV["GOOGLE_PLAY_KEY_IN_BASE64"].empty? + key_file_path = decode_base64_to_temp_file(ENV["GOOGLE_PLAY_KEY_IN_BASE64"]) + upload_to_play_store( + track: "internal", + skip_upload_metadata: true, + release_status: "completed", + aab: aab_path, + skip_upload_apk: true, + json_key: File.expand_path(key_file_path) + ) + UI.success("Successfully deployed to Google Play Internal") + end + rescue => e + UI.error("Error uploading to Google Play: #{e.message}") + raise e + end + rescue => e + UI.error("Error in deploy_beta: #{e.message}") + raise e + end + end + + desc "Deploy to Google Play Production" + lane :deploy_production do + begin + setup_google_services + signing_configured = setup_android_signing + unless signing_configured + UI.user_error!("Missing ANDROID_KEYSTORE_BASE64 (release keystore). Refusing to build/upload a Release AAB unsigned or debug-signed.") + end + + if ENV["FASTLANE_BUILD_NUMBER"].nil? || ENV["FASTLANE_BUILD_NUMBER"].empty? + UI.error("FASTLANE_BUILD_NUMBER environment variable is not set or is empty") + raise "FASTLANE_BUILD_NUMBER environment variable is required" + end + + name = ENV.fetch("FASTLANE_BUILD_NUMBER") + UI.message("Building version #{name} for production") + + generate_openapi + gradle( + task: "bundle", + build_type: "Release", + project_dir: ".." + ) + + test + + begin + major, minor, patch = name.split('.').map(&:to_i) + + # Same version code scheme as beta + base_code = major * 10_000 + minor * 100 + patch + + if ENV["GOOGLE_PLAY_KEY_IN_BASE64"].nil? || ENV["GOOGLE_PLAY_KEY_IN_BASE64"].empty? + UI.important("GOOGLE_PLAY_KEY_IN_BASE64 environment variable is not set or is empty") + UI.important("Will attempt to use default authentication method for version code retrieval") + latest_version_code = google_play_track_version_codes( + track: "production" + ).max || base_code + else + key_base64 = ENV["GOOGLE_PLAY_KEY_IN_BASE64"] + key_file_path = "" + if !key_base64.nil? && !key_base64.empty? + key_file_path = decode_base64_to_temp_file(key_base64) + end + + if File.exist?(File.expand_path(key_file_path)) + UI.success("Using Google Play key file for version code retrieval: #{key_file_path}") + latest_version_code = google_play_track_version_codes( + track: "production", + json_key: File.expand_path(key_file_path) + ).max || base_code + else + UI.error("Google Play key file not found at: #{key_file_path}") + raise "Google Play key file not found. Please check the GOOGLE_PLAY_KEY_IN_BASE64 environment variable." + end + end + + code = [base_code, latest_version_code + 1].max + UI.message("Base version code: #{base_code}") + UI.message("Latest production version code from Google Play: #{latest_version_code}") + UI.message("Using incremented production version code: #{code}") + rescue => e + UI.error("Error processing version number for production: #{e.message}") + UI.important("Falling back to calculated version code without increment") + code = major * 10_000 + minor * 100 + patch + UI.message("Fallback production version code: #{code}") + end + + generate_openapi + + gradle( + task: "bundle", + build_type: "Release", + project_dir: "..", + properties: { + "VERSION_CODE" => code, + "VERSION_NAME" => name + } + ) + + if !signing_configured + UI.important("Release build was created without signing configuration. This may cause issues when uploading to Google Play.") + end + + aab_path = get_aab_path("..") + + begin + if ENV["GOOGLE_PLAY_KEY_IN_BASE64"] && !ENV["GOOGLE_PLAY_KEY_IN_BASE64"].empty? + key_file_path = decode_base64_to_temp_file(ENV["GOOGLE_PLAY_KEY_IN_BASE64"]) + upload_to_play_store( + track: "production", + skip_upload_metadata: true, + release_status: "completed", + aab: aab_path, + skip_upload_apk: true, + json_key: File.expand_path(key_file_path) + ) + UI.success("Successfully deployed to Google Play Production") + end + rescue => e + UI.error("Error uploading to Google Play Production: #{e.message}") + raise e + end + rescue => e + UI.error("Error in deploy_production: #{e.message}") + raise e + end + end +end \ No newline at end of file diff --git a/androidApp/fastlane/README.md b/androidApp/fastlane/README.md new file mode 100644 index 000000000..4022a4cb3 --- /dev/null +++ b/androidApp/fastlane/README.md @@ -0,0 +1,64 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## Android + +### android setup_google_services + +```sh +[bundle exec] fastlane android setup_google_services +``` + + + +### android test + +```sh +[bundle exec] fastlane android test +``` + +Run all tests + +### android build + +```sh +[bundle exec] fastlane android build +``` + +Build the Android app (debug) + +### android deploy_beta + +```sh +[bundle exec] fastlane android deploy_beta +``` + +Deploy to Google Play Beta + +### android deploy_production + +```sh +[bundle exec] fastlane android deploy_production +``` + +Deploy to Google Play Production + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/androidApp/google-services.json b/androidApp/google-services.json deleted file mode 100644 index 1216bd0c6..000000000 --- a/androidApp/google-services.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "project_info": { - "project_number": "867714323835", - "project_id": "more-adad0", - "storage_bucket": "more-adad0.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:867714323835:android:8ec18f3dace4c8cb5019de", - "android_client_info": { - "package_name": "ac.at.lbg.dhp.more" - } - }, - "oauth_client": [ - { - "client_id": "867714323835-diq7io7nnpi78dmolkam7qreea5qpg71.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDOamQrEMV2y3cieAIImOO59DDOYe_n_qQ" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "867714323835-diq7io7nnpi78dmolkam7qreea5qpg71.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "867714323835-jhsjqjereaj3ctk3c709kk8mci20p0qm.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "io.redlink.more.app.multiplatform" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:867714323835:android:c18e2baa787d7a3a5019de", - "android_client_info": { - "package_name": "io.redlink.more.app.android" - } - }, - "oauth_client": [ - { - "client_id": "867714323835-diq7io7nnpi78dmolkam7qreea5qpg71.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDOamQrEMV2y3cieAIImOO59DDOYe_n_qQ" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "867714323835-diq7io7nnpi78dmolkam7qreea5qpg71.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "867714323835-jhsjqjereaj3ctk3c709kk8mci20p0qm.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "io.redlink.more.app.multiplatform" - } - } - ] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro new file mode 100644 index 000000000..01115eef1 --- /dev/null +++ b/androidApp/proguard-rules.pro @@ -0,0 +1,49 @@ +# Keep SLF4J classes +-keep class org.slf4j.** { *; } +-dontwarn org.slf4j.** + +# Keep Google Play Services Location classes +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# Keep Polar SDK classes if you're using it +-keep class com.polar.** { *; } +-dontwarn com.polar.** + +# Keep RxJava classes +-keep class io.reactivex.** { *; } +-dontwarn io.reactivex.** + +# Keep Realm classes +-keep class io.realm.** { *; } +-dontwarn io.realm.** + +# Keep Firebase classes +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** + +# Keep Napier logging classes +-keep class io.github.aakira.napier.** { *; } +-dontwarn io.github.aakira.napier.** + +# General rule for missing classes +-dontwarn java.lang.invoke.** +-dontwarn org.slf4j.impl.StaticLoggerBinder + + +# Joda Time +-keep class org.joda.time.** { *; } +-keep class org.joda.convert.** { *; } +-dontwarn org.joda.time.** +-dontwarn org.joda.convert.** + +# Keep Joda Time annotations +-keepattributes *Annotation* +-keep @org.joda.convert.FromString class * +-keep @org.joda.convert.ToString class * + +# Keep methods annotated with Joda Convert annotations +-keepclassmembers class * { + @org.joda.convert.FromString *; + @org.joda.convert.ToString *; +} \ No newline at end of file diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 60ce3803a..02f420b88 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -19,24 +20,34 @@ + + + + + + + @@ -60,23 +71,43 @@ + + + + + + + + + + + android:name="io.redlink.more.app.android.services.ObservationRecordingService" + android:enabled="true" + android:exported="false" + android:foregroundServiceType="dataSync|location|connectedDevice" /> @@ -89,11 +120,13 @@ android:value="@string/default_channel_id" /> + android:name="io.redlink.more.app.android.broadcasts.NotificationBroadcastReceiver" + android:exported="true"> - + + + - \ No newline at end of file + diff --git a/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt index 2580b04d3..d5db02a52 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt @@ -18,15 +18,22 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import com.google.firebase.analytics.FirebaseAnalytics import io.github.aakira.napier.Napier +import io.redlink.more.Shared +import io.redlink.more.app.android.extensions.applicationId import io.redlink.more.app.android.observations.AndroidDataRecorder import io.redlink.more.app.android.observations.AndroidObservationDataManager import io.redlink.more.app.android.observations.AndroidObservationFactory import io.redlink.more.app.android.services.LocalPushNotificationService import io.redlink.more.app.android.services.bluetooth.PolarConnector import io.redlink.more.app.android.util.logging.FirebaseCrashlyticsAntilog -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.napierDebugBuild -import io.redlink.more.more_app_mutliplatform.services.store.SharedPreferencesRepository +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.getDatabaseBuilder +import io.redlink.more.database.getRoomDatabase +import io.redlink.more.database.repository.MainRepositoryImpl +import io.redlink.more.logging.napierDebugBuild +import io.redlink.more.models.NotificationTextLocalization +import io.redlink.more.services.store.SharedPreferencesRepository +import io.redlink.more.viewModels.ViewManager /** * Main Application class of the project. @@ -36,34 +43,51 @@ class MoreApplication : Application(), DefaultLifecycleObserver { super.onCreate() napierDebugBuild(FirebaseCrashlyticsAntilog()) napierDebugBuild() + + firebaseAnalytics = FirebaseAnalytics.getInstance(this) + appContext = this + packagePath = this.packageName + appName = this.getString(R.string.app_name) + DEFAULT_CHANNEL_ID = packagePath + appName!!.lowercase() + ".urgent" + NotificationTextLocalization.init(this) initShared(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) } override fun onTerminate() { + shared?.bluetoothController?.close() super.onTerminate() - shared?.mainBluetoothConnector?.close() } override fun onResume(owner: LifecycleOwner) { super.onResume(owner) Napier.i { "App is in the foreground..." } - shared?.appInForeground(true) - shared?.notificationManager?.updateNotificationBadgeCount() + ViewManager.appIsInForeground(true) + shared?.updateData(true) } override fun onPause(owner: LifecycleOwner) { super.onPause(owner) Napier.i { "App is in the background..." } - shared?.appInForeground(false) + ViewManager.appIsInForeground(false) + shared?.updateData(false) } companion object { var appContext: Context? = null private set + var appName: String? = null + private set + + var packagePath: String? = null + private set + + var DEFAULT_CHANNEL_ID: String? = null + private set + var firebaseAnalytics: FirebaseAnalytics? = null private set @@ -79,17 +103,30 @@ class MoreApplication : Application(), DefaultLifecycleObserver { if (shared == null) { polarConnector = PolarConnector(context) val androidBluetoothConnector = polarConnector!! - val dataManager = AndroidObservationDataManager(context) - shared = Shared( + val database: AppDatabase = getRoomDatabase(getDatabaseBuilder(context)) + val repositories = MainRepositoryImpl(database) + val dataManager = AndroidObservationDataManager(context, repositories) + val sharedPreferences = SharedPreferencesRepository(context) + val tempShared = Shared( LocalPushNotificationService(context), - SharedPreferencesRepository(context), + repositories, + sharedPreferences, dataManager, androidBluetoothConnector, - AndroidObservationFactory(context, dataManager), + AndroidObservationFactory( + context, + dataManager, + repositories, + sharedPreferences, + ), AndroidDataRecorder() ) + shared = tempShared + tempShared.let { shared -> + shared.deeplinkManager.setProtocol(Shared.PROTOCOL.toString(context)) + shared.deeplinkManager.setHost(applicationId) // applicationId is needed instead of the shared HOST, as this is necessary for the NavController in Android + } } } - } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt index 02b338bd5..c7f2f7656 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentActivity.kt @@ -14,16 +14,25 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import io.github.aakira.napier.Napier +import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen.Companion.NavigationNotificationIDKey import io.redlink.more.app.android.activities.consent.ConsentView import io.redlink.more.app.android.activities.login.LoginView +import io.redlink.more.app.android.activities.studyStates.StudyLoadingErrorView +import io.redlink.more.app.android.activities.studyStates.StudyLoadingView import io.redlink.more.app.android.extensions.applicationId import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.shared_composables.AppVersion import io.redlink.more.app.android.shared_composables.MoreBackground -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager +import io.redlink.more.navigation.model.NavigationRouteParameter +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch class ContentActivity : ComponentActivity() { private val viewModel = ContentViewModel() @@ -31,17 +40,33 @@ class ContentActivity : ComponentActivity() { super.onCreate(savedInstanceState) intent.getStringExtra(NotificationManager.DEEP_LINK)?.let { var deepLink = it + Napier.d { "Received deep link: $deepLink" } intent.getStringExtra(NotificationManager.MSG_ID)?.let { msgId -> - if (!deepLink.contains(NavigationNotificationIDKey)) { + if (!deepLink.contains(NavigationRouteParameter.NOTIFICATION_ID.key)) { deepLink += if (deepLink.contains("?")) { - "&$NavigationNotificationIDKey=$msgId" + "&${NavigationRouteParameter.NOTIFICATION_ID.key}=$msgId" } else { - "?$NavigationNotificationIDKey=$msgId" + "?${NavigationRouteParameter.NOTIFICATION_ID.key}=$msgId" } } } intent.putExtra(NotificationManager.DEEP_LINK, deepLink) } + + lifecycleScope.launch { + combine( + MoreApplication.shared!!.credentialRepository.hasCredentials, + viewModel.registrationService.isLoading + ) { hasCredentials, isLoading -> + hasCredentials && !isLoading + }.collect { shouldNavigateToMain -> + if (shouldNavigateToMain) { + viewModel.openMainActivity(this@ContentActivity) + } + } + } + + setContent { ContentView(viewModel = viewModel) } @@ -54,16 +79,23 @@ class ContentActivity : ComponentActivity() { @Composable fun ContentView(viewModel: ContentViewModel) { - if (viewModel.hasCredentials.value) { - viewModel.openMainActivity(LocalContext.current) - } else { - MoreBackground(showBackButton = false, alertDialogModel = viewModel.alertDialogOpen.value) { - if (viewModel.loginViewScreenNr.intValue == 0) { - LoginView(model = viewModel.loginViewModel) - AppVersion() + val validLogin by viewModel.registrationService.validLoginModel.collectAsStateWithLifecycle() + val hasCredentials by MoreApplication.shared!!.credentialRepository.hasCredentials.collectAsStateWithLifecycle() + val credentialsLoaded by MoreApplication.shared!!.credentialRepository.credentialsLoaded.collectAsStateWithLifecycle() + val studyLoadingError by ViewManager.studyLoadingError.collectAsStateWithLifecycle() + + MoreBackground(showBackButton = false) { + if (credentialsLoaded && !hasCredentials) { + if (validLogin != null) { + ConsentView(viewModel.registrationService) } else { - ConsentView(model = viewModel.consentViewModel) + LoginView(viewModel.registrationService) + AppVersion() } + } else if (studyLoadingError) { + StudyLoadingErrorView() + } else { + StudyLoadingView() } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt index 954daf8ec..bae6dba98 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/ContentViewModel.kt @@ -12,56 +12,45 @@ package io.redlink.more.app.android.activities import android.app.Activity import android.content.Context -import android.net.Uri -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import io.github.aakira.napier.Napier import io.redlink.more.app.android.MoreApplication -import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.consent.ConsentViewModel -import io.redlink.more.app.android.activities.consent.ConsentViewModelListener -import io.redlink.more.app.android.activities.login.LoginViewModel -import io.redlink.more.app.android.activities.login.LoginViewModelListener import io.redlink.more.app.android.activities.main.MainActivity -import io.redlink.more.app.android.extensions.applicationId import io.redlink.more.app.android.extensions.showNewActivityAndClearStack -import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.workers.ScheduleUpdateWorker -import io.redlink.more.more_app_mutliplatform.AlertController -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.services.network.RegistrationService -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.registration.RegistrationService +import io.redlink.more.scopes.Scope +import io.redlink.more.services.notification.NotificationManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit -class ContentViewModel : ViewModel(), LoginViewModelListener, ConsentViewModelListener { - private val registrationService: RegistrationService by lazy { - RegistrationService( - MoreApplication.shared!! - ) - } +class ContentViewModel : ViewModel() { + val registrationService: RegistrationService = + RegistrationService(MoreApplication.shared!!) - val loginViewModel: LoginViewModel by lazy { LoginViewModel(registrationService, this) } - val consentViewModel: ConsentViewModel by lazy { ConsentViewModel(registrationService, this) } - - val hasCredentials = - mutableStateOf(MoreApplication.shared!!.credentialRepository.hasCredentials()) - val loginViewScreenNr = mutableIntStateOf(0) + val hasCredentials = mutableStateOf(false) val alertDialogOpen = mutableStateOf(null) init { NavigationScreen.createDeepLinksForAllRoutes() viewModelScope.launch(Dispatchers.IO) { + MoreApplication.shared!!.credentialRepository.hasCredentials.collect { + hasCredentials.value = it + } + } + viewModelScope.launch(Dispatchers.Main.immediate) { AlertController.alertDialogModel.collect { withContext(Dispatchers.Main) { alertDialogOpen.value = it @@ -70,57 +59,51 @@ class ContentViewModel : ViewModel(), LoginViewModelListener, ConsentViewModelLi } } - fun openMainActivity(context: Context) { - (context as? Activity)?.let { - val workManager = WorkManager.getInstance(context) - val worker = - PeriodicWorkRequestBuilder(15L, TimeUnit.MINUTES).build() - workManager.enqueueUniquePeriodicWork( - ScheduleUpdateWorker.WORKER_TAG, - ExistingPeriodicWorkPolicy.KEEP, - worker - ) + suspend fun openMainActivity(context: Context) { + (context as? Activity)?.let { activity -> + schedulePeriodicWorker(activity) + handleDeepLinkAndOpenMain(activity) + } + } - (it.intent.getStringExtra("deepLink") ?: it.intent.data?.toString())?.let { deepLink -> - Scope.launch { - MoreApplication.shared!!.deeplinkManager.modifyDeepLink( - deepLink, stringResource(R.string.app_scheme), applicationId - ).firstOrNull()?.let { modifiedDeepLink -> - val link = Uri.parse(modifiedDeepLink) - it.intent.getStringExtra(NotificationManager.MSG_ID) - ?.let { notificationId -> - MoreApplication.shared!!.notificationManager.handleNotificationInteraction( - notificationId, - modifiedDeepLink - ) - } - withContext(Dispatchers.Main) { - it.intent.data = link - openMain(it) - } + private fun schedulePeriodicWorker(activity: Activity) { + val workManager = WorkManager.getInstance(activity) + val worker = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + workManager.enqueueUniquePeriodicWork( + ScheduleUpdateWorker.WORKER_TAG, + ExistingPeriodicWorkPolicy.KEEP, + worker + ) + } - } ?: run { - withContext(Dispatchers.Main) { - it.intent.getStringExtra(NotificationManager.MSG_ID)?.let { msgId -> - val route = - ContentActivity.DEEPLINK + NavigationScreen.NOTIFICATIONS.routeWithParameters(); - val link = Uri.parse(route) - it.intent.data = link - } - openMain(it) - } - } - } - } ?: run { - it.intent.getStringExtra(NotificationManager.MSG_ID)?.let { msgId -> - val route = - ContentActivity.DEEPLINK + NavigationScreen.NOTIFICATIONS.routeWithParameters(); - val link = Uri.parse(route) - it.intent.data = link - } - openMain(it) + private suspend fun handleDeepLinkAndOpenMain(activity: Activity) { + val rawDeepLink = + activity.intent.getStringExtra("deepLink") ?: activity.intent.data?.toString() + Napier.d { "Attached deeplink: $rawDeepLink" } + val notificationId = activity.intent.getStringExtra(NotificationManager.MSG_ID) + val sharedInstance = MoreApplication.shared + ?: throw IllegalStateException("MoreApplication.shared is not initialized") + val modifiedDeepLink = rawDeepLink?.let { link -> + sharedInstance.deeplinkManager + .modifyDeepLink(link) + .firstOrNull() + } ?: notificationId?.let { + sharedInstance.deeplinkManager.getNotificationViewDeepLink( + it + ).firstOrNull() + } + + notificationId?.let { + Scope.launch { + sharedInstance.notificationManager.markNotificationAsRead(it) } + } + + Napier.d { "Modified deeplink: $modifiedDeepLink" } + withContext(Dispatchers.Main) { + activity.intent.data = modifiedDeepLink?.route?.toUri() + openMain(activity) } } @@ -133,31 +116,4 @@ class ContentViewModel : ViewModel(), LoginViewModelListener, ConsentViewModelLi ) } } - - private fun showLoginView() { - viewModelScope.launch(Dispatchers.Main) { - loginViewScreenNr.intValue = 0 - registrationService.reset() - } - } - - private fun showConsentView() { - viewModelScope.launch(Dispatchers.Main) { - loginViewScreenNr.intValue = 1 - } - } - - override fun tokenIsValid(study: Study) { - this.consentViewModel.setConsentInfo(study.consentInfo) - this.consentViewModel.buildConsentModel() - showConsentView() - } - - override fun credentialsStored() { - hasCredentials.value = true - } - - override fun decline() { - showLoginView() - } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt index 51c49a386..29e3135e1 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt @@ -20,8 +20,8 @@ import androidx.navigation.navDeepLink import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.LimeSurveyType -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.SimpleQuestionType +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.navigation.model.NavigationRouteParameter data class NavigationParameter( val type: NavType<*>, @@ -30,59 +30,97 @@ data class NavigationParameter( ) enum class NavigationScreen( - private val route: String, + private val route: NavigationRoute, val parameters: Map = emptyMap(), @StringRes val stringResource: Int ) { - DASHBOARD("dashboard", stringResource = R.string.nav_dashboard), - NOTIFICATIONS("notifications", stringResource = R.string.nav_notifications), - INFO("information", stringResource = R.string.nav_info), - SETTINGS("settings", stringResource = R.string.nav_settings), + DASHBOARD(NavigationRoute.DASHBOARD, stringResource = R.string.nav_dashboard), + NOTIFICATIONS(NavigationRoute.NOTIFICATIONS, stringResource = R.string.nav_notifications), + INFO(NavigationRoute.INFO, stringResource = R.string.nav_info), + SETTINGS(NavigationRoute.SETTINGS, stringResource = R.string.nav_settings), SCHEDULE_DETAILS( - "task-details", parameters = mapOf( - "scheduleId" to NavigationParameter(type = NavType.StringType, "") + NavigationRoute.SCHEDULE_DETAILS, parameters = mapOf( + NavigationRouteParameter.SCHEDULE_ID.key to NavigationParameter( + type = NavType.StringType, + "" + ) ), stringResource = R.string.nav_task_detail ), OBSERVATION_DETAILS( - "observation-details", - mapOf("observationId" to NavigationParameter(type = NavType.StringType, "")), + NavigationRoute.OBSERVATION_DETAILS, + mapOf( + NavigationRouteParameter.OBSERVATION_ID.key to NavigationParameter( + type = NavType.StringType, + "" + ) + ), stringResource = R.string.nav_observation_detail ), - STUDY_DETAILS("study-details", stringResource = R.string.nav_study_details), + STUDY_DETAILS(NavigationRoute.STUDY_DETAILS, stringResource = R.string.nav_study_details), OBSERVATION_FILTER( - "observation-filter", mapOf( - "scheduleListType" to NavigationParameter(type = NavType.StringType, "") + NavigationRoute.OBSERVATION_FILTER, mapOf( + NavigationRouteParameter.SCHEDULE_LIST_TYPE.key to NavigationParameter( + type = NavType.StringType, + "" + ) ), stringResource = R.string.nav_observation_filter ), - SIMPLE_QUESTION( - SimpleQuestionType().observationType, + QUESTION( + NavigationRoute.QUESTION, parameters = mapOf( - "scheduleId" to NavigationParameter(type = NavType.StringType, ""), - "observationId" to NavigationParameter(type = NavType.StringType, "") - ), stringResource = R.string.nav_simple_question + NavigationRouteParameter.SCHEDULE_ID.key to NavigationParameter( + type = NavType.StringType, + "" + ), + NavigationRouteParameter.OBSERVATION_ID.key to NavigationParameter( + type = NavType.StringType, + "" + ) + ), stringResource = R.string.nav_question ), QUESTIONNAIRE_RESPONSE( - "${SimpleQuestionType().observationType}_response", - stringResource = R.string.nav_simple_question + NavigationRoute.QUESTIONNAIRE_RESPONSE, + stringResource = R.string.nav_question + ), + BLUETOOTH_CONNECTION( + NavigationRoute.BLUETOOTH_CONNECTION, + stringResource = R.string.more_ble_view_title + ), + RUNNING_SCHEDULES( + NavigationRoute.RUNNING_SCHEDULES, + stringResource = R.string.nav_running_schedules ), - BLUETOOTH_CONNECTION("devices", stringResource = R.string.more_ble_view_title), - RUNNING_SCHEDULES("running-observations", stringResource = R.string.nav_running_schedules), - COMPLETED_SCHEDULES("past-observations", stringResource = R.string.nav_completed_schedules), - NOTIFICATION_FILTER("notification-filter", stringResource = R.string.nav_notification_filter), - LEAVE_STUDY("leave-study", stringResource = R.string.nav_leave_study), + COMPLETED_SCHEDULES( + NavigationRoute.COMPLETED_SCHEDULES, + stringResource = R.string.nav_completed_schedules + ), + NOTIFICATION_FILTER( + NavigationRoute.NOTIFICATION_FILTER, + stringResource = R.string.nav_notification_filter + ), + LEAVE_STUDY(NavigationRoute.LEAVE_STUDY, stringResource = R.string.nav_leave_study), LEAVE_STUDY_CONFIRM( - "leave-study-confirmation", + NavigationRoute.LEAVE_STUDY_CONFIRM, stringResource = R.string.nav_leave_study_confirm ), LIMESURVEY( - LimeSurveyType().observationType, mapOf( - "scheduleId" to NavigationParameter(NavType.StringType, ""), - "observationId" to NavigationParameter( + NavigationRoute.LIMESURVEY, mapOf( + NavigationRouteParameter.SCHEDULE_ID.key to NavigationParameter(NavType.StringType, ""), + NavigationRouteParameter.OBSERVATION_ID.key to NavigationParameter( NavType.StringType, "" ) ), stringResource = R.string.nav_limesurvey ), - OBSERVATION_ERRORS("observation-errors", stringResource = R.string.nav_observation_errors); + GARMIN_CONNECT( + NavigationRoute.GARMIN_CONNECT, + parameters = mapOf(), + stringResource = R.string.nav_garmin_connect + ), + + OBSERVATION_ERRORS( + NavigationRoute.OBSERVATION_ERRORS, + stringResource = R.string.nav_observation_errors + ); private var cachedNavArguments: List? = null private var cachedRoute: String? = null @@ -95,7 +133,7 @@ enum class NavigationScreen( fun routeWithParameters(): String { if (cachedRoute == null) { - var fullRoute = route + var fullRoute = route.route val params = allParam() if (params.isNotEmpty()) { fullRoute += "?" @@ -115,7 +153,7 @@ enum class NavigationScreen( vararg routeParameters: Pair, notificationId: String? = null ): String { - var fullRoute = route + var fullRoute = route.route val routeMap = routeParameters.toMap() val params = allParam() val queryParams = mutableListOf() @@ -129,7 +167,7 @@ enum class NavigationScreen( } notificationId?.let { - queryParams.add("$NavigationNotificationIDKey=$it") + queryParams.add("${NavigationRouteParameter.NOTIFICATION_ID.key}=$it") } if (queryParams.isNotEmpty()) { @@ -165,12 +203,15 @@ enum class NavigationScreen( } companion object { - const val NavigationNotificationIDKey = "notificationId" - private val globalParameters = - mapOf(NavigationNotificationIDKey to NavigationParameter(NavType.StringType, "")) + mapOf( + NavigationRouteParameter.NOTIFICATION_ID.key to NavigationParameter( + NavType.StringType, + "" + ) + ) - fun byRoute(route: String) = entries.firstOrNull { it.route == route } + fun byRoute(route: String) = entries.firstOrNull { it.route.route == route } fun allDeepLinks(deepLinkHost: String) = entries.flatMap { it.createDeepLinkRoute(deepLinkHost).mapNotNull { it.uriPattern } } diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/extensions/StringExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/OnAppearDisappear.kt similarity index 54% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/extensions/StringExtension.kt rename to androidApp/src/main/java/io/redlink/more/app/android/activities/OnAppearDisappear.kt index a8669cd84..b9f10734a 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/extensions/StringExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/OnAppearDisappear.kt @@ -8,14 +8,23 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.extensions -import java.math.BigInteger -import java.security.MessageDigest +package io.redlink.more.app.android.activities -fun String.toMD5(): String { - val md = MessageDigest.getInstance("MD5") - return BigInteger(1, md.digest(this.toByteArray())) - .toString(16) - .padStart(32, '0') +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect + +@Composable +fun OnAppearDisappear( + onAppear: () -> Unit, + onDisappear: () -> Unit, + content: @Composable () -> Unit +) { + DisposableEffect(Unit) { + onAppear() + onDispose { + onDisappear() + } + } + content() } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt index 36cd4e67a..42eece60d 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt @@ -15,6 +15,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,6 +33,7 @@ import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BluetoothDisabled import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -49,7 +51,7 @@ import io.redlink.more.app.android.shared_composables.MoreDivider import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.shared_composables.SmallTitle import io.redlink.more.app.android.shared_composables.Title -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors class BLEConnectionActivity : ComponentActivity() { val viewModel = BluetoothViewModel() @@ -103,8 +105,7 @@ fun LoginBLESetupView(viewModel: BluetoothViewModel, showDescrPart2: Boolean) { showBackButton = true, onBackButtonClick = { (context as? Activity)?.finish() - }, - alertDialogModel = viewModel.alertDialogOpen.value + } ) { LazyColumn { item { @@ -116,7 +117,11 @@ fun LoginBLESetupView(viewModel: BluetoothViewModel, showDescrPart2: Boolean) { Spacer(modifier = Modifier.height(12.dp)) } itemsIndexed(viewModel.neededDevices) { _, item -> - SmallTitle(text = "- $item", fontSize = 16.sp, color = MoreColors.PrimaryDark) + SmallTitle( + text = "- $item", + fontSize = 16.sp, + color = MoreColors.PrimaryDark + ) } if (showDescrPart2) { item { @@ -213,7 +218,10 @@ fun LoginBLESetupView(viewModel: BluetoothViewModel, showDescrPart2: Boolean) { modifier = Modifier .fillMaxWidth() .height(60.dp) - .clickable { + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { viewModel.connectToDevice(device) } ) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt index e1cd4d4a9..65e436f34 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt @@ -17,23 +17,24 @@ import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.app.android.services.sensorsListener.GPSStateListener -import io.redlink.more.more_app_mutliplatform.AlertController -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager -import io.redlink.more.more_app_mutliplatform.viewModels.startupConnection.CoreBluetoothViewModel +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.scopes.Scope +import io.redlink.more.services.bluetooth.BluetoothStateManagement +import io.redlink.more.viewModels.ViewManager +import io.redlink.more.viewModels.startupConnection.CoreBluetoothViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BluetoothViewModel : ViewModel() { - private val coreBluetoothViewModel = CoreBluetoothViewModel( + val coreViewModel = CoreBluetoothViewModel( MoreApplication.shared!!.observationFactory, MoreApplication.shared!!.bluetoothController ) - val discoveredDevices = mutableStateListOf() - val connectedDevices = mutableStateListOf() + val discoveredDevices = mutableStateListOf() + val connectedDevices = mutableStateListOf() val connectingDevices = mutableStateListOf() val isScanning = mutableStateOf(false) val bluetoothPowerState = mutableStateOf(BluetoothStateListener.bluetoothEnabled.value) @@ -59,7 +60,7 @@ class BluetoothViewModel : ViewModel() { } } viewModelScope.launch(Dispatchers.IO) { - BluetoothDeviceManager.discoveredDevices.collect { + BluetoothStateManagement.discoveredDevices.collect { withContext(Dispatchers.Main) { discoveredDevices.clear() discoveredDevices.addAll(it) @@ -68,7 +69,7 @@ class BluetoothViewModel : ViewModel() { } viewModelScope.launch(Dispatchers.IO) { - BluetoothDeviceManager.connectedDevices.collect { + BluetoothStateManagement.connectedDevices.collect { withContext(Dispatchers.Main) { connectedDevices.clear() connectedDevices.addAll(it) @@ -77,7 +78,7 @@ class BluetoothViewModel : ViewModel() { } viewModelScope.launch(Dispatchers.IO) { - coreBluetoothViewModel.coreBluetooth.isScanning.collect { + BluetoothStateManagement.scanning.collect { withContext(Dispatchers.Main) { isScanning.value = it } @@ -85,7 +86,7 @@ class BluetoothViewModel : ViewModel() { } viewModelScope.launch(Dispatchers.IO) { - coreBluetoothViewModel.devicesNeededToConnectTo.collect { + coreViewModel.devicesNeededToConnectTo.collect { withContext(Dispatchers.Main) { neededDevices.clear() neededDevices.addAll(MoreApplication.shared!!.observationFactory.bleDevicesNeeded()) @@ -101,7 +102,7 @@ class BluetoothViewModel : ViewModel() { } viewModelScope.launch(Dispatchers.IO) { - BluetoothDeviceManager.devicesCurrentlyConnecting.collect { + BluetoothStateManagement.devicesCurrentlyConnecting.collect { withContext(Dispatchers.Main) { connectingDevices.clear() connectingDevices.addAll(it.mapNotNull { it.address }) @@ -112,19 +113,21 @@ class BluetoothViewModel : ViewModel() { fun viewDidAppear() { ViewManager.bleViewOpen(true) - coreBluetoothViewModel.viewDidAppear() + coreViewModel.viewOpened() } fun viewDidDisappear() { - coreBluetoothViewModel.viewDidDisappear() ViewManager.bleViewOpen(false) + coreViewModel.viewClosed() } - fun connectToDevice(device: BluetoothDevice) { - coreBluetoothViewModel.connectToDevice(device) + fun connectToDevice(device: BluetoothDeviceEntity) { + Scope.launch { + coreViewModel.connectToDevice(device) + } } - fun disconnectFromDevice(device: BluetoothDevice) { - coreBluetoothViewModel.disconnectFromDevice(device) + fun disconnectFromDevice(device: BluetoothDeviceEntity) { + coreViewModel.disconnectFromDevice(device) } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt index 04019b2f3..777213031 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt @@ -17,51 +17,46 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.dashboard.schedule.list.ScheduleListView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel import io.redlink.more.app.android.shared_composables.ScheduleListHeader @Composable -fun CompletedSchedulesView(viewModel: ScheduleViewModel, navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.COMPLETED_SCHEDULES.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - ScheduleListHeader( - viewModel = viewModel, - navController = navController, - taskCompletionBarViewModel = taskCompletionBarViewModel - ) - Spacer(modifier = Modifier.height(10.dp)) - Column { - ScheduleListView( +fun CompletedSchedulesView( + viewModel: ScheduleViewModel, + navController: NavController, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = viewModel, navController = navController, - routeString = NavigationScreen.COMPLETED_SCHEDULES.routeWithParameters(), - scheduleViewModel = viewModel, - showButton = false + taskCompletionBarViewModel = taskCompletionBarViewModel ) + Spacer(modifier = Modifier.height(10.dp)) + Column { + ScheduleListView( + navController = navController, + scheduleViewModel = viewModel, + showButton = false + ) + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt index 640549064..0505250ad 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentView.kt @@ -21,61 +21,78 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.consent.composables.ConsentButtons import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.Accordion import io.redlink.more.app.android.shared_composables.AccordionReadMore -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.registration.RegistrationService @Composable -fun ConsentView(model: ConsentViewModel) { - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.9f) - ) { - item { - Text( - text = model.permissionModel.value.studyTitle, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = MoreColors.Primary, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.padding(8.dp)) - AccordionReadMore( - title = getStringResource(id = R.string.participant_information), - description = model.permissionModel.value.studyParticipantInfo, +fun ConsentView(registrationService: RegistrationService) { + val model = remember { ConsentViewModel(registrationService) } + val permissions by model.coreModel.permissions.collectAsStateWithLifecycle() + permissions?.let { permissionModel -> + OnAppearDisappear( + { model.coreModel.viewDidAppear() }, + { model.coreModel.viewDidDisappear() }) { + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .fillMaxWidth() - ) - } - items(model.permissionModel.value.consentInfo) { consentInfo -> - Accordion( - title = if (model.permissionModel.value.consentInfo.indexOf(consentInfo) == 0) model.permissionModel.value.studyTitle else consentInfo.title, description = consentInfo.info, - hasCheck = true, hasPreview = (model.permissionModel.value.consentInfo.indexOf(consentInfo) == 0) - ) - } - item { - Spacer(modifier = Modifier.height(40.dp)) - } - - item { - Box( - contentAlignment = Alignment.BottomCenter, - modifier = Modifier - .padding(bottom = 10.dp) + .fillMaxHeight() + .fillMaxWidth(0.9f) ) { - ConsentButtons(model = model) + item { + Text( + text = permissionModel.studyTitle, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MoreColors.Primary, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.padding(8.dp)) + AccordionReadMore( + title = getStringResource(id = R.string.participant_information), + description = permissionModel.studyParticipantInfo, + modifier = Modifier + .fillMaxWidth() + ) + } + items(permissionModel.consentInfo) { consentInfo -> + Accordion( + title = if (permissionModel.consentInfo.indexOf(consentInfo) == 0) + permissionModel.studyTitle + else + consentInfo.title, + description = consentInfo.info, + hasCheck = true, + hasPreview = (permissionModel.consentInfo.indexOf(consentInfo) == 0) + ) + } + item { + Spacer(modifier = Modifier.height(40.dp)) + } + + item { + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .padding(bottom = 10.dp) + ) { + ConsentButtons(model = model) + } + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt index fa2f7b94e..b953b30de 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/ConsentViewModel.kt @@ -11,126 +11,67 @@ package io.redlink.more.app.android.activities.consent import android.content.Context -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getSecureID import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.more_app_mutliplatform.AlertController -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.models.PermissionModel -import io.redlink.more.more_app_mutliplatform.services.extensions.toMD5 -import io.redlink.more.more_app_mutliplatform.services.network.RegistrationService -import io.redlink.more.more_app_mutliplatform.viewModels.permission.CorePermissionViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -interface ConsentViewModelListener { - fun credentialsStored() - fun decline() -} +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.registration.RegistrationService +import io.redlink.more.viewModels.permission.CoreConsentViewModel class ConsentViewModel( - registrationService: RegistrationService, - private val consentViewModelListener: ConsentViewModelListener + val registrationService: RegistrationService ) : ViewModel() { - private val coreModel = - CorePermissionViewModel(registrationService, stringResource(R.string.consent_information)) - private var consentInfo: String? = null - - val permissionModel = - mutableStateOf( - PermissionModel( - "Title", - "Participation Info", - "Study Consent Info", - emptyList() - ) - ) - val loading = mutableStateOf(false) - val error = mutableStateOf(null) - val permissions = mutableSetOf() - - init { - viewModelScope.launch(Dispatchers.IO) { - coreModel.permissionModel.collect { - withContext(Dispatchers.Main) { - permissionModel.value = it - permissions.addAll(MoreApplication.shared!!.observationFactory.studySensorPermissions()) - } - } - } - - viewModelScope.launch(Dispatchers.IO) { - coreModel.loadingFlow.collect { - withContext(Dispatchers.Main) { - loading.value = it - } - } - } - } - - fun setConsentInfo(info: String) { - this.consentInfo = info - } + val coreModel = + CoreConsentViewModel(registrationService, stringResource(R.string.consent_information)) fun acceptConsent(context: Context) { - consentInfo?.let { info -> - getSecureID(context)?.let { uniqueDeviceId -> - coreModel.acceptConsent(info.toMD5(), uniqueDeviceId, - onSuccess = { - consentViewModelListener.credentialsStored() - MoreApplication.shared!!.newLogin() - }, onError = { - error.value = it?.message - }) - } + getSecureID(context)?.let { uniqueDeviceId -> + registrationService.acceptConsent(uniqueDeviceId) } } - fun openPermissionDeniedAlertDialog(context: Context) { - AlertController.openAlertDialog(AlertDialogModel( - title = stringResource(R.string.required_permissions_not_granted_title), - message = stringResource(R.string.required_permission_not_granted_message), - positiveTitle = stringResource(R.string.proceed_to_settings_button), - negativeTitle = stringResource(R.string.proceed_without_granting_button), - onPositive = { - MoreApplication.openSettings.value = true - AlertController.closeAlertDialog() - }, - onNegative = { - acceptConsent(context) - AlertController.closeAlertDialog() - } - )) + fun openPermissionDeniedAlertDialog(context: Context, missingPermissions: List = emptyList()) { + var message = stringResource(R.string.required_permission_not_granted_message) + if (missingPermissions.isNotEmpty()) { + message += "\n\n" + stringResource(R.string.missing_permissions_label) + ": " + missingPermissions.joinToString(", ") + } + AlertController.openAlertDialog( + AlertDialogModel( + title = StringDesc.Raw(stringResource(R.string.required_permissions_not_granted_title)), + message = StringDesc.Raw(message), + confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), + cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), + onConfirm = { + MoreApplication.openSettings.value = true + }, + onDecline = { + acceptConsent(context) + } + )) } fun openNotificationPermissionDeniedAlertDialog(context: Context) { - AlertController.openAlertDialog(AlertDialogModel( - title = stringResource(R.string.notification_permission_not_granted_title), - message = stringResource(R.string.notification_permission_not_granted_message), - positiveTitle = stringResource(R.string.proceed_to_settings_button), - negativeTitle = stringResource(R.string.proceed_without_granting_button), - onPositive = { - MoreApplication.openSettings.value = true - AlertController.closeAlertDialog() - }, - onNegative = { - acceptConsent(context) - AlertController.closeAlertDialog() - } - )) + AlertController.openAlertDialog( + AlertDialogModel( + title = StringDesc.Raw(stringResource(R.string.notification_permission_not_granted_title)), + message = StringDesc.Raw(stringResource(R.string.notification_permission_not_granted_message)), + confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), + cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), + onConfirm = { + MoreApplication.openSettings.value = true + }, + onDecline = { + acceptConsent(context) + } + )) } fun decline() { - coreModel.declineConsent() - consentViewModelListener.decline() - } - - fun buildConsentModel() { - coreModel.buildConsentModel() + registrationService.declineConsent() } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt index 22e83377e..61f8ff4c4 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/consent/composables/ConsentButtons.kt @@ -13,7 +13,6 @@ package io.redlink.more.app.android.activities.consent.composables import android.Manifest import android.app.AlertDialog import android.content.Context -import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult @@ -29,19 +28,26 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.aakira.napier.Napier +import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.consent.ConsentViewModel import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.observations.PermissionUtils +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.logging.event +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.observationTypes.AppUsageObservationType @Composable fun ConsentButtons(model: ConsentViewModel) { + val isLoading by model.registrationService.isLoading.collectAsStateWithLifecycle() val context = LocalContext.current val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -61,17 +67,19 @@ fun ConsentButtons(model: ConsentViewModel) { } } - val anyPermissionDenied = mutablePermissionMap.values.any { !it } - - if (anyPermissionDenied) { - model.openPermissionDeniedAlertDialog(context) + val deniedPermissions = mutablePermissionMap.filter { !it.value }.keys + if (deniedPermissions.isNotEmpty()) { + model.openPermissionDeniedAlertDialog( + context, + deniedPermissions.map { PermissionUtils.getPermissionLabel(context, it) } + ) } else { model.acceptConsent(context) } } - if (!model.loading.value) { + if (!isLoading) { Column( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally, @@ -80,6 +88,7 @@ fun ConsentButtons(model: ConsentViewModel) { ) { Button( onClick = { + Napier.event(LogEvent.BUTTON_PRESS, "Consent approved") checkAndRequestPermissions(context, launcher, model) }, colors = ButtonDefaults @@ -87,7 +96,6 @@ fun ConsentButtons(model: ConsentViewModel) { backgroundColor = MoreColors.Primary, contentColor = MoreColors.White ), - enabled = !model.loading.value, modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) @@ -98,6 +106,7 @@ fun ConsentButtons(model: ConsentViewModel) { Button( onClick = { + Napier.event(LogEvent.BUTTON_PRESS, "Consent declined") model.decline() }, colors = ButtonDefaults @@ -105,7 +114,6 @@ fun ConsentButtons(model: ConsentViewModel) { backgroundColor = MoreColors.Important, contentColor = MoreColors.White ), - enabled = !model.loading.value, modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) @@ -133,17 +141,29 @@ fun checkAndRequestPermissions( model: ConsentViewModel, extraPermissions: Set = emptySet() ) { - val permissions = model.permissions + val permissions = + MoreApplication.shared!!.observationFactory.studySensorPermissions() + .toMutableSet() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissions.add(Manifest.permission.POST_NOTIFICATIONS) } + permissions.addAll(extraPermissions) + permissions.addAll( + MoreApplication.shared?.observationFactory?.studySensorPermissions() + ?: emptySet() + ) + + permissions.removeAll(AppUsageObservationType().sensorPermissions) + val hasBackgroundLocationPermission = permissions.contains(Manifest.permission.ACCESS_BACKGROUND_LOCATION) if (hasBackgroundLocationPermission) { permissions.remove(Manifest.permission.ACCESS_BACKGROUND_LOCATION) } + if (hasBackgroundLocationPermission) { checkPermissionForBackgroundLocationAccess(context, launcher, model) } else { @@ -157,11 +177,7 @@ fun checkPermissions( permissions: Set, model: ConsentViewModel, ): Boolean { - return if ( - !permissions.all { - ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED - } - ) { + return if (!PermissionUtils.hasAllPermissions(permissions, context)) { launcher.launch(permissions.toTypedArray()) false } else { @@ -175,10 +191,10 @@ fun checkPermissionForBackgroundLocationAccess( launcher: ManagedActivityResultLauncher, Map>, model: ConsentViewModel, ) { - if (ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_BACKGROUND_LOCATION - ) == PackageManager.PERMISSION_GRANTED + if (PermissionUtils.hasAllPermissions( + setOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + context + ) ) return AlertDialog.Builder(context) @@ -199,4 +215,4 @@ fun checkPermissionForBackgroundLocationAccess( } .create() .show() -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt index f088a0aa1..5c8561084 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardView.kt @@ -11,70 +11,50 @@ package io.redlink.more.app.android.activities.dashboard import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear +import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.dashboard.schedule.list.ScheduleListView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel -import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.ScheduleListHeader - @Composable -fun DashboardView(navController: NavController, viewModel: DashboardViewModel, taskCompletionBarViewModel: TaskCompletionBarViewModel) { -// PolarHeartRateObservation.scanForDevices() - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.DASHBOARD.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ){ - ScheduleListHeader( - viewModel = viewModel.scheduleViewModel, - navController = navController, - taskCompletionBarViewModel = taskCompletionBarViewModel - ) - Spacer(modifier = Modifier.height(10.dp)) - Column { - if (!viewModel.studyActive.value) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.8f) - .padding(top = 16.dp) - ){ - Text(text = getStringResource(id = R.string.more_dashboard_study_not_active)) - } - } else { - ScheduleListView(navController, NavigationScreen.DASHBOARD.routeWithParameters(), scheduleViewModel = viewModel.scheduleViewModel, showButton = true) +fun DashboardView( + navController: NavController, + scheduleViewModel: ScheduleViewModel, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { + OnAppearDisappear( + { scheduleViewModel.coreViewModel.viewOpened() }, + { scheduleViewModel.coreViewModel.viewClosed() }) { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = scheduleViewModel, + navController = navController, + taskCompletionBarViewModel = taskCompletionBarViewModel + ) + Spacer(modifier = Modifier.height(10.dp)) + Column { + ScheduleListView( + navController, + scheduleViewModel, + showButton = true + ) } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardViewModel.kt deleted file mode 100644 index 553d97b60..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardViewModel.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.app.android.activities.dashboard - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class DashboardViewModel( - val scheduleViewModel: ScheduleViewModel -): ViewModel() { - private val coreDashboardViewModel: CoreDashboardViewModel = CoreDashboardViewModel() - - var study: MutableState = mutableStateOf(StudySchema()) - val studyTitle = mutableStateOf("Study Title") - val studyActive = mutableStateOf(true) - - private val scope = CoroutineScope(Dispatchers.Default + Job()) - - init { - scope.launch { - coreDashboardViewModel.study.collect { - study.value = it - study.value?.let { study -> - studyTitle.value = study.studyTitle - } - } - } - } - - fun viewDidAppear() { - coreDashboardViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreDashboardViewModel.viewDidDisappear() - } -} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/Views.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/Views.kt deleted file mode 100644 index 485b30a5c..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/Views.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.app.android.activities.dashboard - -import io.redlink.more.app.android.R -import io.redlink.more.app.android.extensions.stringResource - -enum class Views(val tabPosition: Int, val tabText: String) { - SCHEDULE(tabPosition = 0, tabText = stringResource(R.string.more_main_tab_schedule)), - MODULES(tabPosition = 1, tabText = stringResource(R.string.more_main_tab_observations)); -} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/composables/FilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/composables/FilterView.kt index 1223deb0a..5abc5888d 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/composables/FilterView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/composables/FilterView.kt @@ -11,6 +11,7 @@ package io.redlink.more.app.android.activities.dashboard.composables import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -20,6 +21,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Tune import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -29,8 +31,9 @@ import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.activities.dashboard.filter.DashboardFilterViewModel import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.models.ScheduleListType +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.models.ScheduleListType +import io.redlink.more.navigation.model.NavigationRouteParameter @Composable fun FilterView( @@ -44,9 +47,15 @@ fun FilterView( modifier = Modifier .fillMaxWidth() .padding(vertical = 19.dp) - .clickable(onClick = { - navController.navigate(NavigationScreen.OBSERVATION_FILTER.navigationRoute("scheduleListType" to scheduleListType)) - }) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, onClick = { + navController.navigate( + NavigationScreen.OBSERVATION_FILTER.navigationRoute( + NavigationRouteParameter.SCHEDULE_LIST_TYPE.key to scheduleListType + ) + ) + }) ) { Text( text = model.getFilterString(), diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt index ea82b9c7f..dfa344609 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt @@ -11,6 +11,7 @@ package io.redlink.more.app.android.activities.dashboard.filter import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -23,117 +24,135 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Done import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.extensions.getStringResourceByName +import io.redlink.more.app.android.extensions.observationTypeToResource import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.shared_composables.HeaderDescription import io.redlink.more.app.android.shared_composables.HeaderTitle import io.redlink.more.app.android.shared_composables.IconInline import io.redlink.more.app.android.shared_composables.MoreDivider -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.viewModels.schedules.CoreScheduleViewModel @Composable -fun DashboardFilterView(viewModel: DashboardFilterViewModel) { - LazyColumn { - item { - HeaderTitle( - title = stringResource(R.string.more_filter_set_duration), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } +fun DashboardFilterView(coreScheduleViewModel: CoreScheduleViewModel) { + val viewModel = remember { DashboardFilterViewModel(coreScheduleViewModel.coreFilterModel) } + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { + LazyColumn { + item { + HeaderTitle( + title = stringResource(R.string.more_filter_set_duration), + modifier = Modifier.padding(top = 20.dp) + ) + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) + } - itemsIndexed(viewModel.currentDateFilter.entries.sortedBy { it.key.sortIndex }) { _, item -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (item.value) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.toggleDateFilter(item.key) }) - .padding(4.dp) + itemsIndexed(viewModel.currentDateFilter.entries.sortedBy { it.key.sortIndex }) { _, item -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = viewModel.dateFilters[item.key] - ?: getStringResource(id = R.string.more_filter_all), - color = if (item.value) MoreColors.Primary else MoreColors.Secondary - ) + if (item.value) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleDateFilter(item.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = viewModel.dateFilters[item.key] + ?: getStringResource(id = R.string.more_filter_all), + color = if (item.value) MoreColors.Primary else MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } - item { - HeaderTitle( - title = stringResource(R.string.more_filter_set_type), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } + item { + HeaderTitle( + title = stringResource(R.string.more_filter_set_type), + modifier = Modifier.padding(top = 20.dp) + ) + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) + } - item { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (!viewModel.typeFilterActive.value) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.clearTypeFilter() }) - .padding(4.dp) + item { + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = stringResource(R.string.more_filter_all), - color = if (!viewModel.typeFilterActive.value) MoreColors.Primary else MoreColors.Secondary - ) + if (!viewModel.typeFilterActive.value) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.clearTypeFilter() }) + .padding(4.dp) + ) { + HeaderDescription( + description = stringResource(R.string.more_filter_all), + color = if (!viewModel.typeFilterActive.value) MoreColors.Primary else MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } - items(viewModel.currentTypeFilter.entries.sortedBy { it.key }) { item -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if ( - item.value - ) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.toggleTypeFilter(item.key) }) - .padding(4.dp) + items(viewModel.currentTypeFilter.entries.sortedBy { it.key }) { item -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = item.key, - color = if (item.value) MoreColors.Primary else MoreColors.Secondary + if ( + item.value ) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleTypeFilter(item.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = getStringResourceByName(item.key.observationTypeToResource()), + color = if (item.value) MoreColors.Primary else MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt index 358cd6423..49a145970 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterViewModel.kt @@ -13,21 +13,24 @@ package io.redlink.more.app.android.activities.dashboard.filter import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.formatDateFilterString import io.redlink.more.app.android.extensions.getQuantityString import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.more_app_mutliplatform.models.DateFilterModel -import io.redlink.more.more_app_mutliplatform.util.Scope.launch -import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel +import io.redlink.more.models.DateFilterModel +import io.redlink.more.scopes.Scope.launch +import io.redlink.more.viewModels.dashboard.CoreDashboardFilterViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class DashboardFilterViewModel(private val coreViewModel: CoreDashboardFilterViewModel) { - val currentTypeFilter = mutableStateMapOf() +class DashboardFilterViewModel(val coreViewModel: CoreDashboardFilterViewModel) : + ViewModel() { + val currentTypeFilter = mutableStateMapOf() val currentDateFilter = mutableStateMapOf() - val dateFilters = DateFilterModel.values().associateWith { it.toString().formatDateFilterString() } + val dateFilters = + DateFilterModel.entries.associateWith { it.toString().formatDateFilterString() } val typeFilterActive: MutableState = mutableStateOf(coreViewModel.activeTypeFilter()) @@ -64,15 +67,16 @@ class DashboardFilterViewModel(private val coreViewModel: CoreDashboardFilterVie fun getFilterString(): String { var filterString = "" val typesAmount = coreViewModel.currentTypeFilter.value.filter { it.value }.size - val dateFilter = coreViewModel.currentDateFilter.value.filterValues { it }.keys.firstOrNull() ?: "" + val dateFilter = + coreViewModel.currentDateFilter.value.filterValues { it }.keys.firstOrNull() ?: "" if (coreViewModel.filterActive()) { - if(coreViewModel.activeDateFilter()) { + if (coreViewModel.activeDateFilter()) { filterString += dateFilter } - if(coreViewModel.activeTypeFilter()) { - if(filterString.isNotBlank()) + if (coreViewModel.activeTypeFilter()) { + if (filterString.isNotBlank()) filterString += ", " filterString += getQuantityString(R.plurals.filter_text, typesAmount, typesAmount) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/modules/ModuleView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/modules/ModuleView.kt deleted file mode 100644 index e9762071d..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/modules/ModuleView.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.app.android.activities.dashboard.modules - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import io.redlink.more.app.android.activities.dashboard.DashboardViewModel - -@Composable -fun ModuleView(model: DashboardViewModel) { - Column( - modifier = Modifier - .fillMaxSize() - ){ - Text( - text = "Here you will find modules", - modifier = Modifier - .fillMaxSize() - .weight(1f, true) - .padding(bottom = 425.dp) - ) - } -} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/ScheduleViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/ScheduleViewModel.kt index 0b5e04558..3faf1d3ee 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/ScheduleViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/ScheduleViewModel.kt @@ -11,95 +11,41 @@ package io.redlink.more.app.android.activities.dashboard.schedule import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication -import io.redlink.more.app.android.activities.dashboard.filter.DashboardFilterViewModel -import io.redlink.more.app.android.extensions.jvmLocalDate -import io.redlink.more.app.android.observations.HR.PolarHeartRateObservation -import io.redlink.more.more_app_mutliplatform.models.ScheduleListType -import io.redlink.more.more_app_mutliplatform.models.ScheduleModel -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.schedules.CoreScheduleViewModel +import io.redlink.more.models.ScheduleListType +import io.redlink.more.services.bluetooth.polar.PolarStates +import io.redlink.more.viewModels.dashboard.CoreDashboardFilterViewModel +import io.redlink.more.viewModels.schedules.CoreScheduleViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.time.LocalDate class ScheduleViewModel( val scheduleListType: ScheduleListType ) : ViewModel() { - private val coreDashboardFilterViewModel = CoreDashboardFilterViewModel() + private val coreDashboardFilterViewModel = + CoreDashboardFilterViewModel(MoreApplication.shared!!.repositories) - private val coreViewModel = CoreScheduleViewModel( + val coreViewModel = CoreScheduleViewModel( + MoreApplication.shared!!.repositories, MoreApplication.shared!!.dataRecorder, - coreFilterModel = coreDashboardFilterViewModel, - scheduleListType = scheduleListType + scheduleListType = scheduleListType, + coreFilterModel = coreDashboardFilterViewModel ) val polarHrReady: MutableState = mutableStateOf(false) - val schedulesByDate = mutableStateMapOf>() - val observationErrors = mutableStateMapOf>() - val observationErrorActions = mutableStateMapOf>() - - val filterModel = DashboardFilterViewModel(coreDashboardFilterViewModel) - - private val jobs = mutableListOf() - init { viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.observationFactory.observationErrors.collect { - val actions = it.mapValues { entry -> - entry.value.filter { it == Observation.ERROR_DEVICE_NOT_CONNECTED }.toSet() - } - val errors = it.mapValues { entry -> - entry.value.filter { it != Observation.ERROR_DEVICE_NOT_CONNECTED }.toSet() - } - withContext(Dispatchers.Main) { - observationErrors.clear() - observationErrors.putAll(errors) - observationErrorActions.clear() - observationErrorActions.putAll(actions) - } - } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - jobs.add(viewModelScope.launch { - coreViewModel.scheduleListState.collect { (added, removed, updated) -> - val idsToRemove = removed + updated.map { it.scheduleId }.toSet() - schedulesByDate.forEach { (date, schedules) -> - schedulesByDate[date] = schedules.filterNot { it.scheduleId in idsToRemove } - } - val schemasToAdd = mergeSchedules(added, updated) - schemasToAdd.groupBy { it.start.jvmLocalDate() }.forEach { (date, schedules) -> - schedulesByDate[date] = mergeSchedules( - schedules.toSet(), - schedulesByDate.getOrDefault(date, emptyList()).toSet() - ).sortedBy { it.start } - } - } - }) - jobs.add(viewModelScope.launch(Dispatchers.IO) { - PolarHeartRateObservation.hrReady.collect { + PolarStates.hrFeatureReady.collect { withContext(Dispatchers.Main) { polarHrReady.value = it } } - }) - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() - jobs.forEach { it.cancel() } - jobs.clear() + } } fun startObservation(scheduleId: String) { @@ -109,21 +55,4 @@ class ScheduleViewModel( fun pauseObservation(scheduleId: String) { coreViewModel.pause(scheduleId) } - - fun stopObservation(scheduleId: String) { - coreViewModel.stop(scheduleId) - } - - fun numberOfObservationErrors(): Int = observationErrors.values.flatten().toSet().count() - .let { if (it > 0) it else observationErrorActions.values.flatten().toSet().count() } - - - private fun mergeSchedules( - first: Set, - second: Set - ): Set { - val firstIds = first.map { it.scheduleId }.toSet() - val secondFiltered = second.filter { it.scheduleId !in firstIds } - return first + secondFiltered - } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListItem.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListItem.kt index e21335701..731680ab9 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListItem.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListItem.kt @@ -23,31 +23,35 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel +import io.redlink.more.app.android.activities.tasks.ObservationActionButton import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.extensions.getStringResourceByName import io.redlink.more.app.android.extensions.jvmLocalDateTime +import io.redlink.more.app.android.extensions.observationTypeToResource import io.redlink.more.app.android.shared_composables.BasicText -import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.shared_composables.SmallTitle import io.redlink.more.app.android.shared_composables.TimeframeHours -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.models.ScheduleModel -import io.redlink.more.more_app_mutliplatform.models.ScheduleState - +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.models.ScheduleModel +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.observationTypes.PolarVerityHeartRateType @Composable fun ScheduleListItem( navController: NavController, - scheduleModel: ScheduleModel, viewModel: ScheduleViewModel, - showButton: Boolean + showButton: Boolean, + scheduleModel: () -> ScheduleModel ) { + val observationErrors by viewModel.coreViewModel.observationErrors.collectAsStateWithLifecycle() Column( verticalArrangement = Arrangement.SpaceEvenly, modifier = Modifier @@ -59,8 +63,11 @@ fun ScheduleListItem( horizontalArrangement = Arrangement.Start, modifier = Modifier.fillMaxWidth() ) { - SmallTitle(text = scheduleModel.observationTitle, color = MoreColors.Primary) - if (scheduleModel.scheduleState == ScheduleState.RUNNING) { + SmallTitle( + text = scheduleModel().observationTitle, + color = MoreColors.Primary + ) + if (scheduleModel().scheduleState == ScheduleState.RUNNING) { CircularProgressIndicator( color = MoreColors.Approved, strokeWidth = 2.dp, @@ -75,12 +82,15 @@ fun ScheduleListItem( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - BasicText(text = scheduleModel.observationType, color = MoreColors.Secondary) + BasicText( + text = getStringResourceByName(scheduleModel().observationType.observationTypeToResource()), + color = MoreColors.Secondary + ) Row(horizontalArrangement = Arrangement.End) { - if ((viewModel.observationErrors[scheduleModel.observationType]?.count() + if ((observationErrors[scheduleModel().observationType]?.count() ?: 0) > 0 ) { - BasicText(text = "${viewModel.observationErrors[scheduleModel.observationType]?.count() ?: 0}") + BasicText(text = "${observationErrors[scheduleModel().observationType]?.count() ?: 0}") Icon( Icons.Default.Warning, contentDescription = null, @@ -97,48 +107,27 @@ fun ScheduleListItem( } TimeframeHours( - startTime = scheduleModel.start.jvmLocalDateTime(), - endTime = scheduleModel.end.jvmLocalDateTime(), + startTime = scheduleModel().start.jvmLocalDateTime(), + endTime = scheduleModel().end.jvmLocalDateTime(), modifier = Modifier.padding(vertical = 8.dp) ) - if (showButton && !scheduleModel.hidden) { - when (scheduleModel.observationType) { - "question-observation" -> { - SmallTextButton( - text = getStringResource(id = R.string.more_questionnaire_start), - enabled = scheduleModel.scheduleState.active() - ) { - navController.navigate( - NavigationScreen.SIMPLE_QUESTION.navigationRoute("scheduleId" to scheduleModel.scheduleId) - ) - } - } - - "lime-survey-observation" -> { - SmallTextButton( - text = getStringResource(id = R.string.more_limesurvey_start), - enabled = scheduleModel.scheduleState.active() - ) { - navController.navigate(NavigationScreen.LIMESURVEY.navigationRoute("scheduleId" to scheduleModel.scheduleId)) - } - } - - else -> { - SmallTextButton( - text = if (scheduleModel.scheduleState == ScheduleState.RUNNING) getStringResource( - id = R.string.more_observation_pause - ) else getStringResource( - id = R.string.more_observation_start - ), - enabled = scheduleModel.scheduleState.active() && (if (scheduleModel.observationType == "polar-verity-observation") viewModel.polarHrReady.value else true) - ) { - if (scheduleModel.scheduleState == ScheduleState.RUNNING) { - viewModel.pauseObservation(scheduleModel.scheduleId) - } else { - viewModel.startObservation(scheduleModel.scheduleId) - } - - } + if (showButton && !scheduleModel().hidden) { + ObservationActionButton( + navController, + scheduleModel().scheduleId, + scheduleModel().observationType, + scheduleModel().scheduleState, + additionalEnableCondition = if (PolarVerityHeartRateType( + setOf() + ).matches( + scheduleModel().observationType + ) + ) viewModel.polarHrReady.value else true + ) { + if (scheduleModel().scheduleState == ScheduleState.RUNNING) { + viewModel.pauseObservation(scheduleModel().scheduleId) + } else { + viewModel.startObservation(scheduleModel().scheduleId) } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListView.kt index bc842b6ed..506db7d27 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListView.kt @@ -13,43 +13,36 @@ package io.redlink.more.app.android.activities.dashboard.schedule.list import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.shared_composables.EmptyListView import io.redlink.more.app.android.shared_composables.ScheduleList - @Composable fun ScheduleListView( navController: NavController, - routeString: String, scheduleViewModel: ScheduleViewModel, showButton: Boolean ) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(routeString) - LaunchedEffect(route) { - scheduleViewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - scheduleViewModel.viewDidDisappear() - } - } + val scheduleList by scheduleViewModel.coreViewModel.schedulesByDate.collectAsStateWithLifecycle() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { - if (scheduleViewModel.schedulesByDate.isNotEmpty()) { + if (scheduleList.isNotEmpty()) { ScheduleList( navController = navController, viewModel = scheduleViewModel, showButton = showButton ) + } else { + EmptyListView(getStringResource(R.string.more_schedule_empty_list)) } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoItem.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoItem.kt index 0884d587c..832a70f6d 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoItem.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoItem.kt @@ -11,6 +11,7 @@ package io.redlink.more.app.android.activities.info import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -22,24 +23,35 @@ import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForwardIos import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.redlink.more.app.android.shared_composables.NavigationText -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun InfoItem(title: String, imageVector: ImageVector, contentDescription: String, onClick: () -> Unit = {}) { - Column(horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth()) { +fun InfoItem( + title: String, + imageVector: ImageVector, + contentDescription: String, + onClick: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth(0.9f) .height(60.dp) - .clickable { onClick() } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onClick() } ) { Row { Icon( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt index db75e1926..def58ea5e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoView.kt @@ -29,8 +29,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Watch import androidx.compose.material.icons.outlined.Autorenew import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,210 +37,207 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.bluetooth.BLEConnectionActivity import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.showNewActivity import io.redlink.more.app.android.shared_composables.AppVersion import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.SmallTitle -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun InfoView(navController: NavController, viewModel: InfoViewModel) { +fun InfoView(navController: NavController) { + val viewModel = remember { InfoViewModel() } + val studyInfo by viewModel.coreViewModel.studyModel.collectAsStateWithLifecycle() val context = LocalContext.current - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.INFO.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - LazyColumn(modifier = Modifier.fillMaxWidth()) { - item { - Divider() - InfoItem( - title = getStringResource(id = R.string.info_study_details), - imageVector = Icons.Default.Info, - contentDescription = getStringResource(id = R.string.info_study_details_desc), - onClick = { - navController.navigate(NavigationScreen.STUDY_DETAILS.routeWithParameters()) - } - ) - InfoItem( - title = getStringResource(id = R.string.info_running_observations), - imageVector = Icons.Outlined.Autorenew, - contentDescription = getStringResource(id = R.string.info_running_observations_desc), - onClick = { - navController.navigate(NavigationScreen.RUNNING_SCHEDULES.routeWithParameters()) - } - ) - InfoItem( - title = getStringResource(id = R.string.info_completed_observations), - imageVector = Icons.Default.Check, - contentDescription = getStringResource(id = R.string.info_completed_observations_desc), - onClick = { - navController.navigate(NavigationScreen.COMPLETED_SCHEDULES.routeWithParameters()) - } - ) - InfoItem( - title = NavigationScreen.BLUETOOTH_CONNECTION.stringRes(), - imageVector = Icons.Default.Watch, - contentDescription = getStringResource(id = R.string.more_ble_icon_description), - onClick = { - (context as? Activity)?.let { - showNewActivity(it, BLEConnectionActivity::class.java) + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + item { + Divider() + InfoItem( + title = getStringResource(id = R.string.info_study_details), + imageVector = Icons.Default.Info, + contentDescription = getStringResource(id = R.string.info_study_details_desc), + onClick = { + navController.navigate(NavigationScreen.STUDY_DETAILS.routeWithParameters()) } - } - ) - InfoItem( - title = getStringResource(id = R.string.info_settings), - imageVector = Icons.Default.Settings, - contentDescription = getStringResource(id = R.string.info_consent_settings_desc), - onClick = { - navController.navigate(NavigationScreen.SETTINGS.routeWithParameters()) - } - ) - InfoItem( - title = getStringResource(id = R.string.info_leave_study), - imageVector = Icons.Default.ExitToApp, - contentDescription = getStringResource(id = R.string.info_leave_study_desc), - onClick = { - navController.navigate(NavigationScreen.LEAVE_STUDY.routeWithParameters()) - } - ) - } - - item { - viewModel.model.value?.let { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - ) { + ) + InfoItem( + title = getStringResource(id = R.string.info_running_observations), + imageVector = Icons.Outlined.Autorenew, + contentDescription = getStringResource(id = R.string.info_running_observations_desc), + onClick = { + navController.navigate(NavigationScreen.RUNNING_SCHEDULES.routeWithParameters()) + } + ) + InfoItem( + title = getStringResource(id = R.string.info_completed_observations), + imageVector = Icons.Default.Check, + contentDescription = getStringResource(id = R.string.info_completed_observations_desc), + onClick = { + navController.navigate(NavigationScreen.COMPLETED_SCHEDULES.routeWithParameters()) + } + ) + InfoItem( + title = NavigationScreen.BLUETOOTH_CONNECTION.stringRes(), + imageVector = Icons.Default.Watch, + contentDescription = getStringResource(id = R.string.more_ble_icon_description), + onClick = { + (context as? Activity)?.let { + showNewActivity(it, BLEConnectionActivity::class.java) + } + } + ) + InfoItem( + title = getStringResource(id = R.string.info_settings), + imageVector = Icons.Default.Settings, + contentDescription = getStringResource(id = R.string.info_consent_settings_desc), + onClick = { + navController.navigate(NavigationScreen.SETTINGS.routeWithParameters()) + } + ) + InfoItem( + title = getStringResource(id = R.string.info_leave_study), + imageVector = Icons.Default.ExitToApp, + contentDescription = getStringResource(id = R.string.info_leave_study_desc), + onClick = { + navController.navigate(NavigationScreen.LEAVE_STUDY.routeWithParameters()) + } + ) + } + item { + studyInfo?.let { Column( - modifier = Modifier.fillMaxWidth(0.8f) + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() ) { - if (it.study.participantId != null || it.study.participantAlias != null) { - Spacer(modifier = Modifier.height(25.dp)) + + Column( + modifier = Modifier.fillMaxWidth(0.8f) + ) { + if (it.study.participantId != null || it.study.participantAlias != null) { + Spacer(modifier = Modifier.height(25.dp)) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + if (it.study.participantId != null) { + SmallTitle( + text = getStringResource(id = R.string.info_participant_credentials), + color = MoreColors.Secondary + ) + Spacer(modifier = Modifier.width(3.dp)) + SmallTitle( + text = it.study.participantId.toString(), + color = MoreColors.Secondary + ) + SmallTitle(text = ": ", color = MoreColors.Secondary) + } + if (it.study.participantAlias != null) { + BasicText( + text = it.study.participantAlias.toString(), + color = MoreColors.Secondary + ) + } + } + if (it.study.participantId != null || it.study.participantAlias != null) { + Spacer(modifier = Modifier.height(10.dp)) + Divider() + } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + + Column( + modifier = Modifier.fillMaxWidth(0.8f) ) { - if (it.study.participantId != null) { + Spacer(modifier = Modifier.height(10.dp)) + + if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { SmallTitle( - text = getStringResource(id = R.string.info_participant_credentials), - color = MoreColors.Secondary + text = getStringResource(id = R.string.info_contact_data), + color = MoreColors.PrimaryDark, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + textAlign = TextAlign.Center, + fontSize = 18.sp ) - Spacer(modifier = Modifier.width(3.dp)) + } + + + if (it.study.contactInstitute != null) { SmallTitle( - text = it.study.participantId.toString(), - color = MoreColors.Secondary + text = it.study.contactInstitute as String, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + textAlign = TextAlign.Center ) - SmallTitle(text = ": ", color = MoreColors.Secondary) } - if (it.study.participantAlias != null) { - BasicText( - text = it.study.participantAlias.toString(), - color = MoreColors.Secondary + + if (it.study.contactPerson != null) { + SmallTitle( + text = it.study.contactPerson as String, + modifier = Modifier.fillMaxWidth(), + color = MoreColors.Secondary, + textAlign = TextAlign.Center ) } - } - if (it.study.participantId != null || it.study.participantAlias != null) { - Spacer(modifier = Modifier.height(10.dp)) - Divider() - } - } - Column( - modifier = Modifier.fillMaxWidth(0.8f) - ) { - Spacer(modifier = Modifier.height(10.dp)) - - if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { - SmallTitle( - text = getStringResource(id = R.string.info_contact_data), - color = MoreColors.PrimaryDark, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp), - textAlign = TextAlign.Center, - fontSize = 18.sp - ) - } - - - if (it.study.contactInstitute != null) { - SmallTitle( - text = it.study.contactInstitute as String, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 5.dp), - textAlign = TextAlign.Center - ) - } - if (it.study.contactPerson != null) { - SmallTitle( - text = it.study.contactPerson as String, - modifier = Modifier.fillMaxWidth(), - color = MoreColors.Secondary, - textAlign = TextAlign.Center - ) - } + if (it.study.contactEmail != null) { + BasicText( + text = it.study.contactEmail as String, + fontSize = 14.sp, + modifier = Modifier.fillMaxWidth(), + color = MoreColors.Secondary, + textAlign = TextAlign.Center + ) + } + if (it.study.contactPhoneNumber != null) + BasicText( + text = it.study.contactPhoneNumber as String, + fontSize = 14.sp, + modifier = Modifier.fillMaxWidth(), + color = MoreColors.Secondary, + textAlign = TextAlign.Center + ) - if (it.study.contactEmail != null) { - BasicText( - text = it.study.contactEmail as String, - fontSize = 14.sp, - modifier = Modifier.fillMaxWidth(), - color = MoreColors.Secondary, - textAlign = TextAlign.Center - ) - } + if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { + Spacer(modifier = Modifier.height(10.dp)) + Divider() - if (it.study.contactPhoneNumber != null) - BasicText( - text = it.study.contactPhoneNumber as String, - fontSize = 14.sp, - modifier = Modifier.fillMaxWidth(), - color = MoreColors.Secondary, - textAlign = TextAlign.Center - ) + BasicText( + text = getStringResource(id = R.string.info_disclaimer), + color = MoreColors.Secondary, + textAlign = TextAlign.Center, + fontSize = 14.sp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) + } - if (it.study.contactPerson != null || it.study.contactEmail != null || it.study.contactPhoneNumber != null) { - Spacer(modifier = Modifier.height(10.dp)) - Divider() - - BasicText( - text = getStringResource(id = R.string.info_disclaimer), - color = MoreColors.Secondary, - textAlign = TextAlign.Center, - fontSize = 14.sp, - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp) - ) } - } } - } - - } - item { - AppVersion() + } + item { + AppVersion() + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt index b0540488e..d76465c25 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoViewModel.kt @@ -10,34 +10,12 @@ */ package io.redlink.more.app.android.activities.info -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.redlink.more.more_app_mutliplatform.models.StudyDetailsModel -import io.redlink.more.more_app_mutliplatform.viewModels.studydetails.CoreStudyDetailsViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.viewModels.studydetails.CoreStudyDetailsViewModel -class InfoViewModel: ViewModel() { - private val coreViewModel = CoreStudyDetailsViewModel() - val model = mutableStateOf(null) - - init { - viewModelScope.launch(Dispatchers.IO) { - coreViewModel.studyModel.collect{ - withContext(Dispatchers.Main) { - model.value = it - } - } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() - } +class InfoViewModel : ViewModel() { + val coreViewModel = + CoreStudyDetailsViewModel(MoreApplication.shared!!, NavigationRoute.INFO.viewIdentifier) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt index b8b73ec5c..1b1b5f26b 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyConfirmView.kt @@ -19,8 +19,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.ButtonDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +30,7 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.leaveStudy.LeaveStudyViewModel import io.redlink.more.app.android.extensions.Image import io.redlink.more.app.android.extensions.getStringResource @@ -39,97 +38,95 @@ import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.shared_composables.SmallTitle import io.redlink.more.app.android.shared_composables.Title -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.moreApproved -import io.redlink.more.app.android.ui.theme.moreImportant +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreApproved +import io.redlink.more.app.android.theme.moreImportant @Composable -fun LeaveStudyConfirmView(navController: NavController, viewModel: LeaveStudyViewModel) { +fun LeaveStudyConfirmView(navController: NavController) { val context = LocalContext.current + val viewModel = remember { LeaveStudyViewModel() } - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.LEAVE_STUDY_CONFIRM.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { Column( - modifier = Modifier.fillMaxWidth(0.8f), - verticalArrangement = Arrangement.Center + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - viewModel.permissionModel.value?.let { - Title(text = it.studyTitle, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) - } - Spacer(Modifier.height(80.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(0.8f), + verticalArrangement = Arrangement.Center ) { - Image( - id = R.drawable.warning_exclamation, - contentDescription = "More Logo", - modifier = Modifier - .fillMaxWidth(0.3f) - .aspectRatio(1.5f) - ) - } + viewModel.permissionModel.value?.let { + Title( + text = it.studyTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(80.dp)) - BasicText( - text = stringResource(id = R.string.more_settings_withdraw_statement_long), - color = MoreColors.TextDefault, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 16.sp - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Image( + id = R.drawable.warning_exclamation, + contentDescription = "More Logo", + modifier = Modifier + .fillMaxWidth(0.3f) + .aspectRatio(1.5f) + ) + } + Spacer(Modifier.height(18.dp)) - Spacer(Modifier.height(10.dp)) + BasicText( + text = stringResource(id = R.string.more_settings_withdraw_statement_long), + color = MoreColors.TextDefault, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 16.sp + ) - SmallTitle( - text = stringResource(id = R.string.more_settings_withdraw_question_confirm), - color = MoreColors.Primary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 16.sp - ) - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(10.dp)) - SmallTextButton( - text = stringResource(id = R.string.more_settings_continue), - buttonColors = ButtonDefaults.moreApproved(), - borderStroke = MoreColors.borderApproved() - ) { - navController.navigate(NavigationScreen.INFO.routeWithParameters()) - } + SmallTitle( + text = stringResource(id = R.string.more_settings_withdraw_question_confirm), + color = MoreColors.Primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 16.sp + ) + + Spacer(Modifier.height(32.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { SmallTextButton( - text = getStringResource(id = R.string.more_settings_resign_confirm), - buttonColors = ButtonDefaults.moreImportant(), - borderStroke = MoreColors.borderImportant() + text = stringResource(id = R.string.more_settings_continue), + buttonColors = ButtonDefaults.moreApproved(), + borderStroke = MoreColors.borderApproved() + ) { + navController.navigate(NavigationScreen.INFO.routeWithParameters()) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() ) { - viewModel.removeParticipation(context) + SmallTextButton( + text = getStringResource(id = R.string.more_settings_resign_confirm), + buttonColors = ButtonDefaults.moreImportant(), + borderStroke = MoreColors.borderImportant() + ) { + viewModel.removeParticipation(context) + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt index 7dca94d3d..67f213f70 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyView.kt @@ -20,8 +20,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.ButtonDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,94 +30,93 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.leaveStudy.LeaveStudyViewModel import io.redlink.more.app.android.extensions.Image import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.shared_composables.SmallTitle import io.redlink.more.app.android.shared_composables.Title -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.moreApproved -import io.redlink.more.app.android.ui.theme.moreImportant +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreApproved +import io.redlink.more.app.android.theme.moreImportant @Composable -fun LeaveStudyView(navController: NavController, viewModel: LeaveStudyViewModel) { +fun LeaveStudyView(navController: NavController) { val context = LocalContext.current + val viewModel = remember { LeaveStudyViewModel() } - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.LEAVE_STUDY.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { Column( - modifier = Modifier.fillMaxWidth(0.8f), - verticalArrangement = Arrangement.Center + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - viewModel.permissionModel.value?.let { - Title(text = it.studyTitle, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) - } - Spacer(Modifier.height(80.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.fillMaxWidth(0.8f), + verticalArrangement = Arrangement.Center ) { - Image( - id = R.drawable.warning_exclamation, - contentDescription = "More Logo", - modifier = Modifier - .fillMaxWidth(0.3f) - .aspectRatio(1.5f) - ) - } + viewModel.permissionModel.value?.let { + Title( + text = it.studyTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(80.dp)) - SmallTitle( - text = stringResource(id = R.string.more_settings_withdraw_statement), - color = MoreColors.Important, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Image( + id = R.drawable.warning_exclamation, + contentDescription = "More Logo", + modifier = Modifier + .fillMaxWidth(0.3f) + .aspectRatio(1.5f) + ) + } - Spacer(Modifier.height(80.dp)) + Spacer(Modifier.height(18.dp)) - SmallTitle( - text = stringResource(id = R.string.more_settings_withdraw_question), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) + SmallTitle( + text = stringResource(id = R.string.more_settings_withdraw_statement), + color = MoreColors.Important, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(80.dp)) - SmallTextButton( - text = stringResource(id = R.string.more_settings_continue), - buttonColors = ButtonDefaults.moreApproved(), - borderStroke = MoreColors.borderApproved() - ) { - (context as? Activity)?.onBackPressed() - } + SmallTitle( + text = stringResource(id = R.string.more_settings_withdraw_question), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) - SmallTextButton( - text = getStringResource(id = R.string.more_settings_withdraw_from_study), - buttonColors = ButtonDefaults.moreImportant(), - borderStroke = MoreColors.borderImportant() - ) { - navController.navigate(NavigationScreen.LEAVE_STUDY_CONFIRM.routeWithParameters()) + Spacer(Modifier.height(18.dp)) + + SmallTextButton( + text = stringResource(id = R.string.more_settings_continue), + buttonColors = ButtonDefaults.moreApproved(), + borderStroke = MoreColors.borderApproved() + ) { + (context as? Activity)?.onBackPressed() + } + + SmallTextButton( + text = getStringResource(id = R.string.more_settings_withdraw_from_study), + buttonColors = ButtonDefaults.moreImportant(), + borderStroke = MoreColors.borderImportant() + ) { + navController.navigate(NavigationScreen.LEAVE_STUDY_CONFIRM.routeWithParameters()) + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt index 1da0326cb..30760ca14 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/leaveStudy/LeaveStudyViewModel.kt @@ -19,28 +19,35 @@ import androidx.work.WorkManager import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.ContentActivity import io.redlink.more.app.android.extensions.showNewActivityAndClearStack -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.models.PermissionModel -import io.redlink.more.more_app_mutliplatform.viewModels.settings.CoreSettingsViewModel +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.models.PermissionModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.viewModels.settings.CoreSettingsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class LeaveStudyViewModel : ViewModel() { - private var coreSettingsViewModel = CoreSettingsViewModel(MoreApplication.shared!!) - val study = mutableStateOf(null) + val coreViewModel = + CoreSettingsViewModel( + MoreApplication.shared!!.repositories, + MoreApplication.shared!!.sharedStorageRepository, + NavigationRoute.LEAVE_STUDY.viewIdentifier + ) + val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) init { - viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.study.collect { + coreViewModel.setExitStudyObserver(MoreApplication.shared!!) + viewModelScope.launch { + coreViewModel.study.collect { withContext(Dispatchers.Main) { study.value = it } } } - viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.permissionModel.collect { + viewModelScope.launch { + coreViewModel.permissionModel.collect { withContext(Dispatchers.Main) { permissionModel.value = it } @@ -48,18 +55,10 @@ class LeaveStudyViewModel : ViewModel() { } } - fun viewDidAppear() { - coreSettingsViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreSettingsViewModel.viewDidDisappear() - } - fun removeParticipation(context: Context) { WorkManager.getInstance(context).cancelAllWork() viewModelScope.launch { - coreSettingsViewModel.dataDeleted.collect { + coreViewModel.dataDeleted.collect { if (it) { (context as? Activity)?.let { activity -> withContext(Dispatchers.Main) { @@ -70,6 +69,6 @@ class LeaveStudyViewModel : ViewModel() { } } } - coreSettingsViewModel.exitStudy() + coreViewModel.exitStudy() } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginView.kt index b05d19843..815500014 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginView.kt @@ -26,10 +26,11 @@ import io.redlink.more.app.android.activities.login.composables.ParticipationKey import io.redlink.more.app.android.extensions.Image import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.AppVersion - +import io.redlink.more.registration.RegistrationService @Composable -fun LoginView(model: LoginViewModel) { +fun LoginView(registrationService: RegistrationService) { + val model = remember { LoginViewModel(registrationService) } Column( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt index 66c7d4244..269add380 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt @@ -12,62 +12,61 @@ package io.redlink.more.app.android.activities.login import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.redlink.more.more_app_mutliplatform.services.network.RegistrationService -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.viewModels.login.CoreLoginViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc +import io.redlink.more.app.android.R +import io.redlink.more.app.android.extensions.stringResource +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.models.LoginModel +import io.redlink.more.registration.RegistrationService +import io.redlink.more.util.validateAndNormalizeUrl -interface LoginViewModelListener { - fun tokenIsValid(study: Study) -} - -class LoginViewModel(registrationService: RegistrationService, private val loginViewModelListener: LoginViewModelListener) : ViewModel() { - private val coreLoginViewModel = CoreLoginViewModel(registrationService) - - private val tokenValid = mutableStateOf(false) +class LoginViewModel( + val registrationService: RegistrationService +) : ViewModel() { val participantKey = mutableStateOf("") - val loadingState = mutableStateOf(false) - val error = mutableStateOf(null) + val isLoading = registrationService.isLoading + val error = registrationService.error - val dataEndpoint = mutableStateOf(""); + val dataEndpoint = mutableStateOf("") val defaultEndpoint = mutableStateOf(registrationService.getEndpointRepository().endpoint()) val endpointError = mutableStateOf(null) fun participationKeyNotBlank(): Boolean = this.participantKey.value.isNotBlank() - fun isTokenError(): Boolean = !this.error.value.isNullOrBlank() - fun isEndpointError(): Boolean = !this.endpointError.value.isNullOrBlank() - fun tokenIsValid() = this.tokenValid.value - - init { - viewModelScope.launch(Dispatchers.IO) { - coreLoginViewModel.loadingFlow.collect { - withContext(Dispatchers.Main) { - loadingState.value = it - } - } - } - } - - fun viewDidAppear() { - coreLoginViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreLoginViewModel.viewDidDisappear() - } + fun isEndpointError(): Boolean = + !this.endpointError.value.validateAndNormalizeUrl().isNullOrBlank() fun currentEndpoint() = dataEndpoint.value.ifEmpty { defaultEndpoint.value } fun validateKey() { - coreLoginViewModel.sendRegistrationToken(participantKey.value, dataEndpoint.value.ifEmpty { null }, - onSuccess = { - loginViewModelListener.tokenIsValid(it) - }, onError = { - error.value = it?.message - }) + if (registrationService.connected.value) { + val loginModel = LoginModel(participantKey.value, currentEndpoint()) + if (loginModel.valid()) { + registrationService.sendRegistrationToken(loginModel) + } else { + AlertController.openAlertDialog( + AlertDialogModel( + StringDesc.Raw(stringResource(R.string.more_token_error)), + StringDesc.Raw(stringResource(R.string.more_404)), + confirmLabel = StringDesc.Raw("Ok") + ) + ) + } + } else { + val dialogModel = AlertDialogModel( + title = StringDesc.Raw(stringResource(R.string.no_internet_connection_title)), + message = StringDesc.Raw(stringResource(R.string.no_internet_connection_body)), + confirmLabel = StringDesc.Raw("Ok") + ) + AlertController.openAlertDialog(dialogModel) + } } + fun extractValuesFromQRCode(qrCodeUrl: String) { + dataEndpoint.value = + qrCodeUrl.substringBefore("signup?").validateAndNormalizeUrl() ?: currentEndpoint() + participantKey.value = + qrCodeUrl.substringAfter("token=", "").takeIf { it.isNotEmpty() } ?: "" + } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/EndpointView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/EndpointView.kt index 78e8cb918..bacd895f7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/EndpointView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/EndpointView.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -54,7 +55,7 @@ import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.login.LoginViewModel import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.BasicText -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun EndpointView( @@ -88,7 +89,10 @@ fun EndpointView( Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { isOpen = !isOpen } + modifier = Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { isOpen = !isOpen } ) { Text( text = getStringResource(id = R.string.more_endpoint_label), diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ErrorMessage.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ErrorMessage.kt index eeb59e87a..357cd4770 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ErrorMessage.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ErrorMessage.kt @@ -20,14 +20,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun ErrorMessage(hasError: Boolean, errorMsg: String) { if (hasError) { - Spacer(modifier = Modifier.height(8.dp).fillMaxWidth()) + Spacer( + modifier = Modifier + .height(8.dp) + .fillMaxWidth() + ) Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = errorMsg, color = MoreColors.Important, textAlign = TextAlign.Center) + Text( + text = errorMsg, + color = MoreColors.Important, + textAlign = TextAlign.Center + ) } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ParticipantKeyInput.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ParticipantKeyInput.kt index 163e27f55..ed14ad73d 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ParticipantKeyInput.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ParticipantKeyInput.kt @@ -24,6 +24,7 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager @@ -38,10 +39,12 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.login.LoginViewModel import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.shared_composables.MoreDivider +import io.redlink.more.app.android.theme.MoreColors @Composable fun ParticipationKeyInput( @@ -49,7 +52,7 @@ fun ParticipationKeyInput( focusRequester: FocusRequester, focusManager: FocusManager, ) { - + val networkError by model.error.collectAsStateWithLifecycle() Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Text( text = getStringResource(id = R.string.more_registration_token_label), @@ -69,10 +72,10 @@ fun ParticipationKeyInput( value = model.participantKey.value, onValueChange = { model.participantKey.value = it - model.error.value = null + model.registrationService.clearError() }, trailingIcon = { - if (model.isTokenError()) { + if (networkError != null && !networkError?.message.isNullOrBlank()) { Icon(Icons.Filled.Error, "Error", tint = MoreColors.Important) } }, @@ -84,13 +87,13 @@ fun ParticipationKeyInput( ) }, keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - autoCorrect = false, + capitalization = KeyboardCapitalization.Characters, + autoCorrectEnabled = false, keyboardType = KeyboardType.Text, - capitalization = KeyboardCapitalization.Characters + imeAction = ImeAction.Done ), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - isError = model.isTokenError(), + isError = networkError != null && !networkError?.message.isNullOrBlank(), singleLine = true, colors = TextFieldDefaults.outlinedTextFieldColors( textColor = MoreColors.Primary, @@ -113,14 +116,23 @@ fun ParticipationKeyInput( .height(60.dp) ) Column(horizontalAlignment = Alignment.CenterHorizontally) { - ErrorMessage( - hasError = model.error.value != null, - errorMsg = model.error.value ?: getStringResource( - id = R.string.more_token_error - ) - ) + networkError?.let { error -> + if (error.code == 404) { + ErrorMessage(true, getStringResource(R.string.more_404)) + } else if (error.code != null && error.code!! >= 500 && error.code!! < 600) { + ErrorMessage(true, getStringResource(R.string.more_system_error)) + } else if (error.message.isNotBlank()) { + ErrorMessage(true, error.message) + } else { + ErrorMessage(true, getStringResource(R.string.more_token_error)) + } + } } Spacer(modifier = Modifier.height(16.dp)) + QRCodeButton(model = model) + Spacer(modifier = Modifier.height(32.dp)) + MoreDivider() + Spacer(modifier = Modifier.height(32.dp)) ValidationButton(model = model, focusManager = focusManager) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/QRCodeButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/QRCodeButton.kt index 9e4bdba46..570155e55 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/QRCodeButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/QRCodeButton.kt @@ -10,37 +10,54 @@ */ package io.redlink.more.app.android.activities.login.composables +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.QrCode import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.login.LoginViewModel +import io.redlink.more.app.android.activities.qrScanner.QRScannerActivity import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.morePrimary - +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreSecondary @Composable -fun QRCodeButton() { +fun QRCodeButton(model: LoginViewModel) { + + val context = rememberUpdatedState(LocalContext.current) + val qrScannerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val scanned = + result.data?.getStringExtra("qrResult") ?: return@rememberLauncherForActivityResult + model.extractValuesFromQRCode(scanned) + } + OutlinedButton( - onClick = {}, + onClick = { + val intent = Intent(context.value, QRScannerActivity::class.java) + qrScannerLauncher.launch(intent) + }, modifier = Modifier .fillMaxWidth(1f) - .padding(vertical = 8.dp) .height(60.dp), - colors = ButtonDefaults.morePrimary(), + colors = ButtonDefaults.moreSecondary(), border = MoreColors.borderPrimary(true) ) { Row( @@ -50,10 +67,10 @@ fun QRCodeButton() { ) { Text(text = getStringResource(id = R.string.more_qr_code_button)) Icon( - Icons.Default.ArrowForwardIos, + Icons.Default.QrCode, tint = MoreColors.White, contentDescription = getStringResource(id = R.string.more_qr_code_button_description), - modifier = Modifier.fillMaxHeight(0.4f) + modifier = Modifier.fillMaxHeight(0.6f) ) } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ValidationButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ValidationButton.kt index 51a8b1ea4..f74aade22 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ValidationButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ValidationButton.kt @@ -18,26 +18,29 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.login.LoginViewModel import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.morePrimary +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.morePrimary @Composable fun ValidationButton(model: LoginViewModel, focusManager: FocusManager) { - if (!model.loadingState.value) { + val isLoading by model.isLoading.collectAsStateWithLifecycle() + if (!isLoading) { OutlinedButton( onClick = { focusManager.clearFocus() model.validateKey() }, - enabled = model.participationKeyNotBlank() && !model.loadingState.value, + enabled = model.participationKeyNotBlank(), colors = ButtonDefaults.morePrimary(), - border = if (model.participationKeyNotBlank() && !model.loadingState.value) + border = if (model.participationKeyNotBlank()) BorderStroke(0.dp, MoreColors.Primary) else BorderStroke(2.dp, MoreColors.SecondaryMedium), diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt index 4beebc923..7d0b9508f 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt @@ -26,21 +26,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.NavigationScreen -import io.redlink.more.app.android.activities.NavigationScreen.Companion.NavigationNotificationIDKey import io.redlink.more.app.android.activities.completedSchedules.CompletedSchedulesView import io.redlink.more.app.android.activities.dashboard.DashboardView import io.redlink.more.app.android.activities.dashboard.filter.DashboardFilterView -import io.redlink.more.app.android.activities.dashboard.filter.DashboardFilterViewModel import io.redlink.more.app.android.activities.info.InfoView import io.redlink.more.app.android.activities.notification.NotificationView import io.redlink.more.app.android.activities.notification.filter.NotificationFilterView import io.redlink.more.app.android.activities.observationErrors.ObservationErrorView +import io.redlink.more.app.android.activities.observations.questionnaire.QuestionViewModel import io.redlink.more.app.android.activities.observations.questionnaire.QuestionnaireResponseView import io.redlink.more.app.android.activities.observations.questionnaire.QuestionnaireView import io.redlink.more.app.android.activities.runningSchedules.RunningSchedulesView @@ -50,22 +54,47 @@ import io.redlink.more.app.android.activities.setting.leave_study.LeaveStudyView import io.redlink.more.app.android.activities.studyDetails.StudyDetailsView import io.redlink.more.app.android.activities.studyDetails.observationDetails.ObservationDetailsView import io.redlink.more.app.android.activities.studyStates.StudyClosedView +import io.redlink.more.app.android.activities.studyStates.StudyLoadingErrorView import io.redlink.more.app.android.activities.studyStates.StudyPausedView import io.redlink.more.app.android.activities.studyStates.StudyUpdateView +import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel import io.redlink.more.app.android.activities.tasks.TaskDetailsView +import io.redlink.more.app.android.observations.PermissionUtils import io.redlink.more.app.android.shared_composables.MoreBackground -import io.redlink.more.more_app_mutliplatform.models.ScheduleListType -import io.redlink.more.more_app_mutliplatform.models.StudyState -import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel +import io.redlink.more.app.android.util.ActivityProvider +import io.redlink.more.models.ScheduleListType +import io.redlink.more.models.StudyState +import io.redlink.more.navigation.model.NavigationRouteParameter +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { - private var loadedNavController = false - private lateinit var navHostController: NavHostController + + override fun onResume() { + super.onResume() + ActivityProvider.setCurrentActivity(this) + } + + override fun onPause() { + super.onPause() + ActivityProvider.clearCurrentActivity() + } + + override fun onDestroy() { + super.onDestroy() + PermissionUtils.cleanupPermissionLauncher(this) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val viewModel = MainViewModel(this) + PermissionUtils.initializePermissionLauncher(this) + val activityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (::navHostController.isInitialized) { @@ -75,33 +104,57 @@ class MainActivity : ComponentActivity() { val destinationChangeListener = NavController.OnDestinationChangedListener { _, destination, _ -> - viewModel.navigationBarTitle.value = destination.navigatorName + val route = destination.route?.split("?")?.firstOrNull() ?: "" + NavigationScreen.byRoute(route)?.let { screen -> + viewModel.navigationBarTitle.value = getString(screen.stringResource) + } } + setContent { navHostController = rememberNavController() + val studyState by MoreApplication.shared!!.repositories.study.studyState.collectAsStateWithLifecycle() + val studyIsUpdating by ViewManager.studyIsUpdating.collectAsStateWithLifecycle(false) + val studyLoadingError by ViewManager.studyLoadingError.collectAsStateWithLifecycle(false) + LaunchedEffect(Unit) { navHostController.addOnDestinationChangedListener(destinationChangeListener) } - if (viewModel.studyIsUpdating.value) { + + if (studyIsUpdating) { StudyUpdateView() - if (loadedNavController) { - navHostController.navigate( - NavigationScreen.DASHBOARD.navigationRoute() - ) - } - } else if (viewModel.studyState.value == StudyState.PAUSED) { + } else if (studyLoadingError) { + StudyLoadingErrorView() + } else if (studyState == StudyState.PAUSED) { StudyPausedView() - } else if (viewModel.studyState.value == StudyState.CLOSED) { - StudyClosedView(viewModel.finishText.value) + } else if (studyState == StudyState.CLOSED) { + StudyClosedView() + } else if (studyState == StudyState.NONE) { + StudyUpdateView() } else { MainView( viewModel.navigationBarTitle.value, viewModel, navHostController, - activityLauncher + activityLauncher, + studyState ) - loadedNavController = true + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ViewManager.showGarminConnectView.collect { + if (it) { + while (!::navHostController.isInitialized) { + delay(500) + } + delay(500) + withContext(Dispatchers.Main) { + navHostController.navigate(NavigationScreen.GARMIN_CONNECT.routeWithParameters()) + } + } + } } } } @@ -112,9 +165,13 @@ fun MainView( navigationTitle: String, viewModel: MainViewModel, navController: NavHostController, - activityResultLauncher: ActivityResultLauncher + activityResultLauncher: ActivityResultLauncher, + studyState: StudyState = StudyState.NONE, ) { val currentContext = rememberUpdatedState(LocalContext.current) + val taskCompletionBarViewModel = remember { TaskCompletionBarViewModel() } + val notificationCount = + MoreApplication.shared!!.notificationManager.unreadUserCount.collectAsStateWithLifecycle() MoreBackground( navigationTitle = navigationTitle, showBackButton = viewModel.showBackButton.value, @@ -130,8 +187,7 @@ fun MainView( 2 -> navController.navigate(NavigationScreen.INFO.routeWithParameters()) } }, - unreadNotificationCount = viewModel.unreadNotificationCount.intValue, - alertDialogModel = viewModel.alertDialogOpen.value + unreadNotificationCount = notificationCount.value, ) { NavHost( navController = navController, @@ -145,10 +201,10 @@ fun MainView( ) { viewModel.tabIndex.intValue = 0 viewModel.showBackButton.value = false - viewModel.navigationBarTitle.value = screen.stringRes() DashboardView( - navController, viewModel = viewModel.dashboardViewModel, - taskCompletionBarViewModel = viewModel.taskCompletionBarViewModel + navController, + viewModel.manualTasks, + taskCompletionBarViewModel = taskCompletionBarViewModel ) } } @@ -161,8 +217,7 @@ fun MainView( ) { viewModel.tabIndex.intValue = 1 viewModel.showBackButton.value = false - viewModel.navigationBarTitle.value = screen.stringRes() - NotificationView(navController, viewModel = viewModel.notificationViewModel) + NotificationView(navController, viewModel.coreNotificationFilterViewModel) } } @@ -175,8 +230,7 @@ fun MainView( ) { viewModel.tabIndex.intValue = 2 viewModel.showBackButton.value = false - viewModel.navigationBarTitle.value = screen.stringRes() - InfoView(navController, viewModel = viewModel.infoVM) + InfoView(navController) } } @@ -187,9 +241,8 @@ fun MainView( screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = screen.stringRes() viewModel.showBackButton.value = true - SettingsView(model = viewModel.settingsViewModel, navController = navController) + SettingsView() } } NavigationScreen.SCHEDULE_DETAILS.let { screen -> @@ -201,16 +254,13 @@ fun MainView( ) { val arguments = requireNotNull(it.arguments) val scheduleId by remember { - mutableStateOf(requireNotNull(arguments.getString("scheduleId"))) + mutableStateOf(requireNotNull(arguments.getString(NavigationRouteParameter.SCHEDULE_ID.key))) } - val taskVM by remember { mutableStateOf(viewModel.getTaskDetailsVM(scheduleId)) } - viewModel.navigationBarTitle.value = screen.stringRes() viewModel.showBackButton.value = true TaskDetailsView( navController = navController, - viewModel = taskVM, scheduleId = scheduleId ) } @@ -223,11 +273,8 @@ fun MainView( ) { val arguments = requireNotNull(it.arguments) - viewModel.navigationBarTitle.value = - NavigationScreen.OBSERVATION_DETAILS.stringRes() - val observationId = arguments.getString("observationId") - viewModel.navigationBarTitle.value = - screen.stringRes() + val observationId = + arguments.getString(NavigationRouteParameter.OBSERVATION_ID.key) viewModel.showBackButton.value = true val obsDetailsVM by remember { @@ -235,8 +282,7 @@ fun MainView( } ObservationDetailsView( - viewModel = obsDetailsVM, - navController = navController + viewModel = obsDetailsVM ) } } @@ -246,12 +292,11 @@ fun MainView( screen.routeWithParameters(), screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = screen.stringRes() viewModel.showBackButton.value = true StudyDetailsView( - viewModel = viewModel.studyDetailsViewModel, navController = navController, - taskCompletionBarViewModel = viewModel.taskCompletionBarViewModel + navController = navController, + taskCompletionBarViewModel = taskCompletionBarViewModel ) } } @@ -263,63 +308,46 @@ fun MainView( screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true - val arguments by remember { mutableStateOf(requireNotNull(it.arguments)) } - val vm by remember { - mutableStateOf( - when (ScheduleListType.valueOf( - arguments.getString( - "scheduleListType", + val coreViewModel = remember { + viewModel.schedulesViewModel( + ScheduleListType.valueOf( + requireNotNull(it.arguments).getString( + NavigationRouteParameter.SCHEDULE_LIST_TYPE.key, "ALL" ) - )) { - ScheduleListType.MANUALS -> viewModel.manualTasks.filterModel - ScheduleListType.RUNNING -> viewModel.runningSchedulesViewModel.filterModel - ScheduleListType.COMPLETED -> viewModel.completedSchedulesViewModel.filterModel - ScheduleListType.ALL -> DashboardFilterViewModel( - CoreDashboardFilterViewModel() - ) - } + ) ) - } - DashboardFilterView(viewModel = vm) + }.coreViewModel + + DashboardFilterView(coreViewModel) } } - NavigationScreen.SIMPLE_QUESTION.let { screen -> + NavigationScreen.QUESTION.let { screen -> composable( screen.routeWithParameters(), screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { val scheduleId by remember { - mutableStateOf(it.arguments?.getString("scheduleId")) + mutableStateOf(it.arguments?.getString(NavigationRouteParameter.SCHEDULE_ID.key)) } val observationId by remember { - mutableStateOf(it.arguments?.getString("observationId")) + mutableStateOf(it.arguments?.getString(NavigationRouteParameter.OBSERVATION_ID.key)) } val notificationId by remember { - mutableStateOf(it.arguments?.getString(NavigationNotificationIDKey)) + mutableStateOf(it.arguments?.getString(NavigationRouteParameter.NOTIFICATION_ID.key)) } - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true - val vm by remember { - mutableStateOf( - viewModel.creteNewSimpleQuestionViewModel( - scheduleId, - observationId, - notificationId - ) - ) + val questionViewModel = remember(scheduleId, notificationId, observationId) { + QuestionViewModel(scheduleId, notificationId, observationId) } QuestionnaireView( - navController = navController, - viewModel = vm + navController, + questionViewModel ) } } @@ -332,13 +360,13 @@ fun MainView( ) { val scheduleId by remember { - mutableStateOf(it.arguments?.getString("scheduleId")) + mutableStateOf(it.arguments?.getString(NavigationRouteParameter.SCHEDULE_ID.key)) } val observationId by remember { - mutableStateOf(it.arguments?.getString("observationId")) + mutableStateOf(it.arguments?.getString(NavigationRouteParameter.OBSERVATION_ID.key)) } val notificationId by remember { - mutableStateOf(it.arguments?.getString(NavigationNotificationIDKey)) + mutableStateOf(it.arguments?.getString(NavigationRouteParameter.NOTIFICATION_ID.key)) } Box(modifier = Modifier.fillMaxSize()) { if (scheduleId != null || observationId != null) { @@ -361,8 +389,6 @@ fun MainView( screen.routeWithParameters(), screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = false QuestionnaireResponseView(navController) @@ -375,11 +401,9 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true - NotificationFilterView(viewModel = viewModel.notificationFilterViewModel) + NotificationFilterView(coreViewModel = viewModel.coreNotificationFilterViewModel) } } @@ -389,14 +413,12 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true RunningSchedulesView( viewModel = viewModel.runningSchedulesViewModel, navController = navController, - taskCompletionBarViewModel = viewModel.taskCompletionBarViewModel + taskCompletionBarViewModel = taskCompletionBarViewModel ) } } @@ -407,26 +429,40 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true CompletedSchedulesView( viewModel = viewModel.completedSchedulesViewModel, navController = navController, - taskCompletionBarViewModel = viewModel.taskCompletionBarViewModel + taskCompletionBarViewModel = taskCompletionBarViewModel ) } } + NavigationScreen.GARMIN_CONNECT.let { screen -> + composable( + screen.routeWithParameters(), + screen.createListOfNavArguments(), + screen.createDeepLinkRoute() + ) { + Box(modifier = Modifier.fillMaxSize()) { + LaunchedEffect(Unit) { + viewModel.openGarminActivity( + currentContext.value, + activityResultLauncher + ) + } + } + } + } + NavigationScreen.LEAVE_STUDY.let { screen -> composable( screen.routeWithParameters(), screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = screen.stringRes() viewModel.showBackButton.value = true - LeaveStudyView(navController, viewModel = viewModel.leaveStudyViewModel) + LeaveStudyView(navController) } } @@ -436,10 +472,8 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true - LeaveStudyConfirmView(navController, viewModel = viewModel.leaveStudyViewModel) + LeaveStudyConfirmView(navController) } } @@ -449,12 +483,29 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true ObservationErrorView() } } } + LaunchedEffect(studyState) { + if (studyState == StudyState.ACTIVE) { + val currentBase = navController.currentDestination + ?.route + ?.substringBefore("?") + + val dashboardBase = NavigationScreen.DASHBOARD.routeWithParameters() + + if (currentBase != dashboardBase) { + navController.navigate(NavigationScreen.DASHBOARD.routeWithParameters()) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + } + } } -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainTabView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainTabView.kt index 0106bd17c..35b9771fc 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainTabView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainTabView.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.graphics.Color import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.activities.main.composables.TabItem - @Composable fun MainTabView(selectedIndex: Int, unreadNotificationCount: Int = 0, onTabChange: (Int) -> Unit) { val nameSet = setOf( @@ -40,7 +39,8 @@ fun MainTabView(selectedIndex: Int, unreadNotificationCount: Int = 0, onTabChang }, ) { - Tab(selected = selectedIndex == 0, + Tab( + selected = selectedIndex == 0, onClick = { onTabChange(0) }) { @@ -52,7 +52,8 @@ fun MainTabView(selectedIndex: Int, unreadNotificationCount: Int = 0, onTabChang selected = selectedIndex == 0 ) } - Tab(selected = selectedIndex == 1, + Tab( + selected = selectedIndex == 1, onClick = { onTabChange(1) }) { @@ -65,7 +66,8 @@ fun MainTabView(selectedIndex: Int, unreadNotificationCount: Int = 0, onTabChang unreadNotificationCount ) } - Tab(selected = selectedIndex == 2, + Tab( + selected = selectedIndex == 2, onClick = { onTabChange(2) }) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt index 8407f5c4f..5c1e5fa94 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt @@ -18,51 +18,26 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.bluetooth.BLEConnectionActivity -import io.redlink.more.app.android.activities.dashboard.DashboardViewModel import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel -import io.redlink.more.app.android.activities.info.InfoViewModel -import io.redlink.more.app.android.activities.leaveStudy.LeaveStudyViewModel -import io.redlink.more.app.android.activities.notification.NotificationViewModel -import io.redlink.more.app.android.activities.notification.filter.NotificationFilterViewModel +import io.redlink.more.app.android.activities.observations.garmin.GarminConnectActivity import io.redlink.more.app.android.activities.observations.limeSurvey.LimeSurveyActivity -import io.redlink.more.app.android.activities.observations.questionnaire.QuestionnaireViewModel -import io.redlink.more.app.android.activities.setting.SettingsViewModel -import io.redlink.more.app.android.activities.studyDetails.StudyDetailsViewModel import io.redlink.more.app.android.activities.studyDetails.observationDetails.ObservationDetailsViewModel -import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel -import io.redlink.more.app.android.activities.tasks.TaskDetailsViewModel -import io.redlink.more.more_app_mutliplatform.AlertController -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.models.ScheduleListType -import io.redlink.more.more_app_mutliplatform.models.StudyState -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager -import io.redlink.more.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel -import kotlinx.coroutines.Dispatchers +import io.redlink.more.models.ScheduleListType +import io.redlink.more.viewModels.ViewManager +import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class MainViewModel(context: Context) : ViewModel() { val tabIndex = mutableIntStateOf(0) val showBackButton = mutableStateOf(false) val navigationBarTitle = mutableStateOf("") - val studyIsUpdating = mutableStateOf(false) - val studyState = mutableStateOf(StudyState.NONE) - val finishText = mutableStateOf(null) + val coreNotificationFilterViewModel = CoreNotificationFilterViewModel() - val unreadNotificationCount = mutableIntStateOf(0) - - private var initFinished = false - - val notificationViewModel: NotificationViewModel - val notificationFilterViewModel: NotificationFilterViewModel - val manualTasks: ScheduleViewModel by lazy { - ScheduleViewModel( - ScheduleListType.MANUALS - ) - } + val manualTasks: ScheduleViewModel = ScheduleViewModel( + ScheduleListType.MANUALS + ) val runningSchedulesViewModel: ScheduleViewModel by lazy { ScheduleViewModel( @@ -75,64 +50,11 @@ class MainViewModel(context: Context) : ViewModel() { ) } - val dashboardViewModel = DashboardViewModel(manualTasks) - val settingsViewModel: SettingsViewModel by lazy { SettingsViewModel() } - val studyDetailsViewModel: StudyDetailsViewModel by lazy { StudyDetailsViewModel() } - val leaveStudyViewModel: LeaveStudyViewModel by lazy { LeaveStudyViewModel() } - - val taskCompletionBarViewModel = TaskCompletionBarViewModel() - - val infoVM: InfoViewModel by lazy { - InfoViewModel() - } - - private val simpleQuestionnaireViewModel by lazy { - QuestionnaireViewModel() - } - - private val taskDetailsViewModel: TaskDetailsViewModel by lazy { - TaskDetailsViewModel(MoreApplication.shared!!.dataRecorder) - } - val alertDialogOpen = mutableStateOf(null) private var lastBleViewState = false - init { - viewModelScope.launch(Dispatchers.IO) { - AlertController.alertDialogModel.collect { - withContext(Dispatchers.Main) { - alertDialogOpen.value = it - } - } - } viewModelScope.launch { - ViewManager.studyIsUpdating.collect { - studyIsUpdating.value = it - } - } - viewModelScope.launch { - MoreApplication.shared!!.currentStudyState.collect { - finishText.value = MoreApplication.shared!!.finishText - studyState.value = it - } - } - - viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.unreadNotificationCount.collect { - withContext(Dispatchers.Main) { - unreadNotificationCount.intValue = it - } - } - } - - val coreNotificationFilterViewModel = CoreNotificationFilterViewModel() - notificationViewModel = NotificationViewModel(coreNotificationFilterViewModel) - notificationFilterViewModel = NotificationFilterViewModel(coreNotificationFilterViewModel) - - initFinished = true - - viewModelScope.launch { - ViewManager.showBluetoothView.collect { + ViewManager.bleViewActive.collect { if (it && !lastBleViewState) { openBLESetupActivity(context) } @@ -141,9 +63,6 @@ class MainViewModel(context: Context) : ViewModel() { } } - fun getTaskDetailsVM(scheduleId: String) = - taskDetailsViewModel.apply { setSchedule(scheduleId) } - fun openLimesurvey( context: Context, activityResultLauncher: ActivityResultLauncher, @@ -161,32 +80,35 @@ class MainViewModel(context: Context) : ViewModel() { LimeSurveyActivity.LIME_SURVEY_ACTIVITY_OBSERVATION_ID, observationId ) - intent.putExtra(LimeSurveyActivity.LIME_SURVEY_ACTIVITY_NOTIFICATION_ID, notificationId) + intent.putExtra( + LimeSurveyActivity.LIME_SURVEY_ACTIVITY_NOTIFICATION_ID, + notificationId + ) activityResultLauncher.launch(intent) } } - fun creteNewSimpleQuestionViewModel( - scheduleId: String? = null, - observationId: String? = null, - notificationId: String? - ): QuestionnaireViewModel { - if (scheduleId != null || observationId != null) { - simpleQuestionnaireViewModel.apply { - if (!scheduleId.isNullOrBlank()) { - setScheduleId(scheduleId, notificationId) - } else if (!observationId.isNullOrBlank()) { - setObservationId(observationId, notificationId) - } - } + fun openGarminActivity( + context: Context, + activityResultLauncher: ActivityResultLauncher + ) { + (context as? Activity)?.let { activity -> + val intent = Intent(activity, GarminConnectActivity::class.java) + activityResultLauncher.launch(intent) } - return simpleQuestionnaireViewModel } fun createObservationDetailView(observationId: String): ObservationDetailsViewModel { return ObservationDetailsViewModel(observationId) } + fun schedulesViewModel(type: ScheduleListType): ScheduleViewModel = when (type) { + ScheduleListType.MANUALS -> manualTasks + ScheduleListType.RUNNING -> runningSchedulesViewModel + ScheduleListType.COMPLETED -> completedSchedulesViewModel + ScheduleListType.ALL -> ScheduleViewModel(ScheduleListType.ALL) + } + private fun openBLESetupActivity(context: Context) { (context as? Activity)?.let { val intent = Intent(context, BLEConnectionActivity::class.java) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/QRScannerActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/QRScannerActivity.kt new file mode 100644 index 000000000..e5613268e --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/QRScannerActivity.kt @@ -0,0 +1,322 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.app.android.activities.qrScanner + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage +import io.redlink.more.app.android.R +import io.redlink.more.app.android.extensions.Image +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.shared_composables.IconInline +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreSecondary + +// Infos to Barcodes mit ML Kit: https://developers.google.com/ml-kit/vision/barcode-scanning/android?hl=de + +class QRScannerActivity : ComponentActivity() { + // preview view for the camera qr code scanner + private lateinit var previewView: PreviewView + private val scanner = BarcodeScanning.getClient() + + private val permissionGiven: MutableState = mutableStateOf(false) + + private val requestPermissionLauncher = + registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + permissionGiven.value = isGranted + if (isGranted) { + startCamera() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestCameraPermissionIfNeeded() + + setContent { + QrScannerScreen( + previewViewProvider = { + previewView = PreviewView(it) + previewView + }, + permissionGiven = permissionGiven, + onClose = { finish() } + ) + } + } + + private fun requestCameraPermissionIfNeeded() { + when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED -> { + startCamera() + } + + ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.CAMERA + ) -> { + } + + else -> { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + } + + // function converts camera image into an inputimage + @OptIn(ExperimentalGetImage::class) + private fun processImageProxy(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image ?: return + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + barcode.rawValue?.let { result -> + // returns scanned result and closes the camera + val intent = Intent().putExtra("qrResult", result) + setResult(Activity.RESULT_OK, intent) + finish() + } + } + } + .addOnCompleteListener { + imageProxy.close() + } + } + + private fun startCamera() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(this) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val analysis = ImageAnalysis.Builder().build().also { + it.setAnalyzer(ContextCompat.getMainExecutor(this)) { imageProxy -> + processImageProxy(imageProxy) + } + } + + val selector = CameraSelector.DEFAULT_BACK_CAMERA + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(this, selector, preview, analysis) + }, ContextCompat.getMainExecutor(this)) + } +} + +@Composable +fun QrScannerScreen( + onClose: () -> Unit, + modifier: Modifier = Modifier, + previewViewProvider: (Context) -> PreviewView, + permissionGiven: MutableState +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MoreColors.PrimaryLight200) + ) { + // Camera Preview (full screen) + AndroidView( + factory = previewViewProvider, + Modifier.fillMaxSize(0.95f) + ) + + // Overlay with center cutout (placed directly after camera view so it doesn’t cover text/buttons) + Box( + modifier = Modifier + .fillMaxSize() + .drawWithContent { + drawContent() + + val cutoutSize = Size(size.width - 140, size.height / 2) + val canvasWidth = size.width + val canvasHeight = size.height + val left = (canvasWidth - cutoutSize.width) / 2 + val top = ((canvasHeight - cutoutSize.height) / 3) * 2 + + // background color + drawRect(color = MoreColors.PrimaryLight) + + // camera area + drawRect( + color = Color.Transparent, + topLeft = Offset(left, top), + size = cutoutSize, + blendMode = BlendMode.Clear + ) + } + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + contentAlignment = Alignment.TopEnd + ) { + Button( + onClick = onClose, + modifier = Modifier + .height(60.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + contentColor = MoreColors.Secondary + ), + elevation = null + ) { + IconInline( + icon = Icons.Rounded.Close, + color = MoreColors.Secondary, + contentDescription = getStringResource(id = R.string.more_close_icon) + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth(0.95f) + .padding(top = 64.dp) + .padding(horizontal = 24.dp) + .align(Alignment.TopCenter), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + // Welcome Image + Image( + id = R.drawable.welcome_to_more, + contentDescription = getStringResource(id = R.string.more_welcome_title) + ) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = getStringResource(id = R.string.more_qr_code_button), + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MoreColors.Primary, + textAlign = TextAlign.Center + ) + } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = getStringResource(id = R.string.more_qr_code_scan_automatically), + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = MoreColors.Secondary, + textAlign = TextAlign.Center + ) + } + + if (!permissionGiven.value) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 30.dp) + .padding(horizontal = 30.dp), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = getStringResource(id = R.string.more_qr_camera_needed), + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = MoreColors.PrimaryLight200, + textAlign = TextAlign.Center + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 48.dp) + .padding(horizontal = 24.dp) + .align(Alignment.BottomCenter), + contentAlignment = Alignment.BottomCenter + ) { + OutlinedButton( + onClick = onClose, + modifier = Modifier + .padding(vertical = 8.dp) + .height(60.dp) + .fillMaxWidth(), + colors = ButtonDefaults.moreSecondary(), + border = MoreColors.borderPrimary(true) + ) { + Text(getStringResource(id = R.string.more_close)) + } + } + } +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/composables/TabIcon.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/composables/TabIcon.kt index a60496b89..35df9a463 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/composables/TabIcon.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/composables/TabIcon.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun TabItem( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt index b29b47030..9bc07430a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationView.kt @@ -11,84 +11,90 @@ package io.redlink.more.app.android.activities.notification import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.notification.composables.NotificationFilterViewButton import io.redlink.more.app.android.activities.notification.composables.NotificationItem import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.MoreDivider - +import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel @Composable -fun NotificationView(navController: NavController, viewModel: NotificationViewModel) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = - backStackEntry?.arguments?.getString(NavigationScreen.NOTIFICATIONS.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - ) { +fun NotificationView( + navController: NavController, + coreFilterViewModel: CoreNotificationFilterViewModel +) { + val viewModel = remember { NotificationViewModel(coreFilterViewModel) } + val notificationList by viewModel.coreViewModel.notificationList.collectAsStateWithLifecycle() + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { - item { - Column( - modifier = Modifier - .height( - IntrinsicSize.Min - ) - ) { - NotificationFilterViewButton(navController, viewModel = viewModel) + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + ) { + + item { + Column( + modifier = Modifier + .height( + IntrinsicSize.Min + ) + ) { + NotificationFilterViewButton(navController, viewModel = viewModel) + } + Spacer(modifier = Modifier.padding(10.dp)) } - Spacer(modifier = Modifier.padding(10.dp)) - } - item { - if (viewModel.notificationList.isEmpty()) { - Text(text = getStringResource(id = R.string.no_notifications_yet)) + item { + if (notificationList.isEmpty()) { + Text(text = getStringResource(id = R.string.no_notifications_yet)) + } } - } - items(viewModel.notificationList.sortedByDescending { it.timestamp }) { notification -> - Column( - modifier = Modifier - .clickable { - if (!notification.read) { - viewModel.handleNotificationAction(notification, navController) + items(notificationList.sortedByDescending { it.timestamp }) { notification -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (!notification.read) { + viewModel.handleNotificationAction(notification, navController) + } } + .padding(bottom = 10.dp) + ) { + Column { + NotificationItem(viewModel, notification, navController) + MoreDivider() } - .padding(bottom = 10.dp) - ) { - NotificationItem( - notification - ) - MoreDivider() + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt index 8705887b1..caf5188e7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/NotificationViewModel.kt @@ -10,58 +10,39 @@ */ package io.redlink.more.app.android.activities.notification -import android.net.Uri -import androidx.compose.runtime.mutableStateListOf +import androidx.core.net.toUri import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R -import io.redlink.more.app.android.extensions.applicationId import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.more_app_mutliplatform.models.NotificationModel -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationActionHandler -import io.redlink.more.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.notifications.CoreNotificationViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - +import io.redlink.more.models.NotificationModel +import io.redlink.more.services.notification.NotificationActionHandler +import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel +import io.redlink.more.viewModels.notifications.CoreNotificationViewModel class NotificationViewModel(private val coreFilterViewModel: CoreNotificationFilterViewModel) : ViewModel() { - private val coreViewModel: CoreNotificationViewModel = + val coreViewModel: CoreNotificationViewModel = CoreNotificationViewModel( coreFilterViewModel, - MoreApplication.shared!!.notificationManager, - stringResource(R.string.app_scheme), - applicationId + MoreApplication.shared!!.notificationManager ) - val notificationList = mutableStateListOf() - - init { - viewModelScope.launch(Dispatchers.IO) { - coreViewModel.notificationList.collect { - withContext(Dispatchers.Main) { - notificationList.clear() - notificationList.addAll(it) - } - } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() - } fun handleNotificationAction(notification: NotificationModel, navController: NavController) { coreViewModel.handleNotificationAction(notification) { actionType, data -> - when (actionType) { - NotificationActionHandler.DEEPLINK -> navController.navigate(Uri.parse(data)) + data?.let { + when (actionType) { + NotificationActionHandler.DEEPLINK -> { + val uri = data.route.toUri() + val destRoute = uri.path?.removePrefix("/") ?: uri.toString().substringBefore("?") + val currentRoute = navController.currentDestination?.route?.substringBefore("?") + if (destRoute != currentRoute) { + navController.navigate(uri) + } + } + else -> {} + } } } } @@ -70,6 +51,6 @@ class NotificationViewModel(private val coreFilterViewModel: CoreNotificationFil if (!coreFilterViewModel.filterActive()) { return stringResource(R.string.more_filter_notification_all) } - return coreFilterViewModel.getActiveTypes().joinToString(", ") + return coreFilterViewModel.activeTypes.value.joinToString(", ") { it } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationFilterViewButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationFilterViewButton.kt index c756e4e54..6040a2bf2 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationFilterViewButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationFilterViewButton.kt @@ -11,6 +11,7 @@ package io.redlink.more.app.android.activities.notification.composables import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -20,6 +21,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Tune import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -29,7 +31,7 @@ import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.activities.notification.NotificationViewModel import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun NotificationFilterViewButton(navController: NavController, viewModel: NotificationViewModel) { @@ -39,8 +41,11 @@ fun NotificationFilterViewButton(navController: NavController, viewModel: Notifi modifier = Modifier .fillMaxWidth() .padding(vertical = 19.dp) - .clickable(onClick = { navController.navigate(NavigationScreen.NOTIFICATION_FILTER.routeWithParameters()) }) - ){ + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { navController.navigate(NavigationScreen.NOTIFICATION_FILTER.routeWithParameters()) }) + ) { Text( text = viewModel.getFilterString(), color = MoreColors.Primary, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationItem.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationItem.kt index ea8ce1ee4..7ea64218b 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationItem.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/composables/NotificationItem.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Done import androidx.compose.runtime.Composable @@ -36,19 +36,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.notification.NotificationViewModel import io.redlink.more.app.android.extensions.Image import io.redlink.more.app.android.extensions.formattedString import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.extensions.jvmLocalDateTimeFromMilliseconds +import io.redlink.more.app.android.extensions.jvmLocalDateTimeFromEpochSeconds import io.redlink.more.app.android.extensions.toAnnotatedString import io.redlink.more.app.android.shared_composables.IconInline -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.models.NotificationModel +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.models.NotificationModel +import io.redlink.more.models.localize @Composable fun NotificationItem( - notificationModel: NotificationModel + viewModel: NotificationViewModel, + notificationModel: NotificationModel, + navController: NavController ) { val context = LocalContext.current Column { @@ -102,8 +107,9 @@ fun NotificationItem( .fillMaxWidth() .defaultMinSize(minHeight = 50.dp) ) { - val annotatedNotificationModelBody = remember { - notificationModel.notificationBody.trim().toAnnotatedString() + val localizedBody = remember(notificationModel.notificationBody) { + notificationModel.notificationBody.trim().localize() + .trim().toAnnotatedString() } Column( verticalArrangement = Arrangement.SpaceEvenly, @@ -111,9 +117,9 @@ fun NotificationItem( modifier = Modifier.fillMaxHeight() ) { ClickableText( - text = annotatedNotificationModelBody, + text = localizedBody, onClick = { offset -> - annotatedNotificationModelBody.getStringAnnotations( + localizedBody.getStringAnnotations( tag = "URL", start = offset, end = offset @@ -121,12 +127,16 @@ fun NotificationItem( .firstOrNull()?.let { annotation -> val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) context.startActivity(intent) + } ?: run { + if (!notificationModel.read) { + viewModel.handleNotificationAction(notificationModel, navController) } + } } ) Text( - text = notificationModel.timestamp.jvmLocalDateTimeFromMilliseconds() + text = notificationModel.timestamp.jvmLocalDateTimeFromEpochSeconds() .formattedString("dd.MM.yyyy HH:mm:ss"), fontWeight = FontWeight.Normal, fontSize = 14.sp, @@ -134,12 +144,15 @@ fun NotificationItem( modifier = Modifier.padding(vertical = 8.dp) ) } + if (notificationModel.deepLink != null) { - Icon( - if (notificationModel.read) Icons.Default.Done else Icons.Default.ArrowForwardIos, - contentDescription = getStringResource(id = R.string.more_observation_open), - tint = if (notificationModel.read) MoreColors.Approved else MoreColors.Primary - ) + if (!notificationModel.read || notificationModel.completed) { + Icon( + if (notificationModel.completed) Icons.Default.Done else Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = getStringResource(id = R.string.more_observation_open), + tint = if (notificationModel.read) MoreColors.Approved else MoreColors.Primary + ) + } } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt index 588c9b83d..6c26a87b3 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterView.kt @@ -11,6 +11,7 @@ package io.redlink.more.app.android.activities.notification.filter import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -22,54 +23,65 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Done import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.OnAppearDisappear +import io.redlink.more.app.android.extensions.formatNotificationFilterString import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.shared_composables.HeaderDescription import io.redlink.more.app.android.shared_composables.HeaderTitle import io.redlink.more.app.android.shared_composables.IconInline import io.redlink.more.app.android.shared_composables.MoreDivider -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel @Composable -fun NotificationFilterView(viewModel: NotificationFilterViewModel) { - LazyColumn { - item { - HeaderTitle( - title = stringResource(R.string.more_select_filter), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } +fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { + val viewModel = remember { NotificationFilterViewModel(coreViewModel) } + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + LazyColumn { + item { + HeaderTitle( + title = stringResource(R.string.more_select_filter), + modifier = Modifier.padding(top = 20.dp) + ) + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) + } - itemsIndexed(viewModel.currentFilters.entries.sortedBy { it.key.sortIndex }) { _, entry -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if(entry.value) - IconInline( - icon = Icons.Rounded.Done, - color = MoreColors.Approved, - contentDescription = getStringResource(id = R.string.more_filter_selected) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.toggleFilter(entry.key) }) - .padding(4.dp) + itemsIndexed(viewModel.currentFilters.entries.sortedBy { it.key.sortIndex }) { _, entry -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = entry.key.type, - color = MoreColors.Secondary - ) + if (entry.value) + IconInline( + icon = Icons.Rounded.Done, + color = MoreColors.Approved, + contentDescription = getStringResource(id = R.string.more_filter_selected) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleFilter(entry.key) }) + .padding(4.dp) + ) { + HeaderDescription( + description = entry.key.toString().formatNotificationFilterString(), + color = MoreColors.Secondary + ) + } } + MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) } } - } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt index 6cd687d9e..54bb2ccd9 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/notification/filter/NotificationFilterViewModel.kt @@ -13,13 +13,13 @@ package io.redlink.more.app.android.activities.notification.filter import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.redlink.more.more_app_mutliplatform.models.NotificationFilterTypeModel -import io.redlink.more.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel +import io.redlink.more.models.NotificationFilterTypeModel +import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class NotificationFilterViewModel(private val coreViewModel: CoreNotificationFilterViewModel) : +class NotificationFilterViewModel(val coreViewModel: CoreNotificationFilterViewModel) : ViewModel() { val currentFilters = mutableStateMapOf() @@ -35,14 +35,6 @@ class NotificationFilterViewModel(private val coreViewModel: CoreNotificationFil } } - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() - } - fun toggleFilter(filter: NotificationFilterTypeModel) { coreViewModel.toggleFilter(filter) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorListView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorListView.kt index 5c8024bea..a273d2c0b 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorListView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorListView.kt @@ -13,7 +13,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Watch import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -27,13 +26,13 @@ import io.redlink.more.app.android.extensions.getStringResourceByName import io.redlink.more.app.android.extensions.showNewActivity import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.SmallTextIconButton -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.observations.Observation +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.observations.Observation.Companion.ERROR_DEVICE_NOT_CONNECTED @Composable fun ObservationErrorListView( - errors: SnapshotStateList, - errorActions: SnapshotStateList + errors: List, + errorActions: List ) { val context = LocalContext.current if (errors.isNotEmpty()) { @@ -65,7 +64,7 @@ fun ObservationErrorListView( } if (errorActions.isNotEmpty()) { item { - if (errorActions.contains(Observation.ERROR_DEVICE_NOT_CONNECTED)) { + if (errorActions.contains(ERROR_DEVICE_NOT_CONNECTED)) { SmallTextIconButton( text = NavigationScreen.BLUETOOTH_CONNECTION.stringRes(), imageText = getStringResource(id = R.string.more_ble_icon_description), diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorView.kt index 687479373..d106d1d0c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorView.kt @@ -1,3 +1,14 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + package io.redlink.more.app.android.activities.observationErrors import androidx.compose.runtime.Composable diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt index 002c4e8e1..630e87748 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.aakira.napier.Napier -import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.observations.Observation +import io.redlink.more.observations.Observation.Companion.ERROR_DEVICE_NOT_CONNECTED +import io.redlink.more.observations.ObservationStates import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -16,10 +16,10 @@ class ObservationErrorViewModel : ViewModel() { init { viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.observationFactory.observationErrors.collect { + ObservationStates.observationErrors.collect { Napier.d { it.toString() } val (actions, errors) = it.values.flatten().toSet() - .partition { it == Observation.ERROR_DEVICE_NOT_CONNECTED } + .partition { it == ERROR_DEVICE_NOT_CONNECTED } withContext(Dispatchers.Main) { observationErrors.clear() observationErrors.addAll(errors) @@ -30,5 +30,4 @@ class ObservationErrorViewModel : ViewModel() { } } - } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt new file mode 100644 index 000000000..eb6692583 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt @@ -0,0 +1,204 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.activities.observations.garmin + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.ViewGroup +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import io.github.aakira.napier.Napier +import io.github.aakira.napier.log +import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.web.WebClient +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.shared_composables.IconInline +import io.redlink.more.app.android.shared_composables.MessageAlertDialog +import io.redlink.more.app.android.shared_composables.MoreBackground +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class GarminConnectActivity : ComponentActivity() { + val viewModel = GarminConnectViewModel() + var webView: WebView? = null + var webClientListener: WebClient? = null + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + + + onBackPressedDispatcher.addCallback(this) { + viewModel.coreViewModel.viewDidDisappear() + } + + webView = WebView(this) + webView?.let { webView -> + webClientListener = WebClient() + webClientListener?.let { + webClientListener?.setListener(viewModel) + webView.apply { + webViewClient = it + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setNetworkAvailable(true) + + settings.javaScriptEnabled = true + } + } + } + + lifecycleScope.launch { + val ssoUrl = viewModel.coreViewModel.garminSSOUrl()?.toString() + + if (ssoUrl != null) { + val authHeader = viewModel.coreViewModel.basicAuthHeader(ssoUrl) + + withContext(Dispatchers.Main) { + if (authHeader != null) { + webView?.loadUrl(ssoUrl, mapOf("Authorization" to authHeader)) + } else { + webView?.loadUrl(ssoUrl) + } + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ViewManager.showGarminConnectView.collectLatest { + Napier.d { "show GarminConnectView: $it" } + if (!it) { + finish() + } + } + } + } + setContent { + GarminConnectSSOView(viewModel = viewModel, webView) + } + + onBackPressedDispatcher.addCallback(this) { + if (webView?.canGoBack() == true) { + webView?.goBack() + } else { + viewModel.coreViewModel.closeView() + } + } + } + + override fun onStart() { + super.onStart() + log { "GarminConnectActivity started!" } + viewModel.coreViewModel.viewDidAppear() + } + + override fun onStop() { + super.onStop() + log { "GarminConnectActivity stopped!" } + viewModel.coreViewModel.viewDidDisappear() + } + + override fun onDestroy() { + super.onDestroy() + log { "GarminConnectActivity destroyed!" } + webClientListener?.removeListener() + webView?.destroy() + + } + +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun GarminConnectSSOView(viewModel: GarminConnectViewModel, webView: WebView?) { + val isLoading by viewModel.coreViewModel.isLoading.collectAsStateWithLifecycle() + MoreBackground( + navigationTitle = NavigationScreen.GARMIN_CONNECT.stringRes(), + maxWidth = 1f, + leftCornerContent = { + if (isLoading) { + CircularProgressIndicator(color = MoreColors.Primary, strokeWidth = 2.dp) + } + }, + rightCornerContent = { + IconButton( + onClick = { + viewModel.coreViewModel.closeView() + }, + modifier = Modifier.width(IntrinsicSize.Min) + ) { + IconInline( + icon = Icons.Default.Close, + contentDescription = getStringResource(id = R.string.more_cancel) + ) + } + } + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (webView == null || viewModel.coreViewModel.garminSSOUrl() == null) { + MessageAlertDialog( + AlertDialogModel.fromStrings( + title = "Garmin Connect", + message = getStringResource(id = R.string.garmin_connect_unavailable), + confirmLabel = "Ok", + onConfirm = { viewModel.coreViewModel.closeView() }) + ) + } else { + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + AndroidView( + factory = { webView!! }, + update = { + Napier.d { "Webview update" } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt new file mode 100644 index 000000000..28fe691d1 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt @@ -0,0 +1,118 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.activities.observations.garmin + +import android.webkit.WebResourceRequest +import android.webkit.WebView +import io.github.aakira.napier.Napier +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.app.android.activities.web.WebClientListener +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.viewModels.garminConnectOAuth.CoreGarminConnectViewModel +import kotlinx.coroutines.runBlocking + +class GarminConnectViewModel : WebClientListener { + val coreViewModel = CoreGarminConnectViewModel( + MoreApplication.shared!!.networkService, + MoreApplication.shared!!.sharedStorageRepository + ) + + private var allowedHost: String? = coreViewModel.garminSSOUrl()?.host + + private val injectedUrls = mutableSetOf() + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest + ) { + val url = request.url + val urlString = url.toString() + Napier.d(tag = "GarminConnectViewModel::shouldOverrideUrlLoading") { + "Request to: $urlString" + } + + if (coreViewModel.checkIfUrlIsCallback(urlString)) { + coreViewModel.setLoading(false) + Napier.d(tag = "GarminConnectViewModel::shouldOverrideUrlLoading") { + "Detected Garmin callback URL, handling in ViewModel" + } + + val callBackSuccess = runBlocking { + coreViewModel.sendCallback(urlString) + } + + if (callBackSuccess) { + coreViewModel.onSuccess() + view?.post { view.stopLoading() } + return + } else { + AlertController.openAlertDialog( + AlertDialogModel.fromStrings( + title = "Garmin Connect", + message = "Failed to authenticate with Garmin Connect. Please try again later.", + confirmLabel = "Ok", + onConfirm = { coreViewModel.closeView() } + )) + } + } + + val host = url.host + val baseHost = allowedHost + if (baseHost != null && host != null && !host.endsWith(baseHost)) { + Napier.d(tag = "GarminConnectViewModel::shouldOverrideUrlLoading") { + "Host '$host' not matching allowedHost '$baseHost' -> letting WebView handle it" + } + return + } + + if (injectedUrls.remove(urlString)) { + Napier.d(tag = "GarminConnectViewModel::shouldOverrideUrlLoading") { + "URL already injected once -> allow" + } + return + } + + if (request.requestHeaders.keys.any { it.equals("Authorization", ignoreCase = true) }) { + Napier.d(tag = "GarminConnectViewModel::shouldOverrideUrlLoading") { + "Request already has Authorization header -> allow" + } + return + } + + val authHeader = coreViewModel.basicAuthHeader(urlString) + if (authHeader == null) { + Napier.d(tag = "GarminConnectViewModel::shouldOverrideUrlLoading") { + "No basic auth header available for $urlString -> allow" + } + return + } + + Napier.d(tag = "GarminConnectViewModel::shouldOverrideUrlLoading") { + "Injecting Authorization header for $urlString" + } + + injectedUrls.add(urlString) + + view?.post { + view.stopLoading() + view.loadUrl( + urlString, + mapOf("Authorization" to authHeader) + ) + } + } + + override fun isLoading(loading: Boolean) { + coreViewModel.loading(loading) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyActivity.kt index 55294511a..1cd39b3f8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyActivity.kt @@ -15,8 +15,8 @@ import android.app.Activity import android.os.Bundle import android.view.ViewGroup import android.webkit.WebView -import android.window.OnBackInvokedDispatcher import androidx.activity.ComponentActivity +import androidx.activity.addCallback import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,38 +32,47 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Done import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.aakira.napier.Napier import io.github.aakira.napier.log import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.web.WebClient import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.IconInline import io.redlink.more.app.android.shared_composables.MoreBackground -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors class LimeSurveyActivity : ComponentActivity() { - val viewModel: LimeSurveyViewModel = LimeSurveyViewModel() + private val viewModel: LimeSurveyViewModel by lazy { + LimeSurveyViewModel( + intent.getStringExtra(LIME_SURVEY_ACTIVITY_SCHEDULE_ID), + intent.getStringExtra(LIME_SURVEY_ACTIVITY_NOTIFICATION_ID), + intent.getStringExtra(LIME_SURVEY_ACTIVITY_OBSERVATION_ID) + ) + } var webView: WebView? = null - var webClientListener: LimeSurveyWebClient? = null + var webClientListener: WebClient? = null @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.setModel( - intent.getStringExtra(LIME_SURVEY_ACTIVITY_SCHEDULE_ID), - intent.getStringExtra(LIME_SURVEY_ACTIVITY_OBSERVATION_ID), - intent.getStringExtra(LIME_SURVEY_ACTIVITY_NOTIFICATION_ID) - ) + viewModel + onBackPressedDispatcher.addCallback(this) { + viewModel.onFinish() + finish() + } webView = WebView(this) webView?.let { webView -> - webClientListener = LimeSurveyWebClient() + webClientListener = WebClient() webClientListener?.let { webClientListener?.setListener(viewModel) webView.apply { @@ -102,17 +111,6 @@ class LimeSurveyActivity : ComponentActivity() { } - override fun getOnBackInvokedDispatcher(): OnBackInvokedDispatcher { - viewModel.onFinish() - return super.getOnBackInvokedDispatcher() - } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - super.onBackPressed() - viewModel.onFinish() - } - companion object { const val LIME_SURVEY_ACTIVITY_SCHEDULE_ID = "LIME_SURVEY_ACTIVITY_SCHEDULE_ID" const val LIME_SURVEY_ACTIVITY_OBSERVATION_ID = "LIME_SURVEY_ACTIVITY_OBSERVATION_ID" @@ -123,6 +121,8 @@ class LimeSurveyActivity : ComponentActivity() { @SuppressLint("SetJavaScriptEnabled") @Composable fun LimeSurveyView(viewModel: LimeSurveyViewModel, webView: WebView?) { + val limeSurveyLink by viewModel.coreViewModel.limeSurveyLink.collectAsStateWithLifecycle(null) + val dataLoading by viewModel.coreViewModel.dataLoading.collectAsStateWithLifecycle(false) val context = LocalContext.current if (viewModel.wasAnswered.value) { viewModel.onFinish() @@ -156,11 +156,10 @@ fun LimeSurveyView(viewModel: LimeSurveyViewModel, webView: WebView?) { ) } } - }, - alertDialogModel = viewModel.alertDialogOpen.value + } ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (viewModel.dataLoading.value) { + if (dataLoading) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center @@ -170,7 +169,7 @@ fun LimeSurveyView(viewModel: LimeSurveyViewModel, webView: WebView?) { } } else { webView?.let { webView -> - viewModel.limeSurveyLink.value?.let { limeSurveyLink -> + limeSurveyLink?.let { limeSurveyLink -> Column( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt index 4d7c2e4c4..ac8ce63f8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyViewModel.kt @@ -16,19 +16,25 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.AlertController -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.viewModels.limeSurvey.CoreLimeSurveyViewModel +import io.redlink.more.app.android.activities.web.WebClientListener +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.viewModels.limeSurvey.CoreLimeSurveyViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.URI import java.net.URL -class LimeSurveyViewModel : ViewModel(), WebClientListener { - private val coreViewModel = CoreLimeSurveyViewModel(MoreApplication.shared!!.observationFactory) - val limeSurveyLink = mutableStateOf(null) - val dataLoading = mutableStateOf(false) +class LimeSurveyViewModel(scheduleId: String?, notificationId: String?, observationId: String?) : + ViewModel(), WebClientListener { + val coreViewModel = CoreLimeSurveyViewModel( + MoreApplication.shared!!.repositories, + MoreApplication.shared!!.observationFactory, + scheduleId, + notificationId, + observationId + ) val wasAnswered = mutableStateOf(false) val networkLoading = mutableStateOf(false) val alertDialogOpen = mutableStateOf(null) @@ -41,35 +47,6 @@ class LimeSurveyViewModel : ViewModel(), WebClientListener { } } } - viewModelScope.launch { - coreViewModel.dataLoading.collect { - withContext(Dispatchers.Main) { - dataLoading.value = it - } - } - } - coreViewModel.limeSurveyLink?.let { flow -> - viewModelScope.launch { - flow.collect { - withContext(Dispatchers.Main) { - limeSurveyLink.value = it - } - } - } - } - - } - - fun setModel( - scheduleId: String? = null, - observationId: String? = null, - notificationId: String? = null - ) { - if (!scheduleId.isNullOrBlank()) { - coreViewModel.setScheduleId(scheduleId, notificationId) - } else if (!observationId.isNullOrBlank()) { - coreViewModel.setObservationId(observationId, notificationId) - } } fun viewDidAppear() { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionViewModel.kt new file mode 100644 index 000000000..0d56ddd14 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionViewModel.kt @@ -0,0 +1,51 @@ +package io.redlink.more.app.android.activities.observations.questionnaire + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.models.QuestionType +import io.redlink.more.viewModels.simpleQuestion.QuestionCoreViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class QuestionViewModel( + scheduleId: String?, + notificationId: String?, + observationId: String? +) : + ViewModel() { + val coreViewModel: QuestionCoreViewModel = QuestionCoreViewModel( + MoreApplication.shared!!.repositories, + MoreApplication.shared!!.observationFactory, + scheduleId, + notificationId, + observationId + ) + + val hasData = mutableStateOf(false) + + init { + viewModelScope.launch(Dispatchers.Main.immediate) { + coreViewModel.questionModel.collect { model -> + withContext(Dispatchers.Main) { + hasData.value = model?.isValidModel() ?: false + } + } + } + } + + fun viewDidAppear() { + coreViewModel.viewDidAppear() + } + + fun viewDidDisappear() { + coreViewModel.viewDidDisappear() + hasData.value = false + } + + fun finish(type: QuestionType, answer: Any) { + coreViewModel.finishQuestion(answer) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireButtons.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireButtons.kt index a04cbea94..beeaa825a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireButtons.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireButtons.kt @@ -26,15 +26,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.navigation.NavController import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.app.android.ui.theme.morePrimary - +import io.redlink.more.app.android.theme.morePrimary +import io.redlink.more.models.QuestionType @Composable -fun QuestionnaireButtons(navController: NavController, model: QuestionnaireViewModel) { +fun QuestionnaireButtons( + questionType: QuestionType, + selectedAnswer: Any?, + onFinish: (Any) -> Unit +) { val context = LocalContext.current Column( @@ -43,20 +45,23 @@ fun QuestionnaireButtons(navController: NavController, model: QuestionnaireViewM modifier = Modifier .fillMaxSize() .padding(bottom = 20.dp) - ) { Button( onClick = { - if (model.answerSet.value.isNotBlank()) { - model.finish() - navController.navigate(NavigationScreen.QUESTIONNAIRE_RESPONSE.routeWithParameters()) + val isValid = when (questionType) { + QuestionType.SINGLE_CHOICE -> (selectedAnswer as? String)?.isNotBlank() == true + QuestionType.MULTIPLE_CHOICE -> (selectedAnswer as? List<*>)?.isNotEmpty() == true + else -> selectedAnswer != null + } + + if (isValid) { + onFinish(selectedAnswer!!) } else { Toast.makeText( context, stringResource(R.string.more_questionnaire_select), Toast.LENGTH_SHORT - ) - .show() + ).show() } }, colors = ButtonDefaults.morePrimary(), diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireHeader.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireHeader.kt index f15f2e2bb..ecd6fce6e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireHeader.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireHeader.kt @@ -21,25 +21,31 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.shared_composables.HeaderDescription import io.redlink.more.app.android.shared_composables.HeaderTitle -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.theme.MoreColors @Composable -fun QuestionnaireHeader(model: QuestionnaireViewModel) { - Column(modifier = Modifier - .fillMaxWidth() - .padding(2.dp)) +fun QuestionnaireHeader(model: QuestionViewModel) { + val observation by model.coreViewModel.questionModel.collectAsStateWithLifecycle(null) + val title = observation?.observationTitle ?: "" + val info = observation?.participantInfo ?: "" + Column( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + ) { - HeaderTitle(title = model.observationTitle.value) + HeaderTitle(title = title) Spacer(Modifier.height(12.dp)) LazyColumn( @@ -59,7 +65,7 @@ fun QuestionnaireHeader(model: QuestionnaireViewModel) { ) } item { - HeaderDescription(description = model.observationParticipantInfo.value) + HeaderDescription(description = info) } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireRadioButtons.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireRadioButtons.kt index e2b394073..1de72abfa 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireRadioButtons.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireRadioButtons.kt @@ -10,53 +10,52 @@ */ package io.redlink.more.app.android.activities.observations.questionnaire +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.selectable import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import io.redlink.more.app.android.ui.theme.MoreColors +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.redlink.more.app.android.theme.MoreColors @Composable -fun QuestionnaireRadioButtons(model: QuestionnaireViewModel) { - val selectedValue = remember { mutableStateOf("") } - - val isSelectedItem: (String) -> Boolean = { selectedValue.value == it } - val onChangeState: (String) -> Unit = { - selectedValue.value = it - model.setAnswer(it) - } - - val items = remember { - model.answers - } +fun QuestionnaireRadioButtons( + model: QuestionViewModel, + selectedAnswer: Any?, + onAnswerSelected: (Any) -> Unit +) { + val observation by model.coreViewModel.questionModel.collectAsStateWithLifecycle(null) + val answers = (observation?.answers ?: mutableSetOf()).toList() LazyColumn { - items(items) { item -> + items(answers.size) { idx -> + val item = answers[idx] Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .selectable( - selected = isSelectedItem(item), - onClick = { onChangeState(item) }, - role = Role.RadioButton + selected = (selectedAnswer as? String) == item, + onClick = { onAnswerSelected(item) }, + role = Role.RadioButton, + indication = null, + interactionSource = remember { MutableInteractionSource() } ) .padding(vertical = 8.dp) ) { RadioButton( - selected = isSelectedItem(item), + selected = (selectedAnswer as? String) == item, onClick = null, colors = RadioButtonDefaults.colors( selectedColor = MoreColors.Primary, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireResponseView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireResponseView.kt index c594c5c77..d16ddcd00 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireResponseView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireResponseView.kt @@ -10,6 +10,7 @@ */ package io.redlink.more.app.android.activities.observations.questionnaire +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -32,13 +33,17 @@ import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.shared_composables.HeaderDescription import io.redlink.more.app.android.shared_composables.HeaderTitle import io.redlink.more.app.android.shared_composables.MoreBackground -import io.redlink.more.app.android.ui.theme.morePrimary - +import io.redlink.more.app.android.theme.morePrimary @Composable fun QuestionnaireResponseView(navController: NavController) { val title = stringResource(R.string.more_quest_thank_you) + BackHandler { + navController.navigate(NavigationScreen.DASHBOARD.routeWithParameters()) { + popUpTo(0) { inclusive = true } + } + } MoreBackground { Column( verticalArrangement = Arrangement.SpaceBetween, @@ -59,7 +64,11 @@ fun QuestionnaireResponseView(navController: NavController) { HeaderDescription(description = stringResource(R.string.more_quest_thank_you_full)) } TextButton( - onClick = { navController.navigate(NavigationScreen.DASHBOARD.routeWithParameters()) }, + onClick = { + navController.navigate(NavigationScreen.DASHBOARD.routeWithParameters()) { + popUpTo(0) { inclusive = true } + } + }, colors = ButtonDefaults.morePrimary(), modifier = Modifier .padding(bottom = 16.dp) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt index cfbb23915..6c7fec5c1 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireView.kt @@ -10,43 +10,102 @@ */ package io.redlink.more.app.android.activities.observations.questionnaire +import android.os.Bundle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.core.os.bundleOf +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear +import io.redlink.more.app.android.activities.observations.questionnaire.questionType.QuestionnaireQuestionAnswer import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.ErrorMessage +import io.redlink.more.models.QuestionType + +/** + * Saves the *answer value* (not the question). Extend this when you add new answer shapes. + * + * Supported: + * - SINGLE_CHOICE -> String + * - MULTIPLE_CHOICE -> List + */ +private val AnswerValueSaver: Saver = Saver( + save = { answer -> + when (answer) { + null -> bundleOf("t" to "null") + is String -> bundleOf("t" to "s", "v" to answer) + is List<*> -> bundleOf( + "t" to "l", + "v" to answer.filterIsInstance().toTypedArray() + ) + + else -> bundleOf("t" to "null") + } + }, + restore = { b -> + when (b.getString("t")) { + "s" -> b.getString("v") + "l" -> (b.getStringArray("v") ?: emptyArray()).toList() + else -> null + } + } +) @Composable -fun QuestionnaireView(navController: NavController, viewModel: QuestionnaireViewModel) { +fun QuestionnaireView(navController: NavController, viewModel: QuestionViewModel) { val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.SIMPLE_QUESTION.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() + val route = backStackEntry?.arguments?.getString( + NavigationScreen.QUESTION.routeWithParameters() + ) + + OnAppearDisappear( + { viewModel.viewDidAppear() }, + { viewModel.viewDidDisappear() }) { + var selectedAnswer by rememberSaveable(route, stateSaver = AnswerValueSaver) { + mutableStateOf(null) } - } - if (viewModel.hasData.value) { - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - QuestionnaireQuestionAnswer(model = viewModel) + + if (viewModel.hasData.value) { + val observation by viewModel.coreViewModel.questionModel.collectAsStateWithLifecycle( + null + ) + val type = observation?.type ?: QuestionType.NON + + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + QuestionnaireQuestionAnswer( + model = viewModel, + selectedAnswer = selectedAnswer, + onAnswerSelected = { selectedAnswer = it } + ) + } + + QuestionnaireButtons( + questionType = type, + selectedAnswer = selectedAnswer, + onFinish = { + viewModel.finish(type, it) + navController.navigate(NavigationScreen.QUESTIONNAIRE_RESPONSE.routeWithParameters()) + } + ) + } else { + ErrorMessage(message = "${getStringResource(id = R.string.data_not_found)}!") } - QuestionnaireButtons(navController = navController, model = viewModel) - } else { - ErrorMessage(message = "${getStringResource(id = R.string.data_not_found)}!") } + } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireViewModel.kt deleted file mode 100644 index 968029c99..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireViewModel.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.app.android.activities.observations.questionnaire - -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.viewModels.simpleQuestion.SimpleQuestionCoreViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class QuestionnaireViewModel : ViewModel() { - private val coreViewModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(MoreApplication.shared!!.observationFactory) - - val hasData = mutableStateOf(false) - val observationTitle = mutableStateOf("") - val question = mutableStateOf("") - var answers = mutableStateListOf("") - val answerSet = mutableStateOf("") - val observationParticipantInfo = mutableStateOf("") - - private val scope = CoroutineScope(Dispatchers.Default + Job()) - - init { - scope.launch { - coreViewModel.simpleQuestionModel.collect { model -> - withContext(Dispatchers.Main) { - model?.let { - hasData.value = true - observationTitle.value = it.observationTitle - question.value = it.question - answers.clear() - answers.addAll(it.answers) - observationParticipantInfo.value = it.participantInfo - } ?: kotlin.run { - hasData.value = false - } - } - } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() - hasData.value = false - } - - fun setScheduleId(scheduleId: String, notificationId: String?) { - coreViewModel.setScheduleId(scheduleId, notificationId) - } - - fun setObservationId(observationId: String, notificationId: String?) { - coreViewModel.setScheduleViaObservationId(observationId, notificationId) - } - - fun finish(setObservationToDone: Boolean = true) { - coreViewModel.finishQuestion(answerSet.value, setObservationToDone) - } - - fun setAnswer(answer: String) { - answerSet.value = answer - } -} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireCheckboxes.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireCheckboxes.kt new file mode 100644 index 000000000..7a20607cc --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireCheckboxes.kt @@ -0,0 +1,82 @@ +package io.redlink.more.app.android.activities.observations.questionnaire.questionType + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.redlink.more.app.android.activities.observations.questionnaire.QuestionViewModel +import io.redlink.more.app.android.theme.MoreColors + +@Composable +fun QuestionnaireCheckboxes( + model: QuestionViewModel, + selectedAnswer: Any?, + onAnswerSelected: (Any) -> Unit +) { + val observation by model.coreViewModel.questionModel.collectAsStateWithLifecycle(null) + val answers = (observation?.answers ?: mutableSetOf()).toList() + + val selected: Set = (selectedAnswer as? List<*>) + ?.filterIsInstance() + ?.toSet() + ?: emptySet() + + LazyColumn { + items(answers.size) { idx -> + val item = answers[idx] + val checked = selected.contains(item) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .toggleable( + value = checked, + onValueChange = { isChecked -> + val newSelection = if (isChecked) selected + item else selected - item + + val ordered = answers.filter { it in newSelection } + + onAnswerSelected(ordered) + }, + role = Role.Checkbox, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(vertical = 8.dp) + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + colors = CheckboxDefaults.colors( + checkedColor = MoreColors.Primary, + uncheckedColor = MoreColors.Primary, + disabledColor = MoreColors.SecondaryMedium + ), + modifier = Modifier.padding(4.dp) + ) + Text( + text = item, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireQuestionAnswer.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireQuestionAnswer.kt similarity index 53% rename from androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireQuestionAnswer.kt rename to androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireQuestionAnswer.kt index 4a04969bb..0d9f26e02 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireQuestionAnswer.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireQuestionAnswer.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.app.android.activities.observations.questionnaire +package io.redlink.more.app.android.activities.observations.questionnaire.questionType import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,25 +18,40 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.redlink.more.app.android.activities.observations.questionnaire.QuestionViewModel +import io.redlink.more.app.android.activities.observations.questionnaire.QuestionnaireRadioButtons +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.models.QuestionType @Composable -fun QuestionnaireQuestionAnswer(model: QuestionnaireViewModel) { +fun QuestionnaireQuestionAnswer( + model: QuestionViewModel, + selectedAnswer: Any?, + onAnswerSelected: (Any) -> Unit +) { + val observation by model.coreViewModel.questionModel.collectAsStateWithLifecycle(null) + val question = observation?.question ?: "" + val type = observation?.type ?: QuestionType.NON + Spacer(Modifier.height(16.dp)) Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) ) { Text( - text = model.question.value, + text = question, maxLines = 5, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold, @@ -47,7 +62,23 @@ fun QuestionnaireQuestionAnswer(model: QuestionnaireViewModel) { Spacer(Modifier.height(12.dp)) - QuestionnaireRadioButtons(model = model) + when (type) { + QuestionType.SINGLE_CHOICE -> QuestionnaireRadioButtons( + model = model, + selectedAnswer = selectedAnswer, + onAnswerSelected = onAnswerSelected + ) + + QuestionType.MULTIPLE_CHOICE -> QuestionnaireCheckboxes( + model, + selectedAnswer = selectedAnswer, + onAnswerSelected = onAnswerSelected + ) + + else -> { + // Keep dynamic: each new subview should call onAnswerSelected(...) with the correct type. + } + } Spacer(Modifier.height(4.dp)) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt index 79036bc24..412827e7a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/runningSchedules/RunningSchedulesView.kt @@ -17,51 +17,45 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.dashboard.schedule.list.ScheduleListView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel import io.redlink.more.app.android.shared_composables.ScheduleListHeader @Composable -fun RunningSchedulesView(viewModel: ScheduleViewModel, navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.RUNNING_SCHEDULES.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - Column( - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) { - ScheduleListHeader( - viewModel = viewModel, - navController = navController, - taskCompletionBarViewModel = taskCompletionBarViewModel - ) - Spacer(modifier = Modifier.height(10.dp)) - Column { - ScheduleListView( +fun RunningSchedulesView( + viewModel: ScheduleViewModel, + navController: NavController, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = viewModel, navController = navController, - routeString = NavigationScreen.RUNNING_SCHEDULES.routeWithParameters(), - scheduleViewModel = viewModel, - showButton = true + taskCompletionBarViewModel = taskCompletionBarViewModel ) + Spacer(modifier = Modifier.height(10.dp)) + Column { + ScheduleListView( + navController = navController, + scheduleViewModel = viewModel, + showButton = true + ) + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt index 2178cf803..5473ee017 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt @@ -10,7 +10,10 @@ */ package io.redlink.more.app.android.activities.setting +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -18,81 +21,106 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +import androidx.compose.material.Switch +import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.navigation.NavController +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.StringDesc +import io.redlink.more.SharedRes import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.Accordion import io.redlink.more.app.android.shared_composables.BasicText -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.shared_composables.SmallTextButton +import io.redlink.more.app.android.theme.MoreColors @Composable -fun SettingsView( - model: SettingsViewModel, - navController: NavController -) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.SETTINGS.routeWithParameters()) - LaunchedEffect(route) { - model.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - model.viewDidDisappear() - } - } - Column( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth() - ) { - LazyColumn( +fun SettingsView() { + val context = LocalContext.current + val model = remember { SettingsViewModel() } + val needsTracking by model.coreViewModel.needsTracking.collectAsState() + val trackingApproval by model.coreViewModel.allowTracking.collectAsState() + OnAppearDisappear({ model.coreViewModel.viewOpened() }, { model.coreViewModel.viewClosed() }) { + Column( modifier = Modifier + .fillMaxHeight() .fillMaxWidth() - .weight(1f) - .padding(vertical = 16.dp) ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(vertical = 16.dp) + ) { + item { + SmallTextButton( + text = getStringResource(id = R.string.proceed_to_settings_button), + onClick = { model.coreViewModel.openSettings() } + ) + Spacer(Modifier.height(16.dp)) + } + item { + if (needsTracking) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MoreColors.TextDefault, RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + StringDesc.Resource(SharedRes.strings.app_tracking_dialog_title) + .toString(context) + ) - item { - BasicText( - text = getStringResource(id = R.string.more_settings_permission_information), - color = MoreColors.TextDefault, - ) + Switch( + checked = trackingApproval, + onCheckedChange = { isChecked -> + model.coreViewModel.setTrackingPermission(isChecked) + } + ) + } + Divider() + Text( + StringDesc.Resource(SharedRes.strings.app_tracking_dialog_message) + .toString(context) + ) + } + } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) + + BasicText( + text = getStringResource(id = R.string.more_settings_permission_information), + color = MoreColors.TextDefault, + ) - /* - model.permissionModel.value?.let { Spacer(Modifier.height(24.dp)) + } + items(model.permissionModel.value?.consentInfo ?: emptyList()) { consentInfo -> Accordion( - title = getStringResource(id = R.string.more_study_consent), - description = it.studyConsentInfo, + title = consentInfo.title, + description = consentInfo.info, hasCheck = true, hasSmallTitle = true, hasPreview = false ) } - */ - - - } - - items(model.permissionModel.value?.consentInfo ?: emptyList()) { consentInfo -> - Accordion( - title = consentInfo.title, - description = consentInfo.info, - hasCheck = true, - hasSmallTitle = true, - hasPreview = false - ) } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt index 8431c6182..fb2a962b7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsViewModel.kt @@ -14,40 +14,37 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.models.PermissionModel -import io.redlink.more.more_app_mutliplatform.viewModels.settings.CoreSettingsViewModel +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.models.PermissionModel +import io.redlink.more.viewModels.settings.CoreSettingsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class SettingsViewModel : ViewModel() { - private var coreSettingsViewModel = CoreSettingsViewModel(MoreApplication.shared!!) - val study = mutableStateOf(null) + val coreViewModel = + CoreSettingsViewModel( + MoreApplication.shared!!.repositories, + MoreApplication.shared!!.sharedStorageRepository + ) + val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) init { + coreViewModel.setExitStudyObserver(MoreApplication.shared) viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.study.collect { + coreViewModel.study.collect { withContext(Dispatchers.Main) { study.value = it } } } viewModelScope.launch(Dispatchers.IO) { - coreSettingsViewModel.permissionModel.collect { + coreViewModel.permissionModel.collect { withContext(Dispatchers.Main) { permissionModel.value = it } } } } - - fun viewDidAppear() { - coreSettingsViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreSettingsViewModel.viewDidDisappear() - } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt index b1ef7f4f4..bbeeff34c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsView.kt @@ -19,15 +19,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.studyDetails.composables.AccordionWithList import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel @@ -37,64 +37,63 @@ import io.redlink.more.app.android.extensions.jvmLocalDateTime import io.redlink.more.app.android.shared_composables.AccordionReadMore import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.HeaderTitle -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.theme.MoreColors @Composable -fun StudyDetailsView(navController: NavController, viewModel: StudyDetailsViewModel, taskCompletionBarViewModel: TaskCompletionBarViewModel) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.STUDY_DETAILS.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } +fun StudyDetailsView( + navController: NavController, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { + val viewModel = remember { StudyDetailsViewModel() } + val studyInfo by viewModel.coreViewModel.studyModel.collectAsStateWithLifecycle() + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + studyInfo?.let { + Column( + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + ) { + LazyColumn { + item { + HeaderTitle(title = it.study.studyTitle) + Spacer(Modifier.height(12.dp)) + TaskCompletionBarView(taskCompletionBarViewModel) + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + if (it.study.start != null && it.study.end != null) { + BasicText(text = "${getStringResource(R.string.study_duration)}: ") + BasicText( + text = "${ + it.study.start!!.jvmLocalDateTime().formattedString() + } - ${ + it.study.end!!.jvmLocalDateTime().formattedString() + }", + color = MoreColors.Secondary + ) + } - viewModel.model.value?.let { - Column( - verticalArrangement = Arrangement.Top, - modifier = Modifier - .fillMaxSize() - ) { - LazyColumn { - item { - HeaderTitle(title = it.study.studyTitle) - Spacer(Modifier.height(12.dp)) - TaskCompletionBarView(taskCompletionBarViewModel) - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - if (it.study.start?.epochSeconds != null && it.study.end?.epochSeconds != null) { - BasicText(text = "${getStringResource(R.string.study_duration)}: ") - BasicText( - text = "${it.study.start!!.epochSeconds.jvmLocalDateTime().formattedString()} - ${ - it.study.end!!.epochSeconds.jvmLocalDateTime().formattedString() - }", - color = MoreColors.Secondary - ) } + Spacer(Modifier.height(40.dp)) + AccordionReadMore( + title = getStringResource(R.string.participant_information), + description = it.study.participantInfo, + modifier = Modifier + .fillMaxWidth() + ) + Spacer(Modifier.height(16.dp)) + AccordionWithList( + title = getStringResource(R.string.observation_modules), + observations = it.observations, + navController = navController + ) } - Spacer(Modifier.height(40.dp)) - AccordionReadMore( - title = getStringResource(R.string.participant_information), - description = it.study.participantInfo, - modifier = Modifier - .fillMaxWidth() - ) - Spacer(Modifier.height(16.dp)) - - AccordionWithList( - title = getStringResource(R.string.observation_modules), - observations = it.observations, - navController = navController - ) } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsViewModel.kt index a4d687073..4be7df560 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/StudyDetailsViewModel.kt @@ -10,34 +10,10 @@ */ package io.redlink.more.app.android.activities.studyDetails -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.redlink.more.more_app_mutliplatform.models.StudyDetailsModel -import io.redlink.more.more_app_mutliplatform.viewModels.studydetails.CoreStudyDetailsViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.viewModels.studydetails.CoreStudyDetailsViewModel -class StudyDetailsViewModel: ViewModel() { - private val coreViewModel = CoreStudyDetailsViewModel() - val model = mutableStateOf(null) - - init { - viewModelScope.launch(Dispatchers.IO) { - coreViewModel.studyModel.collect{ - withContext(Dispatchers.Main) { - model.value = it - } - } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() - } +class StudyDetailsViewModel : ViewModel() { + val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/AccordionWithList.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/AccordionWithList.kt index 70bb21b04..4e409489c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/AccordionWithList.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/AccordionWithList.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -35,11 +36,15 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.redlink.more.app.android.shared_composables.MediumTitle import io.redlink.more.app.android.shared_composables.MoreDivider -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.database.entities.ObservationEntity @Composable -fun AccordionWithList(navController: NavController, title: String, observations: List) { +fun AccordionWithList( + navController: NavController, + title: String, + observations: List +) { val open = remember { mutableStateOf(false) } @@ -63,7 +68,10 @@ fun AccordionWithList(navController: NavController, title: String, observations: modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp) - .clickable { + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { open.value = !open.value } ) { @@ -84,6 +92,5 @@ fun AccordionWithList(navController: NavController, title: String, observations: ObservationList(observations = observations, navController = navController) } - } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/ObservationList.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/ObservationList.kt index fd3cacf00..87eda5979 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/ObservationList.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/composables/ObservationList.kt @@ -11,6 +11,7 @@ package io.redlink.more.app.android.activities.studyDetails.composables import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -19,8 +20,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.Icon import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -29,11 +31,12 @@ import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.MediumTitle import io.redlink.more.app.android.shared_composables.MoreDivider -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.navigation.model.NavigationRouteParameter @Composable -fun ObservationList(navController: NavController, observations: List) { +fun ObservationList(navController: NavController, observations: List) { Column( verticalArrangement = Arrangement.Top, modifier = Modifier.fillMaxWidth() @@ -42,10 +45,13 @@ fun ObservationList(navController: NavController, observations: List + activity.finish() + showNewActivityAndClearStack( + activity, + ContentActivity::class.java + ) + } + } + } + ) + ) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ReloadButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ReloadButton.kt new file mode 100644 index 000000000..7d0604250 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ReloadButton.kt @@ -0,0 +1,29 @@ +package io.redlink.more.app.android.activities.subcomponents + +import androidx.compose.material.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.app.android.R +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.shared_composables.SmallTextButton +import io.redlink.more.app.android.theme.MoreColors + +@Composable +fun ReloadButton( + modifier: Modifier = Modifier +) { + val isLoading = remember { mutableStateOf(false) } + SmallTextButton( + text = getStringResource(id = R.string.reload_button), + buttonColors = ButtonDefaults.buttonColors(), + borderStroke = MoreColors.borderPrimary(isLoading.value), + modifier = modifier + ) { + isLoading.value = true + MoreApplication.shared!!.updateStudy() + isLoading.value = false + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarView.kt index 7a2539bab..7d5e1aac8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarView.kt @@ -20,8 +20,9 @@ import io.redlink.more.app.android.shared_composables.ActivityProgressView @Composable fun TaskCompletionBarView(viewModel: TaskCompletionBarViewModel) { - Column(modifier = Modifier - .fillMaxWidth() + Column( + modifier = Modifier + .fillMaxWidth() ) { ActivityProgressView( finishedTasks = viewModel.taskCompletion.value.finishedTasks, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarViewModel.kt index 12e383f0d..f37fde910 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/taskCompletion/TaskCompletionBarViewModel.kt @@ -14,14 +14,16 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.redlink.more.more_app_mutliplatform.models.TaskCompletion -import io.redlink.more.more_app_mutliplatform.viewModels.taskCompletionBar.CoreTaskCompletionBarViewModel +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.models.TaskCompletion +import io.redlink.more.viewModels.taskCompletionBar.CoreTaskCompletionBarViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class TaskCompletionBarViewModel: ViewModel() { - private val coreViewModel = CoreTaskCompletionBarViewModel() +class TaskCompletionBarViewModel : ViewModel() { + private val coreViewModel = + CoreTaskCompletionBarViewModel(MoreApplication.shared!!.repositories) val taskCompletion: MutableState = mutableStateOf(TaskCompletion()) init { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt new file mode 100644 index 000000000..fe5248f90 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt @@ -0,0 +1,108 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.activities.tasks + +import androidx.compose.runtime.Composable +import androidx.navigation.NavController +import io.github.aakira.napier.Napier +import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.shared_composables.SmallTextButton +import io.redlink.more.logging.event +import io.redlink.more.models.ScheduleState +import io.redlink.more.navigation.model.NavigationRouteParameter +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.observationTypes.LimeSurveyType +import io.redlink.more.observations.observationTypes.QuestionType + +@Composable +fun ObservationActionButton( + navController: NavController, + scheduleId: String, + observationType: String, + scheduleState: ScheduleState, + additionalEnableCondition: Boolean = true, + onClick: () -> Unit +) { + SmallTextButton( + text = buttonText(observationType = observationType, scheduleState = scheduleState), + enabled = scheduleState.active() && buttonEnabled( + observationType = observationType, + additionalEnableCondition = additionalEnableCondition + ) + ) { + handleButtonAction( + navController = navController, + scheduleId = scheduleId, + observationType = observationType, + scheduleState = scheduleState, + onClick = onClick + ) + } +} + +@Composable +private fun buttonText( + observationType: String, + scheduleState: ScheduleState +): String { + return when { + QuestionType().matches(observationType) -> getStringResource(id = R.string.more_questionnaire_start) + LimeSurveyType().matches(observationType) -> getStringResource(id = R.string.more_limesurvey_start) + scheduleState == ScheduleState.RUNNING -> getStringResource(id = R.string.more_observation_pause) + else -> getStringResource(id = R.string.more_observation_start) + } +} + +private fun buttonEnabled( + observationType: String, + additionalEnableCondition: Boolean +): Boolean { + val opensSeparateScreen = + QuestionType().matches(observationType) || LimeSurveyType().matches(observationType) + + return if (opensSeparateScreen) { + true + } else { + additionalEnableCondition + } +} + +private fun handleButtonAction( + navController: NavController, + scheduleId: String, + observationType: String, + scheduleState: ScheduleState, + onClick: () -> Unit +) { + val navigationScreen = when { + QuestionType().matches(observationType) -> NavigationScreen.QUESTION + LimeSurveyType().matches(observationType) -> NavigationScreen.LIMESURVEY + else -> null + } + + Napier.event( + LogEvent.BUTTON_PRESS, + "${if (scheduleState == ScheduleState.RUNNING) "Pause" else "Start"} observation $observationType" + ) + + if (navigationScreen != null) { + navController.navigate( + navigationScreen.navigationRoute( + NavigationRouteParameter.SCHEDULE_ID.key to scheduleId + ) + ) + } else { + onClick() + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt index a49e7d50e..599bcdc8a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt @@ -22,16 +22,16 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Square import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R -import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.OnAppearDisappear import io.redlink.more.app.android.activities.observationErrors.ObservationErrorListView import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.jvmLocalDate @@ -40,174 +40,152 @@ import io.redlink.more.app.android.shared_composables.Accordion import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.DatapointCollectionView import io.redlink.more.app.android.shared_composables.HeaderTitle -import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.shared_composables.SmallTextIconButton import io.redlink.more.app.android.shared_composables.TimeframeDays import io.redlink.more.app.android.shared_composables.TimeframeHours -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.moreSecondary2 -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.LimeSurveyType -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.PolarVerityHeartRateType -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.SimpleQuestionType - +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreSecondary2 +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.observationTypes.PolarVerityHeartRateType @Composable fun TaskDetailsView( navController: NavController, - viewModel: TaskDetailsViewModel, - scheduleId: String? + scheduleId: String ) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = - backStackEntry?.arguments?.getString(NavigationScreen.SCHEDULE_DETAILS.routeWithParameters()) - val context = LocalContext.current - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() + val viewModel = + remember { + TaskDetailsViewModel( + MoreApplication.shared!!.dataRecorder, + scheduleId + ) } - } - Column( - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - LazyColumn( - verticalArrangement = Arrangement.Top, + val taskDetails by viewModel.coreViewModel.taskDetailsModel.collectAsStateWithLifecycle() + val dataPoints by viewModel.coreViewModel.dataCount.collectAsStateWithLifecycle() + val taskErrors by viewModel.coreViewModel.taskObservationErrors.collectAsStateWithLifecycle() + val taskErrorActions by viewModel.coreViewModel.taskObservationErrorActions.collectAsStateWithLifecycle() + + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { + Column( + verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() + taskDetails?.let { taskDetails -> + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, ) { - HeaderTitle( - title = viewModel.taskDetailsModel.value.observationTitle, - modifier = Modifier - .weight(0.65f) - .padding(vertical = 11.dp) - ) - if (viewModel.taskDetailsModel.value.state == ScheduleState.RUNNING) - SmallTextIconButton( - text = getStringResource(id = R.string.more_abort), - imageText = getStringResource(id = R.string.more_abort), - image = Icons.Rounded.Square, - imageTint = MoreColors.Important, - borderStroke = MoreColors.borderDefault(), - buttonColors = ButtonDefaults.moreSecondary2() + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() ) { - viewModel.stopObservation() + HeaderTitle( + title = taskDetails.observationTitle, + modifier = Modifier + .weight(0.65f) + .padding(vertical = 11.dp) + ) + if (taskDetails.state == ScheduleState.RUNNING) + SmallTextIconButton( + text = getStringResource(id = R.string.more_abort), + imageText = getStringResource(id = R.string.more_abort), + image = Icons.Rounded.Square, + imageTint = MoreColors.Important, + borderStroke = MoreColors.borderDefault(), + buttonColors = ButtonDefaults.moreSecondary2() + ) { + viewModel.stopObservation() + } } - } - BasicText( - text = viewModel.taskDetailsModel.value.observationType, - color = MoreColors.Secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 14.dp) - ) - - TimeframeDays( - viewModel.taskDetailsModel.value.start.jvmLocalDate(), - viewModel.taskDetailsModel.value.end.jvmLocalDate(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) - TimeframeHours( - viewModel.taskDetailsModel.value.start.jvmLocalDateTime(), - viewModel.taskDetailsModel.value.end.jvmLocalDateTime(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) + BasicText( + text = taskDetails.observationType, + color = MoreColors.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp) + ) - Spacer(modifier = Modifier.height(8.dp)) + TimeframeDays( + taskDetails.start.jvmLocalDate(), + taskDetails.end.jvmLocalDate(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) + TimeframeHours( + taskDetails.start.jvmLocalDateTime(), + taskDetails.end.jvmLocalDateTime(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) - Accordion( - title = getStringResource(id = R.string.participant_information), - description = viewModel.taskDetailsModel.value.participantInformation, - hasCheck = false, - hasPreview = false - ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - } - } + Accordion( + title = getStringResource(id = R.string.participant_information), + description = taskDetails.participantInformation, + hasCheck = false, + hasPreview = false + ) + Spacer(modifier = Modifier.height(8.dp)) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - scheduleId?.let { - if (!viewModel.taskDetailsModel.value.state.completed()) { - DatapointCollectionView( - viewModel.dataPointCount.value, - viewModel.taskDetailsModel.value.state - ) + } } - } - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.CenterHorizontally - ) { - - - ObservationErrorListView( - errors = viewModel.taskObservationErrors, - errorActions = viewModel.taskObservationErrorActions - ) - - if (!viewModel.taskDetailsModel.value.hidden) { - SmallTextButton( - text = if (viewModel.taskDetailsModel.value.state == ScheduleState.RUNNING) getStringResource( - id = R.string.more_observation_pause - ) - else if (viewModel.taskDetailsModel.value.observationType == SimpleQuestionType().observationType) getStringResource( - id = R.string.more_questionnaire_start - ) - else if (viewModel.taskDetailsModel.value.observationType == LimeSurveyType().observationType) getStringResource( - id = R.string.more_limesurvey_start - ) - else getStringResource( - id = R.string.more_observation_start - ), - enabled = viewModel.isEnabled.value && if (viewModel.taskDetailsModel.value.observationType == PolarVerityHeartRateType( - emptySet() - ).observationType - ) viewModel.polarHrReady.value else true + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - if (viewModel.taskDetailsModel.value.observationType == SimpleQuestionType().observationType) { - navController.navigate( - NavigationScreen.SIMPLE_QUESTION.navigationRoute( - "scheduleId" to scheduleId + scheduleId?.let { + if (!taskDetails.state.completed()) { + DatapointCollectionView( + dataPoints, + taskDetails.state ) - ) - } else if (viewModel.taskDetailsModel.value.observationType == LimeSurveyType().observationType) { - navController.navigate( - NavigationScreen.LIMESURVEY.navigationRoute( - "scheduleId" to scheduleId - ) - ) - } else if (viewModel.taskDetailsModel.value.state == ScheduleState.RUNNING) { - viewModel.pauseObservation() - } else { - viewModel.startObservation() + } + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + ObservationErrorListView( + errors = taskErrors, + errorActions = taskErrorActions + ) + + if (!taskDetails.hidden) { + ObservationActionButton( + navController, + taskDetails.scheduleId, + taskDetails.observationType, + taskDetails.state, + if (taskDetails.observationType == PolarVerityHeartRateType( + emptySet() + ).observationType + ) viewModel.polarHrReady.value else true + ) { + if (taskDetails.state == ScheduleState.RUNNING) { + viewModel.pauseObservation() + } else { + viewModel.startObservation() + } + } } } } - } + } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt index 053ebe6ff..6a71798fc 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt @@ -10,101 +10,38 @@ */ package io.redlink.more.app.android.activities.tasks -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication -import io.redlink.more.app.android.observations.HR.PolarHeartRateObservation -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.models.TaskDetailsModel -import io.redlink.more.more_app_mutliplatform.observations.DataRecorder -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.viewModels.tasks.CoreTaskDetailsViewModel +import io.redlink.more.observations.DataRecorder +import io.redlink.more.services.bluetooth.polar.PolarStates +import io.redlink.more.viewModels.tasks.CoreTaskDetailsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class TaskDetailsViewModel( - dataRecorder: DataRecorder + dataRecorder: DataRecorder, + scheduleId: String ) : ViewModel() { - private val coreViewModel: CoreTaskDetailsViewModel = CoreTaskDetailsViewModel(dataRecorder) - val isEnabled = mutableStateOf(false) - val polarHrReady = mutableStateOf(false) - val dataPointCount = mutableStateOf(0L) - val taskDetailsModel = mutableStateOf( - TaskDetailsModel( - "", "", "", "", 0, 0, "", false, ScheduleState.DEACTIVATED + val coreViewModel: CoreTaskDetailsViewModel = + CoreTaskDetailsViewModel( + MoreApplication.shared!!.repositories, + dataRecorder, + scheduleId ) - ) - val taskObservationErrors = mutableStateListOf() - val taskObservationErrorActions = mutableStateListOf() - private var observationErrors: Map> = emptyMap() + val polarHrReady = mutableStateOf(false) init { viewModelScope.launch(Dispatchers.IO) { - PolarHeartRateObservation.hrReady.collect { + PolarStates.hrFeatureReady.collect { withContext(Dispatchers.Main) { polarHrReady.value = it } } } - viewModelScope.launch { - coreViewModel.taskDetailsModel.collect { details -> - details?.let { - withContext(Dispatchers.Main) { - taskDetailsModel.value = it - isEnabled.value = it.state.active() - withContext(Dispatchers.Main) { - taskObservationErrors.clear() - taskObservationErrorActions.clear() - observationErrors[taskDetailsModel.value.observationType]?.let { - val (actions, messages) = it.partition { it == Observation.ERROR_DEVICE_NOT_CONNECTED } - taskObservationErrors.addAll(messages) - taskObservationErrorActions.addAll(actions) - } - } - } - } - } - } - viewModelScope.launch { - coreViewModel.dataCount.collect { - withContext(Dispatchers.Main) { - dataPointCount.value = it - } - } - } - - viewModelScope.launch { - MoreApplication.shared!!.observationFactory.observationErrors.collect { - observationErrors = it - if (taskDetailsModel.value.observationType != "") { - withContext(Dispatchers.Main) { - taskObservationErrors.clear() - taskObservationErrorActions.clear() - observationErrors[taskDetailsModel.value.observationType]?.let { - val (actions, messages) = it.partition { it == Observation.ERROR_DEVICE_NOT_CONNECTED } - taskObservationErrors.addAll(messages) - taskObservationErrorActions.addAll(actions) - } - } - } - } - } - } - - fun setSchedule(scheduleId: String) { - coreViewModel.setSchedule(scheduleId) - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() } fun startObservation() { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyWebClient.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt similarity index 85% rename from androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyWebClient.kt rename to androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt index f84414fd5..56c52257a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/limeSurvey/LimeSurveyWebClient.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt @@ -8,23 +8,19 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.app.android.activities.observations.limeSurvey + +package io.redlink.more.app.android.activities.web import android.graphics.Bitmap import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import io.github.aakira.napier.Napier import io.github.aakira.napier.log +import io.redlink.more.logging.event +import io.redlink.more.observations.appUsage.model.LogEvent - -interface WebClientListener { - fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest) - - fun isLoading(loading: Boolean) -} - - -class LimeSurveyWebClient : WebViewClient(){ +class WebClient : WebViewClient() { private var clientListener: WebClientListener? = null fun setListener(webClientListener: WebClientListener) { @@ -49,10 +45,9 @@ class LimeSurveyWebClient : WebViewClient(){ override fun onPageCommitVisible(view: WebView?, url: String?) { super.onPageCommitVisible(view, url) - log { "WebViewClient\$onPageCommitVisible: $url" } + Napier.event(LogEvent.URL_OPEN, "WebView: $url") } - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { request?.let { log { "WebViewClient\$shouldOverrideUrlLoading: url: ${it.url}; headers: ${it.url}; isForMainFrame: ${it.isForMainFrame}; method: ${it.method}; isRedirect: ${it.isRedirect}" } @@ -60,6 +55,4 @@ class LimeSurveyWebClient : WebViewClient(){ } return super.shouldOverrideUrlLoading(view, request) } -} - - +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/MapExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt similarity index 64% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/MapExtension.kt rename to androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt index 4bc664caa..4959272de 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/MapExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt @@ -8,12 +8,14 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema +package io.redlink.more.app.android.activities.web -fun Map>.equalsTo(other: Map>) { - if (keys == other.keys) { +import android.webkit.WebResourceRequest +import android.webkit.WebView - } +interface WebClientListener { + fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest) + + fun isLoading(loading: Boolean) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/broadcasts/NotificationBroadcastReceiver.kt b/androidApp/src/main/java/io/redlink/more/app/android/broadcasts/NotificationBroadcastReceiver.kt index 1153a6cab..9be7f2853 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/broadcasts/NotificationBroadcastReceiver.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/broadcasts/NotificationBroadcastReceiver.kt @@ -13,24 +13,91 @@ package io.redlink.more.app.android.broadcasts import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import io.github.aakira.napier.Napier import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager +import io.redlink.more.app.android.util.AlarmUtils +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.services.notification.NotificationManager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class NotificationBroadcastReceiver : BroadcastReceiver() { - private var notificationRepository = MoreApplication.shared!!.notificationManager.notificationRepository - - private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob()) override fun onReceive(context: Context, intent: Intent?) { - intent?.let { intent -> - if (intent.action == NOTIFICATION_SET_ON_READ_ACTION) { - intent.getStringExtra(NotificationManager.MSG_ID)?.let { key -> - scope.launch { - notificationRepository.setNotificationReadStatus(key, true) + Napier.d(tag = "NotificationBroadcastReceiver") { "onReceive: ${intent?.action}" } + if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { + Napier.i(tag = "NotificationBroadcastReceiver") { "Boot completed. Rescheduling observation reminders." } + val pendingResult = goAsync() + scope.launch { + try { + MoreApplication.initShared(context) + MoreApplication.shared?.observationService?.rescheduleObservationRemindersAfterBoot() + Napier.i(tag = "NotificationBroadcastReceiver") { "Observation reminders rescheduled after boot." } + } catch (t: Throwable) { + Napier.e(tag = "NotificationBroadcastReceiver", throwable = t) { + "Failed rescheduling reminders after boot." + } + } finally { + pendingResult.finish() + } + } + return + } + + if (intent?.action == SCHEDULED_NOTIFICATION_ACTION) { + val notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID) ?: return + val channelId = intent.getStringExtra(EXTRA_CHANNEL_ID) + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val message = intent.getStringExtra(EXTRA_MESSAGE) ?: "" + val deepLink = intent.getStringExtra(EXTRA_DEEP_LINK) + + Napier.i(tag = "NotificationBroadcastReceiver") { "Received scheduled notification: $notificationId" } + + val notification = NotificationEntity( + notificationId, + channelId, + title, + message, + deepLink = deepLink + ) + + AlarmUtils.removeAlarmId(context, notificationId.hashCode()) + val pendingResult = goAsync() + scope.launch { + try { + MoreApplication.shared?.notificationManager?.displayNotification( + notification + ) + Napier.d(tag = "NotificationBroadcastReceiver") { + "Displayed scheduled notification ${notification.notificationId}" + } + + Napier.d(tag = "NotificationBroadcastReceiver") { "Calling scheduleObservationReminder()" } + MoreApplication.shared?.observationService?.scheduleObservationReminder() + Napier.d(tag = "NotificationBroadcastReceiver") { "scheduleObservationReminder() returned" } + } catch (t: Throwable) { + Napier.e(tag = "NotificationBroadcastReceiver", throwable = t) { + "Failed handling scheduled notification ${notification.notificationId}" + } + } finally { + pendingResult.finish() + } + } + + return + } + + if (intent?.action == NOTIFICATION_SET_ON_READ_ACTION) { + intent.getStringExtra(NotificationManager.MSG_ID)?.let { key -> + val pendingResult = goAsync() + scope.launch { + try { + MoreApplication.shared?.repositories?.notification + ?.setNotificationReadStatus(key, true) + } finally { + pendingResult.finish() } } } @@ -38,6 +105,14 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { } companion object { - const val NOTIFICATION_SET_ON_READ_ACTION = "io.redlink.more.app.android.NOTIFICATION_ACTION_READ" + const val NOTIFICATION_SET_ON_READ_ACTION = + "io.redlink.more.app.android.NOTIFICATION_ACTION_READ" + const val SCHEDULED_NOTIFICATION_ACTION = + "io.redlink.more.app.android.SCHEDULED_NOTIFICATION" + const val EXTRA_NOTIFICATION_ID = "extra_notification_id" + const val EXTRA_CHANNEL_ID = "extra_channel_id" + const val EXTRA_TITLE = "extra_title" + const val EXTRA_MESSAGE = "extra_message" + const val EXTRA_DEEP_LINK = "extra_deep_link" } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt index 1c2d27274..b95f064f7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt @@ -10,7 +10,6 @@ */ package io.redlink.more.app.android.extensions - import android.app.Activity import android.app.TaskStackBuilder import android.content.Context @@ -27,23 +26,28 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.DefaultAlpha import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource @Composable @ReadOnlyComposable fun getStringResource(@StringRes id: Int): String = - LocalContext.current.resources.getText(id).toString() + LocalResources.current.getText(id).toString() @Composable @ReadOnlyComposable fun getStringResourceByName(name: String): String { - val resourceId = LocalContext.current.resources.getIdentifier( + val resourceId = LocalResources.current.getIdentifier( name, "string", LocalContext.current.packageName ) - return getStringResource(resourceId) + return if (resourceId != 0) { + getStringResource(resourceId) + } else { + name + } } @Composable diff --git a/androidApp/src/main/java/io/redlink/more/app/android/extensions/DateTimeConversion.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/DateTimeConversion.kt index 1565cbb41..0926273f7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/extensions/DateTimeConversion.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/extensions/DateTimeConversion.kt @@ -17,25 +17,29 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.* +import java.util.Date fun Long.jvmLocalDateTime(): LocalDateTime { - return LocalDateTime.ofEpochSecond( - this, - 0, - ZoneId.systemDefault().rules.getOffset(Instant.ofEpochSecond(this)) + return LocalDateTime.ofInstant( + Instant.ofEpochSecond(this), + ZoneId.systemDefault() ) } fun Long.jvmLocalDateTimeFromMilliseconds(): LocalDateTime { - return LocalDateTime.ofEpochSecond( - this / 1000, - 0, - ZoneId.systemDefault().rules.getOffset(Instant.ofEpochSecond(this / 1000)) // convert milliseconds to seconds + return LocalDateTime.ofInstant( + Instant.ofEpochMilli(this), + ZoneId.systemDefault() ) } -fun Long.jvmLocalDate(): LocalDate = this.jvmLocalDateTime().toLocalDate() +fun Long.jvmLocalDateTimeFromEpochSeconds(): LocalDateTime { + return this.jvmLocalDateTime() +} + +fun Long.jvmLocalDate(): LocalDate { + return this.jvmLocalDateTime().toLocalDate() +} fun LocalDate.formattedString(pattern: String = "dd.MM.yyyy"): String { val formatter = DateTimeFormatter.ofPattern(pattern) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceExtension.kt index 014d799d8..cccda097a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceExtension.kt @@ -11,19 +11,28 @@ package io.redlink.more.app.android.extensions import android.content.Context +import android.os.Build import android.provider.Settings +import dev.icerock.moko.resources.StringResource +import io.redlink.more.Shared.Companion.getSharedResource import io.redlink.more.app.android.BuildConfig import io.redlink.more.app.android.MoreApplication - fun stringResource(id: Int) = MoreApplication.appContext?.getString(id) ?: "" -fun getQuantityString(id: Int, count: Int, formatArgs: Any) = MoreApplication.appContext?.resources?.getQuantityString(id, count, formatArgs) ?: "" +fun getQuantityString(id: Int, count: Int, formatArgs: Any) = + MoreApplication.appContext?.resources?.getQuantityString(id, count, formatArgs) + ?: "" + +fun getSystemService(serviceClass: Class): T? = + MoreApplication.appContext?.getSystemService(serviceClass) -fun getSystemService(serviceClass: Class): T? = MoreApplication.appContext?.getSystemService(serviceClass) +fun getSecureID(context: Context) = + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) -fun getSecureID(context: Context) = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) +fun getProductName() = Build.PRODUCT -fun getProductName() = android.os.Build.PRODUCT +fun sharedResource(id: StringResource): String = + getSharedResource(id).toString(context = MoreApplication.appContext!!) const val applicationId = BuildConfig.APPLICATION_ID \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceMappingExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceMappingExtension.kt index 440682718..2a7636977 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceMappingExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceMappingExtension.kt @@ -10,11 +10,13 @@ */ package io.redlink.more.app.android.extensions +import io.redlink.more.SharedRes import io.redlink.more.app.android.R -import io.redlink.more.more_app_mutliplatform.models.DateFilterModel +import io.redlink.more.models.DateFilterModel +import io.redlink.more.models.NotificationFilterTypeModel fun String.formatDateFilterString(): String { - return when(this) { + return when (this) { DateFilterModel.TODAY_AND_TOMORROW.toString() -> stringResource(R.string.more_filter_today_tomorrow) DateFilterModel.ONE_WEEK.toString() -> stringResource(R.string.more_filter_week) DateFilterModel.ONE_MONTH.toString() -> stringResource(R.string.more_filter_month) @@ -23,12 +25,10 @@ fun String.formatDateFilterString(): String { } -fun String.formatObservationTypeString(): String { - return when(this) { - "question-observation" -> stringResource(R.string.more_filter_question) - "gps-mobile-observation" -> stringResource(R.string.more_filter_gps) - "acc-mobile-observation" -> stringResource(R.string.more_filter_accelerometer) - "polar-verity-observation" -> stringResource(R.string.more_filter_polar) - else -> "Unknown Type Filter" +fun String.formatNotificationFilterString(): String { + return when (this) { + NotificationFilterTypeModel.UNREAD.toString() -> sharedResource(SharedRes.strings.more_filter_notification_unread) + NotificationFilterTypeModel.IMPORTANT.toString() -> sharedResource(SharedRes.strings.more_filter_notification_important) + else -> sharedResource(SharedRes.strings.more_filter_notification_all) } -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/extensions/StringExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/StringExtension.kt index d1f200bba..3f12fdb5c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/extensions/StringExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/extensions/StringExtension.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.style.TextDecoration -import io.redlink.more.more_app_mutliplatform.util.RegexData +import io.redlink.more.util.RegexData fun String.toAnnotatedString(): AnnotatedString { val urlStyle = SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline) @@ -40,3 +40,6 @@ fun String.toAnnotatedString(): AnnotatedString { return builder.toAnnotatedString() } + +// app-usage -> app_usage +fun String.observationTypeToResource(): String = this.replace("-", "_") diff --git a/androidApp/src/main/java/io/redlink/more/app/android/firebase/FCMService.kt b/androidApp/src/main/java/io/redlink/more/app/android/firebase/FCMService.kt index 2e2945637..6b162a2b6 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/firebase/FCMService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/firebase/FCMService.kt @@ -14,7 +14,7 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import io.github.aakira.napier.Napier import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema +import io.redlink.more.database.entities.NotificationEntity import java.util.UUID /* @@ -23,29 +23,35 @@ Service to handle push notifications and firebase connections class FCMService : FirebaseMessagingService() { override fun onNewToken(token: String) { - Napier.i( "Refreshed token: $token", tag = "FCMService::onNewToken") + Napier.i("Refreshed token: $token", tag = "FCMService::onNewToken") MoreApplication.shared!!.notificationManager.newFCMToken(token) } override fun onMessageReceived(message: RemoteMessage) { if (message.data.isNotEmpty() || message.notification != null) { - Napier.i(tag = "FCMService::onMessageReceived") { message.daoFromRemoteMessage().toString()} - MoreApplication.shared!!.notificationManager.storeAndHandleNotification(MoreApplication.shared!!, message.daoFromRemoteMessage(), true) + Napier.i(tag = "FCMService::onMessageReceived") { + message.daoFromRemoteMessage().toString() + } + MoreApplication.shared!!.notificationManager.storeAndHandleNotification( + message.daoFromRemoteMessage(), + true + ) } } } -fun RemoteMessage.daoFromRemoteMessage(): NotificationSchema { +fun RemoteMessage.daoFromRemoteMessage(): NotificationEntity { val notificationId = this.data["MSG_ID"] ?: UUID.randomUUID().toString() - return NotificationSchema.toSchema( + return NotificationEntity.toEntity( notificationId = notificationId, title = this.notification?.title, notificationBody = this.notification?.body, read = false, + completed = false, userFacing = this.notification != null, priority = 1, notificationData = this.data, channelId = null, - timestamp = sentTime + timestamp = sentTime / 1000 ) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidDataRecorder.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidDataRecorder.kt index 2b5e5f43c..1231f2230 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidDataRecorder.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidDataRecorder.kt @@ -12,7 +12,7 @@ package io.redlink.more.app.android.observations import io.github.aakira.napier.Napier import io.redlink.more.app.android.services.ObservationRecordingService -import io.redlink.more.more_app_mutliplatform.observations.DataRecorder +import io.redlink.more.observations.DataRecorder class AndroidDataRecorder : DataRecorder { override fun start(scheduleId: String) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt index 2de009051..8acc75ded 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt @@ -11,30 +11,127 @@ package io.redlink.more.app.android.observations import android.content.Context +import android.os.Build import androidx.work.Constraints import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo import androidx.work.WorkManager +import io.github.aakira.napier.Napier +import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.workers.DataUploadWorker -import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManager +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.ObservationDataManager +import io.redlink.more.scopes.Scope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.util.UUID -class AndroidObservationDataManager(context: Context) : ObservationDataManager() { - private val workManager = WorkManager.getInstance(context) +class AndroidObservationDataManager(context: Context, repository: MainRepository) : + ObservationDataManager(repository) { + private val workManager: WorkManager? = try { + WorkManager.getInstance(context) + } catch (e: IllegalStateException) { + Napier.e(tag = "AndroidObservationDataManager::workManager::init") { "Error init WorkManager: ${e.message}" } + null + } private val workerConstraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() - override fun sendData(onCompletion: (Boolean) -> Unit) { - val dataWorker = OneTimeWorkRequestBuilder() - .setConstraints(workerConstraints) - .build() - workManager.enqueueUniqueWork( - DataUploadWorker.WORKER_TAG, - ExistingWorkPolicy.KEEP, - dataWorker) - onCompletion(true) + override fun sendData(immediately: Boolean, onCompletion: (Boolean) -> Unit) { + Scope.launch { + if (!immediately && workManager != null) { + onCompletion(tryWorkManagerThenFallback()) + } else { + if (workManager == null) { + Napier.w { "WorkManager not available, falling back to direct upload..." } + } else { + Napier.i { "Immediate upload requested, bypassing WorkManager for direct upload..." } + } + onCompletion(directUploadFallback()) + } + } } + private suspend fun tryWorkManagerThenFallback(): Boolean { + return workManager?.let { workManager -> + val request = OneTimeWorkRequestBuilder() + .setConstraints(workerConstraints) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } + .addTag(DataUploadWorker.WORKER_TAG) + .build() + + workManager.enqueueUniqueWork( + DataUploadWorker.WORKER_TAG, + ExistingWorkPolicy.KEEP, + request + ) + + val result = waitForWorkOrTimeout(request.id, timeoutMs = 25_000L) + Napier.i { "WorkManager work with id ${request.id} finished with state $result" } + when (result) { + WorkInfo.State.SUCCEEDED -> true + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> directUploadFallback() + else -> directUploadFallback() + } + } ?: false + } + + private suspend fun waitForWorkOrTimeout(id: UUID, timeoutMs: Long): WorkInfo.State { + Napier.i { "Waiting for WorkManager work with id $id to finish (max $timeoutMs ms)..." } + return withTimeoutOrNull(timeoutMs) { + workManager?.getWorkInfoByIdFlow(id)?.collect { info -> + info?.let { workInfo -> + Napier.i { "WorkManager work with id $id is in state ${workInfo.state}" } + if (workInfo.state.isFinished) return@collect + } + } + WorkInfo.State.ENQUEUED + } ?: WorkInfo.State.ENQUEUED + } + + private suspend fun directUploadFallback( + maxAttempts: Int = 3, + baseDelayMs: Long = 1_000L + ): Boolean = withContext(Dispatchers.IO) { + val networkService = MoreApplication.shared?.networkService + if (networkService == null) { + Napier.e(tag = "AndroidObservationDataManager::directUploadFallback") { + "NetworkService not available" + } + return@withContext false + } + Napier.i { "Sending data via fallback method..." } + var attemptCount = 0 + while (attemptCount < maxAttempts && isConnected()) { + dataBulk()?.let { bulk -> + if (bulk.dataPoints.isNotEmpty()) { + val (ids, error) = networkService.sendData(bulk) + if (error != null) { + Napier.e { "Error sending data: $error" } + attemptCount++ + delay(baseDelayMs * attemptCount) + continue + } else { + Napier.i { "Successfully sent ${ids.size} data points! Deleting data from local database..." } + deleteAll(ids) + Napier.i { "Successfully deleted ${ids.size} data points!" } + return@withContext true + } + } + } + } + Napier.e { "Max attempts ($maxAttempts) reached, no data points sent!" } + return@withContext false + } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt index 0ef9f2bba..ba1b77d95 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt @@ -18,24 +18,44 @@ import io.redlink.more.app.android.observations.HR.PolarHeartRateObservation import io.redlink.more.app.android.observations.accelerometer.AccelerometerObservation import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.app.android.services.sensorsListener.GPSStateListener -import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManager -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationDataManager +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.MoreScope +import io.redlink.more.scopes.Scope +import io.redlink.more.services.store.SharedStorageRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class AndroidObservationFactory(context: Context, observationDataManager: ObservationDataManager) : - ObservationFactory(observationDataManager) { +class AndroidObservationFactory( + context: Context, + observationDataManager: ObservationDataManager, + repository: MainRepository, + sharedStorageRepository: SharedStorageRepository, + scope: MoreScope = Scope +) : + ObservationFactory( + repository, + sharedStorageRepository, + observationDataManager + ) { init { - observations.addAll( - setOf( - AccelerometerObservation(context), - GPSObservation(context, gpsService = GPSService(context)), - PolarHeartRateObservation() - ) - ) + registerObservation { + AccelerometerObservation(context, repository) + } + registerObservation { + GPSObservation(context, repository, gpsService = GPSService(context)) + } + registerObservation { + PolarHeartRateObservation(repository) + } + registerObservation { + appUsageObservation!! + } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { GPSStateListener.gpsEnabled.collect { withContext(Dispatchers.Main) { super.updateObservationErrors() @@ -43,7 +63,7 @@ class AndroidObservationFactory(context: Context, observationDataManager: Observ } } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { BluetoothStateListener.bluetoothEnabled.collect { withContext(Dispatchers.Main) { super.updateObservationErrors() @@ -51,7 +71,7 @@ class AndroidObservationFactory(context: Context, observationDataManager: Observ } } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { super.studyObservationTypes.collect { studyObservationTypes -> val permissions = super.observations.filter { it.observationType.observationType in studyObservationTypes } @@ -77,4 +97,11 @@ class AndroidObservationFactory(context: Context, observationDataManager: Observ } } + override fun observationPostConstruct(observation: Observation) { + observation.setPermissionObserver( + AndroidObservationPermissionObserver( + permissionRepository + ) + ) + } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt new file mode 100644 index 000000000..01d343a1c --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt @@ -0,0 +1,91 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.observations + +import android.app.Activity +import android.content.Context +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.StringDesc +import io.github.aakira.napier.Napier +import io.redlink.more.SharedRes +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.app.android.util.ActivityProvider +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.logging.event +import io.redlink.more.observations.ObservationPermissionObserver +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.observations.observationTypes.ObservationType +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.services.store.PermissionRepository +import io.redlink.more.services.store.PermissionType + +class AndroidObservationPermissionObserver( + private val permissionRepository: PermissionRepository, + private val context: Context = MoreApplication.appContext!! +) : ObservationPermissionObserver { + + override fun permissionState(observationType: ObservationType): PermissionApprovalState { + if (observationType.observationType == AppUsageObservationType().observationType) { + return permissionRepository.getPermission(PermissionType.APP_TRACKING) + } + + return if (PermissionUtils.hasAllPermissions(observationType.sensorPermissions, context)) { + PermissionApprovalState.GRANTED + } else { + PermissionApprovalState.DECLINED + } + } + + override fun requestPermission(observationType: ObservationType) { + Napier.d { "Requesting permissions for $observationType" } + MoreApplication.shared?.observationFactory?.startRequestingPermissions() + if (observationType.observationType == AppUsageObservationType().observationType) { + if (permissionState(observationType) != PermissionApprovalState.NOT_SET) { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + return + } + AlertController.openAlertDialog( + AlertDialogModel( + title = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_title), + message = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_message), + confirmLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_positive_button), + cancelLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_negative_button), + onConfirm = { + Napier.event(LogEvent.APP_TRACKING_ACCEPTED) + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + }, + onDecline = { + Napier.event(LogEvent.APP_TRACKING_DECLINED) + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + } + ) + ) + return + } + + val activity = (context as? Activity) ?: ActivityProvider.getCurrentActivity() + if (activity != null) { + MoreApplication.shared!!.observationFactory.observation(observationType.observationType) + ?.let { observation -> + PermissionUtils.requestPermissions(observation, activity) { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + } + } ?: run { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + } + } else { + MoreApplication.shared?.observationFactory?.stopRequestingPermissions() + } + } +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt index b95131ec8..f26eb3a93 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSObservation.kt @@ -12,21 +12,16 @@ package io.redlink.more.app.android.observations.GPS import android.Manifest import android.content.Context -import android.content.pm.PackageManager import android.location.LocationManager import android.util.Log -import androidx.core.app.ActivityCompat import com.google.android.gms.location.LocationResult import io.github.aakira.napier.Napier -import io.redlink.more.app.android.MoreApplication -import io.redlink.more.app.android.observations.showPermissionAlertDialog import io.redlink.more.app.android.services.sensorsListener.GPSStateListener -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.GPSType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.GPSType +import io.redlink.more.scopes.Scope +import io.redlink.more.services.store.PermissionApprovalState private const val TAG = "GPSObservation" private val permissions = setOf( @@ -36,27 +31,20 @@ private val permissions = setOf( class GPSObservation( context: Context, + repos: MainRepository, private val gpsService: GPSService -) : Observation(observationType = GPSType(permissions)), GPSListener { +) : Observation(repos, observationType = GPSType(permissions)), + GPSListener { private val locationManager = context.getSystemService(LocationManager::class.java) - private val scope = CoroutineScope(Job() + Dispatchers.IO) - - fun getPermission(): Set = permissions override fun start(): Boolean { - Napier.d { "Trying to start GPS..." } - if (this.hasPermission()) { - val listener = this - scope.launch { - Napier.d { "Registering GPS Service..." } - gpsService.registerForLocationUpdates(listener) - } - return true - } - return false + Napier.d { "Registering GPS Service..." } + gpsService.registerForLocationUpdates(this) + return true } override fun stop(onCompletion: () -> Unit) { + Napier.d { "Unregistering GPS Service..." } this.gpsService.unregisterForLocationUpdates(this) onCompletion() } @@ -69,9 +57,8 @@ class GPSObservation( if (!GPSStateListener.gpsEnabled.value) { errors.add("location_disabled") } - if (!hasPermission()) { + if (this.hasPermission() != PermissionApprovalState.GRANTED) { errors.add("location_permission_not_granted") - showPermissionAlertDialog() } return errors } @@ -79,9 +66,9 @@ class GPSObservation( override fun applyObservationConfig(settings: Map) { try { settings[LOCATION_INTERVAL_MILLIS_KEY]?.toString()?.trim('\"')?.toLong()?.let { - //gpsService.setIntervalMillis(it) + gpsService.setIntervalMillis(it) } - } catch (e: java.lang.Exception) { + } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } } @@ -100,28 +87,14 @@ class GPSObservation( override fun locationAvailable(available: Boolean) { Napier.d { "Location available: $available" } - } - - private fun hasPermission(): Boolean { - return this.hasPermissions(MoreApplication.appContext!!) - } - - private fun hasPermissions(context: Context): Boolean { - getPermission().forEach { permission -> - if (ActivityCompat.checkSelfPermission( - context, - permission - ) == PackageManager.PERMISSION_DENIED - ) { - Napier.d { "Has no GPS permissions!" } - return false + if (!available) { + Scope.launch() { + updateObservationErrors() } } - Napier.d { "Has GPS permissions!" } - return true } companion object { const val LOCATION_INTERVAL_MILLIS_KEY = "location_interval_millis" } -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt index a77030ae6..ebbcf3e57 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/GPS/GPSService.kt @@ -13,16 +13,24 @@ package io.redlink.more.app.android.observations.GPS import android.annotation.SuppressLint import android.content.Context import android.os.Looper -import com.google.android.gms.location.* +import com.google.android.gms.location.Granularity +import com.google.android.gms.location.LocationAvailability +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority import io.github.aakira.napier.Napier private const val TAG = "GPSService" class GPSService(context: Context) { - private val fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) + private val fusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) - private val locationRequest = LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, 1000) + private val locationRequest = + LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) private val locationCallback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { super.onLocationResult(result) @@ -39,11 +47,8 @@ class GPSService(context: Context) { init { setWaitForAccurateLocation(false) -// setDurationMillis(1000) setMinUpdateIntervalMillis(500) - setMaxUpdateAgeMillis(1000) setGranularity(Granularity.GRANULARITY_FINE) -// setMinUpdateDistanceMeters(10f) } fun setPriority(priority: Int) { @@ -90,7 +95,11 @@ class GPSService(context: Context) { fun registerForLocationUpdates(listener: GPSListener) { Napier.d(tag = "GPSService::registerForLocationUpdates") { "Registered new listener!" } this.gpsListener = listener - fusedLocationProviderClient.requestLocationUpdates(locationRequest.build(), locationCallback, Looper.getMainLooper()) + fusedLocationProviderClient.requestLocationUpdates( + locationRequest.build(), + locationCallback, + Looper.getMainLooper() + ) } fun unregisterForLocationUpdates(listener: GPSListener) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarConnectorListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarConnectorListener.kt index 641021b13..d1d085a35 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarConnectorListener.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarConnectorListener.kt @@ -12,7 +12,6 @@ package io.redlink.more.app.android.observations.HR import com.polar.sdk.api.PolarBleApi import com.polar.sdk.api.model.PolarDeviceInfo -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothState interface PolarConnectorListener { fun onPolarFeatureReady(feature: PolarBleApi.PolarBleSdkFeature) @@ -20,6 +19,4 @@ interface PolarConnectorListener { fun onDeviceDisconnected(polarDeviceInfo: PolarDeviceInfo) fun onDeviceConnecting(polarDeviceInfo: PolarDeviceInfo) - - fun onPowerChange(bluetoothState: BluetoothState) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt index eaa2d5ff9..73eac2e20 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt @@ -21,25 +21,34 @@ import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.observations.pauseObservation -import io.redlink.more.app.android.observations.showPermissionAlertDialog import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener -import io.redlink.more.more_app_mutliplatform.extensions.anyNameIn -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.PolarVerityHeartRateType -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.anyNameIn +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.PolarVerityHeartRateType +import io.redlink.more.scopes.Scope +import io.redlink.more.services.bluetooth.BluetoothStateManagement +import io.redlink.more.services.bluetooth.polar.PolarStates +import io.redlink.more.viewModels.bluetoothConnection.PolarController import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update private val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setOf( - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.ACCESS_FINE_LOCATION - ) + if (Build.VERSION.SDK_INT >= 34) { + setOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE + ) + } else { + setOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_FINE_LOCATION + ) + } } else { setOf( Manifest.permission.BLUETOOTH, @@ -47,38 +56,60 @@ private val permissions = ) } -class PolarHeartRateObservation : - Observation(observationType = PolarVerityHeartRateType(permissions)) { - private val deviceManager = BluetoothDeviceManager +class PolarHeartRateObservation(repos: MainRepository) : + Observation( + repos, + observationType = PolarVerityHeartRateType(permissions) + ) { + private val bleManager = BluetoothStateManagement private val deviceIdentifier = setOf("Polar") private val polarConnector = MoreApplication.polarConnector!! private var heartRateDisposable: Disposable? = null private var deviceConnectionListener: Job? = null + private val polarController = PolarController(repos) + + init { + Scope.launch { + polarController.hrFeatureChange.collect { (studyActive, hrReady) -> + if (studyActive) { + if (hrReady) { + MoreApplication.shared!!.observationManager.updateTaskStates() + } else { + pauseObservation( + super.observationType + ) + } + } + } + } + } + override fun start(): Boolean { Napier.d(tag = "PolarHeartRateObservation::start") { "Trying to start Polar Verity Heart Rate Observation..." } if (observerAccessible()) { - val polarDevices = deviceManager.connectedDevices.value.filter { + val polarDevices = bleManager.connectedDevices.value.filter { (it.deviceName?.lowercase()?.contains("polar") ?: false) && it.address != null } return polarDevices.firstOrNull()?.let { try { heartRateDisposable = - polarConnector.polarApi.startHrStreaming(it.address!!).subscribe( - { polarData -> - storeData(mapOf("hr" to polarData.samples[0].hr)) - }, - { error -> - Napier.e( - tag = "PolarHeartRateObservation::start", - message = "HR Recording error: ${error.stackTraceToString()}" - ) - pauseObservation(PolarVerityHeartRateType(emptySet())) - showObservationErrorNotification( - stringResource(R.string.observation_bluetooth_error), - stringResource(R.string.observation_error) - ) - }) + polarConnector.polarApi.startHrStreaming(it.address!!) + .subscribe( + { polarData -> + storeData(mapOf("hr" to polarData.samples[0].hr)) + }, + { error -> + Napier.e( + tag = "PolarHeartRateObservation::start", + message = "HR Recording error: ${error.stackTraceToString()}" + ) + pauseObservation(PolarVerityHeartRateType(emptySet())) + showObservationErrorNotification( + stringResource(R.string.observation_bluetooth_error), + stringResource(R.string.observation_error) + ) + }) deviceConnectionListener = listenToDeviceConnection() true } catch (exception: Exception) { @@ -117,15 +148,20 @@ class PolarHeartRateObservation : val errors = mutableSetOf() if (!hasPermissions(MoreApplication.appContext!!)) { errors.add("error_access_bluetooth") - showPermissionAlertDialog() + PolarStates.hrFeatureReady(false) } if (!BluetoothStateListener.bluetoothEnabled.value) { errors.add("bluetooth_disabled") + PolarStates.hrFeatureReady(false) } - if (!MoreApplication.shared!!.bluetoothController.observerDeviceAccessible(deviceIdentifier)) { + if (!MoreApplication.shared!!.bluetoothController.observerDeviceAccessible( + deviceIdentifier + ) + ) { + PolarStates.hrFeatureReady(false) errors.add("device_not_connected") errors.add(ERROR_DEVICE_NOT_CONNECTED) - } else if (!hrReady.value) { + } else if (!PolarStates.hrFeatureReady.value) { errors.add("hr_unavailable") } return errors @@ -159,37 +195,13 @@ class PolarHeartRateObservation : private fun listenToDeviceConnection(): Job { return Scope.launch { - BluetoothDeviceManager.connectedDevices.collect { devices -> + BluetoothStateManagement.connectedDevices.collect { devices -> if (!deviceIdentifier.anyNameIn(devices)) { pauseObservation(PolarVerityHeartRateType(emptySet())) - hrReady.set(false) - Napier.d(tag = "PolarHeartRateObservation::Companion::listenToDeviceConnection") { "HR Feature removed!" } + PolarStates.hrFeatureReady(false) + Napier.d(tag = "PolarHeartRateObservation:::listenToDeviceConnection") { "HR Feature removed!" } } } }.second } - - companion object { - val hrReady: MutableStateFlow = MutableStateFlow(false) - - fun setHRFeature(state: Boolean) { - if (state) { - if (!hrReady.value) { - MoreApplication.shared!!.observationManager.startObservationType( - PolarVerityHeartRateType( - emptySet() - ).observationType - ) - Napier.d(tag = "PolarHeartRateObservation::Companion::setHRFeature") { "HR Feature Ready!" } - } - } else { - Observation.pauseObservation( - PolarVerityHeartRateType( - emptySet() - ) - ) - } - hrReady.update { state } - } - } -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarObserverCallback.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarObserverCallback.kt index 80b2e514d..eb741745c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarObserverCallback.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarObserverCallback.kt @@ -10,38 +10,22 @@ */ package io.redlink.more.app.android.observations.HR - import com.polar.androidcommunications.api.ble.model.DisInfo import com.polar.sdk.api.PolarBleApi import com.polar.sdk.api.PolarBleApiCallback import com.polar.sdk.api.model.PolarDeviceInfo +import com.polar.sdk.api.model.PolarHealthThermometerData import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothState +import io.redlink.more.services.bluetooth.BluetoothStateManagement import java.util.UUID class PolarObserverCallback : PolarBleApiCallback() { - - private var listeners: MutableSet = mutableSetOf() - var connectionListener: PolarConnectorListener? = null - fun addListener(listener: HeartRateListener) { - this.listeners.add(listener) - } - - fun removeListener(listener: HeartRateListener): Int { - this.listeners.remove(listener) - return this.listeners.size - } - - private fun updateListeners(update: (HeartRateListener) -> Unit) { - listeners.forEach(update) - } - override fun blePowerStateChanged(powered: Boolean) { super.blePowerStateChanged(powered) Napier.d("BLE power: $powered", tag = "PolarObserverCallback::blePowerStateChanged") - connectionListener?.onPowerChange(if (powered) BluetoothState.ON else BluetoothState.OFF) + BluetoothStateManagement.setBluetoothState(powered) } override fun deviceConnected(polarDeviceInfo: PolarDeviceInfo) { @@ -88,6 +72,13 @@ class PolarObserverCallback : PolarBleApiCallback() { ) } + override fun htsNotificationReceived( + identifier: String, + data: PolarHealthThermometerData + ) { + Napier.i(">$identifier, $data", tag = "PolarObserverCallback::htsNotificationReceived") + } + override fun disInformationReceived(identifier: String, disInfo: DisInfo) { Napier.i( "Disinformation: $identifier, DisInfo: $disInfo", diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt index 4b0b86bd6..3c47d568f 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationExtension.kt @@ -1,30 +1,44 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + package io.redlink.more.app.android.observations +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.stringResource -import io.redlink.more.more_app_mutliplatform.AlertController -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.ObservationType +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.ObservationType -fun Observation.showPermissionAlertDialog() { - AlertController.openAlertDialog(AlertDialogModel( - title = stringResource(R.string.required_permissions_not_granted_title), - message = stringResource(R.string.required_permission_not_granted_message), - positiveTitle = stringResource(R.string.proceed_to_settings_button), - negativeTitle = stringResource(R.string.proceed_without_granting_button), - onPositive = { - MoreApplication.openSettings.value = true - AlertController.closeAlertDialog() - }, - onNegative = { - AlertController.closeAlertDialog() - } - )) +fun Observation.showPermissionAlertDialog(missingPermissions: List = emptyList()) { + var message = stringResource(R.string.required_permission_not_granted_message) + if (missingPermissions.isNotEmpty()) { + message += "\n\n" + stringResource(R.string.missing_permissions_label) + ": " + missingPermissions.joinToString(", ") + } + AlertController.openAlertDialog( + AlertDialogModel( + title = StringDesc.Raw(stringResource(R.string.required_permissions_not_granted_title)), + message = StringDesc.Raw(message), + confirmLabel = StringDesc.Raw(stringResource(R.string.proceed_to_settings_button)), + cancelLabel = StringDesc.Raw(stringResource(R.string.proceed_without_granting_button)), + onConfirm = { + MoreApplication.openSettings.value = true + } + )) } -fun Observation.Companion.pauseObservation(observationType: ObservationType) { +fun Observation.pauseObservation(observationType: ObservationType) { MoreApplication.shared!!.observationManager.pauseObservationType( observationType.observationType ) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt new file mode 100644 index 000000000..137a96354 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt @@ -0,0 +1,85 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.observations + +import android.app.Activity +import io.github.aakira.napier.Napier +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationManager +import io.redlink.more.scopes.Scope +import kotlinx.coroutines.Dispatchers + +/** + * Extension function for ObservationManager to start an observation with permission check + * @param scheduleId The schedule ID + * @param activity The activity to request permissions in + * @return True if the observation was started or permissions were requested, false otherwise + */ +suspend fun ObservationManager.startWithPermissionCheck( + scheduleId: String, + activity: Activity +): Boolean { + val observation = this.findObservationForSchedule(scheduleId) + + if (observation == null) { + Napier.e("No observation found for schedule $scheduleId") + return false + } + + if (PermissionUtils.hasAllPermissions(observation, activity)) { + return this.start(scheduleId) + } else { + val permissionsRequested = PermissionUtils.requestPermissions( + observation, + activity, + null, + scheduleId + ) { granted -> + if (granted) { + Scope.launch(Dispatchers.IO) { + val result = this@startWithPermissionCheck.start(scheduleId) + Napier.d("Observation started with result: $result") + } + } else { + observation.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observation, + activity + ) + ) + } + } + + return permissionsRequested + } +} + +/** + * Helper function to find the observation for a schedule + * @param scheduleId The schedule ID + * @return The observation for the schedule, or null if not found + */ +private fun ObservationManager.findObservationForSchedule(scheduleId: String): Observation? { + return this.getRunningObservations()[scheduleId] +} + +/** + * Extension property to get the running observations + * @return The map of running observations + */ +private fun ObservationManager.getRunningObservations(): Map { + val field = ObservationManager::class.java.getDeclaredField("runningObservations") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(this) as Map +} + diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt new file mode 100644 index 000000000..5671f4518 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt @@ -0,0 +1,67 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.observations + +import android.app.Activity +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.app.android.services.ObservationRecordingService + +/** + * Extension function to start observations with permission check + * @param scheduleIds The schedule IDs to start + * @param activity The activity to request permissions in + */ +fun startObservationsWithPermissionCheck( + scheduleIds: Set, + activity: Activity +) { + val observations = + MoreApplication.shared?.observationFactory?.observations ?: emptySet() + + if (observations.isEmpty()) { + ObservationRecordingService.start(scheduleIds) + return + } + + val allPermissionsGranted = observations.all { observation -> + PermissionUtils.hasAllPermissions(observation, activity) + } + + if (allPermissionsGranted) { + ObservationRecordingService.start(scheduleIds) + } else { + val observationNeedingPermissions = observations.firstOrNull { observation -> + !PermissionUtils.hasAllPermissions(observation, activity) + } + + if (observationNeedingPermissions != null) { + PermissionUtils.requestPermissions( + observationNeedingPermissions, + activity + ) { granted -> + if (granted) { + startObservationsWithPermissionCheck(scheduleIds, activity) + } else { + observationNeedingPermissions.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observationNeedingPermissions, + activity + ) + ) + } + } + } else { + ObservationRecordingService.start(scheduleIds) + } + } +} + diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt new file mode 100644 index 000000000..7e39963a3 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt @@ -0,0 +1,181 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.observations + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import io.github.aakira.napier.Napier +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.services.store.PermissionApprovalState + +/** + * Utility class for handling permissions for observations + */ +object PermissionUtils { + private var pendingObservation: Observation? = null + private var pendingObservationId: String? = null + private var pendingScheduleId: String? = null + private var pendingNotificationId: String? = null + private var pendingCallback: ((Boolean) -> Unit)? = null + private var pendingPermissions: Set? = null + + private val permissionLaunchers = + mutableMapOf>>() + + /** + * Initializes the permission launcher for an activity + * This should be called in the activity's onCreate method + * @param activity The activity to initialize the permission launcher for + */ + fun initializePermissionLauncher(activity: ComponentActivity) { + val permissionLauncher = activity.registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val allGranted = permissions.values.all { it } + pendingCallback?.invoke(allGranted) + + pendingObservation = null + pendingObservationId = null + pendingScheduleId = null + pendingNotificationId = null + pendingPermissions = null + pendingCallback = null + } + + permissionLaunchers[activity] = permissionLauncher + } + + /** + * Cleans up the permission launcher for an activity + * This should be called in the activity's onDestroy method + * @param activity The activity to clean up the permission launcher for + */ + fun cleanupPermissionLauncher(activity: Activity) { + permissionLaunchers.remove(activity) + } + + /** + * Checks if all required permissions for the observation are granted + * @param observation The observation to check permissions for + * @param context The context to check permissions in + * @return True if all permissions are granted, false otherwise + */ + fun hasAllPermissions(observation: Observation, context: Context): Boolean { + return observation.hasPermission() == PermissionApprovalState.GRANTED + } + + fun getMissingPermissionNames(observation: Observation, context: Context): List { + val missing = mutableListOf() + if (observation.observationType.observationType == AppUsageObservationType().observationType) { + if (observation.hasPermission() != PermissionApprovalState.GRANTED) { + missing.add("App Usage") + } + } else { + for (permission in observation.observationType.sensorPermissions) { + if (ContextCompat.checkSelfPermission( + context, + permission + ) != PackageManager.PERMISSION_GRANTED + ) { + missing.add(getPermissionLabel(context, permission)) + } + } + } + return missing + } + + fun getPermissionLabel(context: Context, permission: String): String { + return try { + val permissionInfo = context.packageManager.getPermissionInfo(permission, 0) + permissionInfo.loadLabel(context.packageManager).toString() + } catch (e: Exception) { + permission.substringAfterLast('.') + } + } + + /** + * Checks if all required permissions are granted + * @param permissions The permissions to check + * @param context The context to check permissions in + * @return True if all permissions are granted, false otherwise + */ + fun hasAllPermissions(permissions: Set, context: Context): Boolean { + if (permissions.isEmpty()) { + return true + } + + for (permission in permissions) { + if (ContextCompat.checkSelfPermission( + context, + permission + ) != PackageManager.PERMISSION_GRANTED + ) { + Napier.d("Permission not granted: $permission") + return false + } + } + return true + } + + /** + * Requests all required permissions for the observation + * @param observation The observation to request permissions for + * @param activity The activity to request permissions in + * @return True if all permissions are already granted, false if permissions need to be requested + */ + fun requestPermissions( + observation: Observation, + activity: Activity, + observationId: String? = null, + scheduleId: String? = null, + notificationId: String? = null, + callback: ((Boolean) -> Unit)? = null + ): Boolean { + val permissions = observation.observationType.sensorPermissions.toTypedArray() + if (permissions.isEmpty()) { + callback?.invoke(true) + return true + } + + val permissionsToRequest = permissions.filter { + ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED + }.toTypedArray() + + if (permissionsToRequest.isEmpty()) { + callback?.invoke(true) + return true + } + + pendingObservation = observation + pendingObservationId = observationId + pendingScheduleId = scheduleId + pendingNotificationId = notificationId + pendingCallback = callback + + val permissionLauncher = permissionLaunchers[activity] + if (permissionLauncher != null) { + permissionLauncher.launch(permissionsToRequest) + } else { + observation.showPermissionAlertDialog(getMissingPermissionNames(observation, activity)) + callback?.invoke(false) + } + + return false + } +} + diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt index bf8b74819..490725ba7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt @@ -16,15 +16,22 @@ import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.util.Log -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.AccelerometerType -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.AccelerometerType +import io.redlink.more.scopes.Scope private const val TAG = "AccelerometerObservation" class AccelerometerObservation( - context: Context -) : Observation(observationType = AccelerometerType(emptySet())), SensorEventListener { + context: Context, + repos: MainRepository +) : Observation( + repos, + observationType = AccelerometerType( + emptySet() + ) +), SensorEventListener { private val sensorManager = context.getSystemService(SensorManager::class.java) private val sensor = this.sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) private var sampleFrequency: Int = SensorManager.SENSOR_DELAY_NORMAL @@ -66,4 +73,4 @@ class AccelerometerObservation( override fun applyObservationConfig(settings: Map) { } -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt index a9be33bda..713a3141e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/LocalPushNotificationService.kt @@ -10,73 +10,103 @@ */ package io.redlink.more.app.android.services +import android.app.AlarmManager import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import android.provider.Settings import androidx.core.app.NotificationCompat import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging import io.github.aakira.napier.Napier +import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.ContentActivity import io.redlink.more.app.android.broadcasts.NotificationBroadcastReceiver -import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema -import io.redlink.more.more_app_mutliplatform.services.notification.LocalNotificationListener -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager.Companion.MSG_ID +import io.redlink.more.app.android.extensions.jvmLocalDateTimeFromMilliseconds +import io.redlink.more.app.android.util.AlarmUtils +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.models.localize +import io.redlink.more.services.notification.LocalNotificationListener +import io.redlink.more.services.notification.NotificationManager.Companion.MSG_ID class LocalPushNotificationService(private val context: Context) : LocalNotificationListener { - private val defaultChannelId = context.getString(R.string.default_channel_id) - private val unreadChannelId = context.getString(R.string.unread_channel_id) - private val unreadNotificationId = 1 - override fun displayNotification(notification: NotificationSchema) { + override fun displayNotification(notification: NotificationEntity, badgeCount: Int) { notification.title?.let { title -> - notification.notificationBody?.let { message -> - val intent = Intent(context, ContentActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - action = NotificationBroadcastReceiver.NOTIFICATION_SET_ON_READ_ACTION - putExtra(MSG_ID, notification.notificationId) - notification.deepLink()?.let { data = Uri.parse(it) } - } - - val pendingIntent = PendingIntent.getActivity( - context, 0, intent, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE - ) + notification.notificationBody?.let { messageKeyOrText -> + // If the notification should fire in the future, schedule it. + val nowMillis = System.currentTimeMillis() + val triggerAtMillis = (notification.timestamp ?: 0L) * 1000L + if (triggerAtMillis > nowMillis + 1000L) { + val channelId = notification.channelId ?: MoreApplication.DEFAULT_CHANNEL_ID!! - val channelId = - notification.channelId ?: defaultChannelId - val notificationBuilder = NotificationCompat.Builder(context, channelId) - .setSmallIcon(R.mipmap.ic_more_logo_hf_v2_round) - .setContentTitle(title) - .setContentText(message) - .setAutoCancel(true) - .setSound(Settings.System.DEFAULT_NOTIFICATION_URI) - .setContentIntent(pendingIntent) - - val notificationManager = context.getSystemService(NotificationManager::class.java) - if (notificationManager != null) { - val channel = notificationManager.getNotificationChannel(channelId) - if (channel == null) { - val name = context.getString(R.string.notification_channel_name) - val descriptionText = - context.getString(R.string.notification_channel_description) - val importance = NotificationManager.IMPORTANCE_DEFAULT - val mChannel = NotificationChannel(channelId, name, importance).apply { - description = descriptionText + createNotificationIntent(notification, channelId)?.let { alarmIntent -> + AlarmUtils.addAlarm( + context, + alarmIntent, + notification.notificationId, + triggerAtMillis + ) + Napier.i(tag = "LocalPushNotificationService::displayNotification") { + "Notification scheduled for ${triggerAtMillis.jvmLocalDateTimeFromMilliseconds()} with id ${notification.notificationId}" } - notificationManager.createNotificationChannel(mChannel) + } + } else { + val intent = Intent(context, ContentActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + action = + NotificationBroadcastReceiver.NOTIFICATION_SET_ON_READ_ACTION + putExtra(MSG_ID, notification.notificationId) + notification.deepLink()?.let { data = Uri.parse(it) } } - notificationManager.notify( - notification.notificationId.hashCode(), - notificationBuilder.build() + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE ) - } else { - Napier.e(tag = "NotificationError") { "Notification Manager is null" } + + val channelId = notification.channelId ?: MoreApplication.DEFAULT_CHANNEL_ID!! + val notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.mipmap.ic_more_logo_hf_v2_round) + .setContentTitle(title) + .setContentText(messageKeyOrText.localize()) + .setAutoCancel(true) + .setSound(Settings.System.DEFAULT_NOTIFICATION_URI) + .setContentIntent(pendingIntent) + .setNumber(1) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_LIGHTS or NotificationCompat.DEFAULT_VIBRATE) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + + val notificationManager = + context.getSystemService(NotificationManager::class.java) + if (notificationManager != null) { + val channel = notificationManager.getNotificationChannel(channelId) + if (channel == null) { + val name = context.getString(R.string.notification_channel_name) + val descriptionText = + context.getString(R.string.notification_channel_description) + val importance = NotificationManager.IMPORTANCE_HIGH + val mChannel = NotificationChannel(channelId, name, importance).apply { + description = descriptionText + enableVibration(true) + setShowBadge(true) + } + notificationManager.createNotificationChannel(mChannel) + } + + notificationManager.notify( + notification.notificationId.hashCode(), + notificationBuilder.build() + ) + Napier.i { "Sent Notification to device" } + } else { + Napier.e(tag = "NotificationError") { "Notification Manager is null" } + } } } ?: run { Napier.e(tag = "NotificationError") { "Notification message is null" } @@ -86,6 +116,26 @@ class LocalPushNotificationService(private val context: Context) : LocalNotifica } } + override fun clearScheduledNotifications(notifications: List) { + val alarmManager = context.getSystemService(AlarmManager::class.java) + if (alarmManager == null) { + Napier.e(tag = "NotificationError") { "AlarmManager is null" } + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + alarmManager.cancelAll() + } else { + notifications.forEach { notification -> + createNotificationIntent( + notification, + notification.channelId ?: MoreApplication.DEFAULT_CHANNEL_ID!! + )?.let { + AlarmUtils.cancelAllAlarms(context, it) + Napier.i { "Cleared scheduled notifications for ID: ${notification.notificationId}" } + } + } + } + } override fun deleteNotificationFromSystem(notificationId: String) { context.getSystemService(NotificationManager::class.java)?.cancel(notificationId.hashCode()) @@ -111,51 +161,34 @@ class LocalPushNotificationService(private val context: Context) : LocalNotifica FirebaseMessaging.getInstance().deleteToken() } - override fun updateBadgeCount(count: Int) { - if (count > 0) { - val intent = Intent(context, ContentActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - val pendingIntent = PendingIntent.getActivity( - context, 0, intent, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE - ) - val title = context.getString(R.string.notification_unread_title, count) - val message = context.getString(R.string.notification_unread_content, count) - val notificationBuilder = NotificationCompat.Builder(context, unreadChannelId) - .setSmallIcon(R.mipmap.ic_more_logo_hf_v2_round) - .setContentTitle(title) - .setContentText(message) - .setAutoCancel(false) - .setNumber(count) - .setVibrate(longArrayOf(0)) - .setSound(null) - .setContentIntent(pendingIntent) + override fun updateBadgeCount(count: Int) { + if (count <= 0) { + context.getSystemService(NotificationManager::class.java)?.cancelAll() + } + } - context.getSystemService(NotificationManager::class.java)?.let { notificationManager -> - val channel = notificationManager.getNotificationChannel(unreadChannelId) - if (channel == null) { - val name = context.getString(R.string.unread_channel_id) - val descriptionText = - context.getString(R.string.notification_channel_description) - val importance = NotificationManager.IMPORTANCE_LOW - val mChannel = NotificationChannel(unreadChannelId, name, importance).apply { - description = descriptionText - } - notificationManager.createNotificationChannel(mChannel) + private fun createNotificationIntent( + notification: NotificationEntity, + channelId: String + ): Intent? { + return notification.title?.let { title -> + notification.notificationBody?.let { body -> + Intent(context, NotificationBroadcastReceiver::class.java).apply { + action = NotificationBroadcastReceiver.SCHEDULED_NOTIFICATION_ACTION + putExtra( + NotificationBroadcastReceiver.EXTRA_NOTIFICATION_ID, + notification.notificationId + ) + putExtra(NotificationBroadcastReceiver.EXTRA_CHANNEL_ID, channelId) + putExtra(NotificationBroadcastReceiver.EXTRA_TITLE, title) + putExtra(NotificationBroadcastReceiver.EXTRA_MESSAGE, body) + putExtra( + NotificationBroadcastReceiver.EXTRA_DEEP_LINK, + notification.deepLink() + ) } - notificationManager.notify( - unreadNotificationId, - notificationBuilder.build() - ) } - } else { - context.getSystemService(NotificationManager::class.java)?.cancel(unreadNotificationId) } } - - companion object { - const val NOTIFICATION_KEY = "notification_key" - } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt index e6cd5604f..ab8847712 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt @@ -10,38 +10,46 @@ */ package io.redlink.more.app.android.services +import android.Manifest +import android.app.Activity +import android.app.AlarmManager import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.SystemClock +import androidx.core.content.ContextCompat import io.github.aakira.napier.Napier -import io.github.aakira.napier.log import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.ContentActivity -import io.redlink.more.app.android.observations.AndroidDataRecorder -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepository -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.observations.ObservationManager -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.app.android.observations.PermissionUtils +import io.redlink.more.app.android.observations.showPermissionAlertDialog +import io.redlink.more.app.android.util.ActivityProvider +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.ObservationManager +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.Scope +import io.redlink.more.viewModels.ViewManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ObservationRecordingService : Service() { private var observationManager: ObservationManager? = null - private val scheduleRepository = ScheduleRepository() private var observationFactory: ObservationFactory? = null - private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onBind(intent: Intent?): IBinder? { return null @@ -49,6 +57,39 @@ class ObservationRecordingService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Napier.i { "ObservationRecordingService called..." } + + // Critical: Start foreground service immediately to prevent ANR crashes + // This must happen within 5 seconds when startForegroundService() is called + try { + startForegroundService() + } catch (e: Exception) { + Napier.e("Failed to start foreground service: ${e.message}") + try { + val fallbackChannelId = "observation_service_fallback" + val notificationManager = getSystemService(NotificationManager::class.java) + if (notificationManager?.getNotificationChannel(fallbackChannelId) == null) { + val fallbackChannel = NotificationChannel( + fallbackChannelId, + "Observation Service Fallback", + NotificationManager.IMPORTANCE_LOW + ) + notificationManager?.createNotificationChannel(fallbackChannel) + } + val basicNotification = Notification.Builder(this, fallbackChannelId) + .setContentTitle("${MoreApplication.appName ?: "More"} Observation Service") + .setContentText("Service is running") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .build() + val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + startForeground(1001, basicNotification, type) + running = true + } catch (fallbackException: Exception) { + Napier.e("Failed to start foreground service with fallback notification: ${fallbackException.message}") + stopSelf() + return START_NOT_STICKY + } + } + if (observationFactory == null) { if (MoreApplication.shared == null) { MoreApplication.initShared(applicationContext) @@ -57,11 +98,7 @@ class ObservationRecordingService : Service() { } observationFactory?.let { if (observationManager == null) { - observationManager = - MoreApplication.shared?.observationManager ?: ObservationManager( - it, - AndroidDataRecorder() - ) + observationManager = MoreApplication.shared!!.observationManager } } return intent?.action?.let { action -> @@ -70,23 +107,23 @@ class ObservationRecordingService : Service() { SERVICE_RECEIVER_START_ACTION -> { intent.getStringArrayListExtra(SCHEDULE_ID)?.let { startObservation(it.toSet()) - return super.onStartCommand(intent, flags, startId) + return START_REDELIVER_INTENT } - START_NOT_STICKY + START_STICKY } SERVICE_RECEIVER_PAUSE_ACTION -> { intent.getStringExtra(SCHEDULE_ID)?.let { pauseObservation(it) } - START_NOT_STICKY + START_STICKY } SERVICE_RECEIVER_STOP_ACTION -> { intent.getStringExtra(SCHEDULE_ID)?.let { stopObservation(it) } - START_NOT_STICKY + START_STICKY } SERVICE_RECEIVER_STOP_ALL_ACTION -> { @@ -96,45 +133,187 @@ class ObservationRecordingService : Service() { SERVICE_RECEIVER_RESTART_ALL_STATES -> { restartAll() - return super.onStartCommand(intent, flags, startId) + START_REDELIVER_INTENT } else -> { - START_NOT_STICKY + START_STICKY } } - } ?: START_NOT_STICKY + } ?: START_STICKY } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) Napier.i { "ObservationRecordingService taskRemove!" } + + try { + if (runningSchedules.isNotEmpty()) { + val restartServiceIntent = + Intent(applicationContext, ObservationRecordingService::class.java) + restartServiceIntent.action = SERVICE_RECEIVER_RESTART_ALL_STATES + + val pendingIntent = PendingIntent.getService( + applicationContext, 1, restartServiceIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val alarmManager = + applicationContext.getSystemService(ALARM_SERVICE) as? AlarmManager + alarmManager?.let { + try { + it.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + Napier.i { "Scheduled service restart after task removal" } + } catch (e: SecurityException) { + Napier.e("Failed to schedule restart due to security restriction: ${e.message}") + } catch (e: Exception) { + Napier.e("Failed to schedule service restart: ${e.message}") + } + } ?: Napier.e("AlarmManager not available for service restart") + } + } catch (e: Exception) { + Napier.e("Error in onTaskRemoved: ${e.message}") + } } override fun onDestroy() { Napier.i { "ObservationRecordingService is destroyed!" } running = false - super.onDestroy() + + try { + try { + stopForeground(STOP_FOREGROUND_REMOVE) + } catch (e: Exception) { + Napier.e("Failed to stop foreground service: ${e.message}") + } + + if (runningSchedules.isNotEmpty()) { + Napier.i { "Service destroyed with running schedules, attempting to restart" } + val restartServiceIntent = + Intent(applicationContext, ObservationRecordingService::class.java) + restartServiceIntent.action = SERVICE_RECEIVER_RESTART_ALL_STATES + + val pendingIntent = PendingIntent.getService( + applicationContext, 2, restartServiceIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val alarmManager = + applicationContext.getSystemService(ALARM_SERVICE) as? AlarmManager + alarmManager?.let { + try { + it.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + Napier.i { "Scheduled service restart from onDestroy" } + } catch (e: SecurityException) { + Napier.e("Failed to schedule restart due to security restriction: ${e.message}") + } catch (e: Exception) { + Napier.e("Failed to schedule service restart from onDestroy: ${e.message}") + } + } ?: Napier.e("AlarmManager not available for service restart from onDestroy") + } + } catch (e: Exception) { + Napier.e("Error in onDestroy: ${e.message}") + } finally { + try { + super.onDestroy() + } catch (e: Exception) { + Napier.e("Error in super.onDestroy(): ${e.message}") + } + } } override fun onUnbind(intent: Intent?): Boolean { running = false - return super.onUnbind(intent) + + try { + if (runningSchedules.isNotEmpty()) { + Napier.i { "Service unbound with running schedules, attempting to restart" } + val restartServiceIntent = + Intent(applicationContext, ObservationRecordingService::class.java) + restartServiceIntent.action = SERVICE_RECEIVER_RESTART_ALL_STATES + + val pendingIntent = PendingIntent.getService( + applicationContext, 3, restartServiceIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val alarmManager = + applicationContext.getSystemService(ALARM_SERVICE) as? AlarmManager + alarmManager?.let { + try { + it.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + Napier.i { "Scheduled service restart from onUnbind" } + } catch (e: SecurityException) { + Napier.e("Failed to schedule restart due to security restriction: ${e.message}") + } catch (e: Exception) { + Napier.e("Failed to schedule service restart from onUnbind: ${e.message}") + } + } ?: Napier.e("AlarmManager not available for service restart from onUnbind") + } + } catch (e: Exception) { + Napier.e("Error in onUnbind: ${e.message}") + } + + return try { + super.onUnbind(intent) + } catch (e: Exception) { + Napier.e("Error in super.onUnbind(): ${e.message}") + false + } } private fun startObservation(scheduleId: Set) { Napier.i { "Starting the foreground service for scheduleId: $scheduleId..." } - startForegroundService() - scope.launch { - if (StudyRepository().getStudy().firstOrNull()?.active == true) { - scheduleId.forEach { - if (observationManager?.start(it) == true) { - runningSchedules.add(it) + try { + startForegroundService() + scope.launch { + try { + if (MoreApplication.shared!!.repositories.study.study.value?.active == true) { + scheduleId.forEach { id -> + try { + if (observationManager?.start(id) == true) { + runningSchedules.add(id) + Napier.d { "Successfully started observation for schedule: $id" } + } else { + Napier.w { "Failed to start observation for schedule: $id" } + } + } catch (e: Exception) { + Napier.e("Error starting observation for schedule $id: ${e.message}") + } + } + } else { + Napier.w { "Study is not active, skipping observation start" } + } + + if (runningSchedules.isEmpty()) { + Napier.i { "No observations started, stopping service" } + stopService() + } + } catch (e: Exception) { + Napier.e("Error in startObservation coroutine: ${e.message}") + if (runningSchedules.isEmpty()) { + stopService() } } } - if (runningSchedules.isEmpty()) { + } catch (e: Exception) { + Napier.e("Error starting observation: ${e.message}") + try { stopService() + } catch (stopError: Exception) { + Napier.e("Error stopping service after startup failure: ${stopError.message}") } } } @@ -150,7 +329,12 @@ class ObservationRecordingService : Service() { private fun stopObservation(scheduleId: String) { observationManager?.stop(scheduleId) runningSchedules.remove(scheduleId) - scheduleRepository.setCompletionStateFor(scheduleId, true) + Scope.launch { + MoreApplication.shared!!.repositories.schedule.setCompletionStateFor( + scheduleId, + true + ) + } if (observationManager?.hasRunningTasks() == false) { stopService() } @@ -165,39 +349,159 @@ class ObservationRecordingService : Service() { } private fun stopService() { - Napier.i { "Stopping ObservationRecordingService..." } - stopForeground(STOP_FOREGROUND_REMOVE) - running = false - stopSelf() - Napier.i { "Stopped ObservationRecordingService!" } + if (runningSchedules.isEmpty()) { + Napier.i { "Stopping ObservationRecordingService..." } + stopForeground(STOP_FOREGROUND_REMOVE) + running = false + stopSelf() + Napier.i { "Stopped ObservationRecordingService!" } + } else { + Napier.i { "Not stopping ObservationRecordingService because there are still running schedules" } + } } override fun onLowMemory() { super.onLowMemory() Napier.i { "ObservationRecording Service has low memory!" } + + if (runningSchedules.isNotEmpty()) { + Napier.i { "Service low on memory with running schedules, attempting to restart" } + val restartServiceIntent = + Intent(applicationContext, ObservationRecordingService::class.java) + restartServiceIntent.action = SERVICE_RECEIVER_RESTART_ALL_STATES + + val pendingIntent = PendingIntent.getService( + applicationContext, 4, restartServiceIntent, PendingIntent.FLAG_IMMUTABLE + ) + + val alarmManager = + applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + } } private fun restartAll() { - startForegroundService() - scope.launch { - val startedObservations = observationManager?.restartStillRunning() ?: emptySet() - if (startedObservations.isEmpty()) { - if (observationManager?.hasRunningTasks() == false) { - stopAll() + try { + startForegroundService() + scope.launch { + try { + Napier.i { "Restarting all running observations..." } + val startedObservations = + observationManager?.restartStillRunning() ?: emptySet() + + if (startedObservations.isNotEmpty()) { + runningSchedules.clear() + runningSchedules.addAll(startedObservations) + Napier.i { "Restarted ${startedObservations.size} observations: $startedObservations" } + } else { + Napier.i { "No observations to restart" } + val hasRunningTasks = try { + observationManager?.hasRunningTasks() == true + } catch (e: Exception) { + Napier.e("Error checking running tasks: ${e.message}") + false + } + + if (!hasRunningTasks) { + Napier.i { "No running tasks found, stopping service" } + stopAll() + } + } + } catch (e: Exception) { + Napier.e("Error in restartAll coroutine: ${e.message}") + try { + if (runningSchedules.isEmpty() && observationManager?.hasRunningTasks() != true) { + Napier.i { "No active observations after restart error, stopping service" } + stopAll() + } + } catch (stopError: Exception) { + Napier.e("Error handling restart failure: ${stopError.message}") + } } } + } catch (e: Exception) { + Napier.e("Error starting foreground service in restartAll: ${e.message}") + try { + stopAll() + } catch (stopError: Exception) { + Napier.e("Error stopping service after restartAll failure: ${stopError.message}") + } } } + private fun calculateForegroundServiceType(): Int { + var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + + // Location check + val hasLocationPermission = ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (hasLocationPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } + + // Connected Device check (Bluetooth) + val hasBluetoothPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_SCAN + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.BLUETOOTH_ADVERTISE + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + + if (hasBluetoothPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + + return type + } + private fun startForegroundService() { Napier.d { "Starting the foreground service..." } - val notification = buildNotification( - channelId = getString(R.string.default_channel_id), - notificationTitle = getString(R.string.more_observation_running), - notificationText = getString(R.string.more_observation_notification_explanation) - ) - startForeground(1001, notification) - running = true + try { + val notificationTitle = try { + getString(R.string.more_observation_running) + } catch (e: Exception) { + "${MoreApplication.appName} Observation Service" + } + + val notificationText = try { + getString(R.string.more_observation_notification_explanation) + } catch (e: Exception) { + "Service is running" + } + + val notification = buildNotification( + channelId = MoreApplication.DEFAULT_CHANNEL_ID ?: (packageName + ".observation_service"), + notificationTitle = notificationTitle, + notificationText = notificationText + ) + val type = calculateForegroundServiceType() + startForeground(1001, notification, type) + running = true + Napier.d { "Foreground service started successfully" } + } catch (e: Exception) { + Napier.e("Failed to start foreground service: ${e.message}") + throw e + } } private fun buildNotification( @@ -205,39 +509,71 @@ class ObservationRecordingService : Service() { notificationTitle: String, notificationText: String, ): Notification { - val channel = NotificationChannel( - channelId, - channelId, - NotificationManager.IMPORTANCE_LOW - ) - - val intent = Intent(this, ContentActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - val pendingIntent = PendingIntent.getActivity( - this, 0, intent, - PendingIntent.FLAG_IMMUTABLE - ) - applicationContext.getSystemService(NotificationManager::class.java) - .createNotificationChannel(channel) - return Notification.Builder(applicationContext, channelId) - .setContentText(notificationText) - .setContentTitle(notificationTitle) - .setSmallIcon(R.mipmap.ic_more_logo_hf_v2) - .setContentIntent(pendingIntent) - .build() + try { + val channel = NotificationChannel( + channelId, + channelId, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "${MoreApplication.appName} observation service notifications" + enableLights(false) + enableVibration(false) + } + + val intent = Intent(this, ContentActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val notificationManager = + applicationContext.getSystemService(NotificationManager::class.java) + notificationManager?.createNotificationChannel(channel) + + return Notification.Builder(applicationContext, channelId) + .setContentText(notificationText) + .setContentTitle(notificationTitle) + .setSmallIcon(R.mipmap.ic_more_logo_hf_v2) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setAutoCancel(false) + .build() + } catch (e: Exception) { + Napier.e("Failed to build notification: ${e.message}") + val fallbackChannelId = "observation_service_fallback" + val notificationManager = applicationContext.getSystemService(NotificationManager::class.java) + if (notificationManager?.getNotificationChannel(fallbackChannelId) == null) { + val fallbackChannel = NotificationChannel( + fallbackChannelId, + "Observation Service Fallback", + NotificationManager.IMPORTANCE_LOW + ) + notificationManager?.createNotificationChannel(fallbackChannel) + } + return Notification.Builder(applicationContext, fallbackChannelId) + .setContentTitle("${MoreApplication.appName ?: "More"} Service") + .setContentText("Running") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) + .build() + } } companion object { private const val SCHEDULE_ID = "SCHEDULE_ID" - private const val SERVICE_RECEIVER_START_ACTION = - "io.redlink.more.app.android.START_SERVICE" - private const val SERVICE_RECEIVER_PAUSE_ACTION = - "io.redlink.more.app.android.PAUSE_SERVICE" - private const val SERVICE_RECEIVER_STOP_ACTION = "io.redlink.more.app.android.STOP_SERVICE" - private const val SERVICE_RECEIVER_STOP_ALL_ACTION = - "io.redlink.more.app.android.STOP_ALL_SERVICE" - private const val SERVICE_RECEIVER_RESTART_ALL_STATES = - "io.redlink.more.app.android.RESTART_ALL" + private val SERVICE_RECEIVER_START_ACTION = + "${MoreApplication.packagePath}.START_SERVICE" + private val SERVICE_RECEIVER_PAUSE_ACTION = + "${MoreApplication.packagePath}.PAUSE_SERVICE" + private val SERVICE_RECEIVER_STOP_ACTION = + "${MoreApplication.packagePath}.STOP_SERVICE" + private val SERVICE_RECEIVER_STOP_ALL_ACTION = + "${MoreApplication.packagePath}.STOP_ALL_SERVICE" + private val SERVICE_RECEIVER_RESTART_ALL_STATES = + "${MoreApplication.packagePath}.RESTART_ALL" private const val MAX_RETRIES = 100 @@ -246,79 +582,178 @@ class ObservationRecordingService : Service() { private val runningSchedules = mutableSetOf() + private val pendingScheduleIds = mutableSetOf() fun start( scheduleIds: Set, ) { val validToStart = scheduleIds.filter { it !in runningSchedules } - Scope.launch(Dispatchers.IO) { - if (!running) { - var counter = 0 - while (MoreApplication.shared?.appIsInForeGround == false) { - if (counter++ >= MAX_RETRIES) { - log { "Stopping retries for launching observations" } - return@launch + + val activity = ActivityProvider.getCurrentActivity() + if (activity != null && validToStart.isNotEmpty()) { + checkPermissionsAndStart(validToStart.toSet(), activity) + } else { + startWithoutPermissionCheck(validToStart.toSet()) + } + } + + /** + * Checks permissions before starting observations + * @param scheduleIds The schedule IDs to start + * @param activity The activity to request permissions in + */ + private fun checkPermissionsAndStart( + scheduleIds: Set, + activity: Activity + ) { + Scope.launch { + val observationsTypes = + MoreApplication.shared!!.repositories.schedule.observationTypesForScheduleIds( + scheduleIds + ).firstOrNull() ?: emptySet() + val observations = + MoreApplication.shared!!.observationFactory.observations.filter { it.observationType.observationType in observationsTypes } + if (observations.isEmpty()) { + startWithoutPermissionCheck(scheduleIds) + return@launch + } + + withContext(AppDispatchers.main) { + val allPermissionsGranted = observations.all { observation -> + PermissionUtils.hasAllPermissions(observation, activity) + } + + if (allPermissionsGranted) { + startWithoutPermissionCheck(scheduleIds) + } else { + val observationNeedingPermissions = + observations.firstOrNull { observation -> + !PermissionUtils.hasAllPermissions(observation, activity) + } + + if (observationNeedingPermissions != null) { + pendingScheduleIds.addAll(scheduleIds) + + + PermissionUtils.requestPermissions( + observationNeedingPermissions, + activity + ) { granted -> + if (granted) { + checkPermissionsAndStart(scheduleIds, activity) + } else { + observationNeedingPermissions.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observationNeedingPermissions, + activity + ) + ) + pendingScheduleIds.removeAll(scheduleIds) + } + } + } else { + startWithoutPermissionCheck(scheduleIds) } - log { "Waiting till app goes into foreground to start observations..." } - delay(1000) } } - if (validToStart.isNotEmpty() && MoreApplication.shared?.appIsInForeGround == true || running) { - val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) - serviceIntent.action = SERVICE_RECEIVER_START_ACTION - serviceIntent.putStringArrayListExtra(SCHEDULE_ID, ArrayList(validToStart)) - try { - Handler(Looper.getMainLooper()).post { - if (running) { - MoreApplication.appContext?.startService(serviceIntent) - } else { - MoreApplication.appContext?.startForegroundService(serviceIntent) - } + + } + + } + + /** + * Starts observations without permission check + * @param scheduleIds The schedule IDs to start + */ + private fun startWithoutPermissionCheck(scheduleIds: Set) { + if (ViewManager.appInForeground.value && scheduleIds.isNotEmpty()) { + val serviceIntent = + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) + serviceIntent.action = SERVICE_RECEIVER_START_ACTION + serviceIntent.putStringArrayListExtra(SCHEDULE_ID, ArrayList(scheduleIds)) + try { + Handler(Looper.getMainLooper()).post { + if (running) { + MoreApplication.appContext?.startService( + serviceIntent + ) + } else { + MoreApplication.appContext?.startForegroundService( + serviceIntent + ) } - } catch (e: Exception) { - Napier.e(e.stackTraceToString()) } - } else { - Napier.w { "Application not in foreground" } + } catch (e: Exception) { + Napier.e(e.stackTraceToString()) } + } else if (!ViewManager.appInForeground.value) { + Napier.w { "Could not start Foreground service: App is not open!" } } } fun pause(scheduleId: String) { - val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) - serviceIntent.action = SERVICE_RECEIVER_PAUSE_ACTION - serviceIntent.putExtra(SCHEDULE_ID, scheduleId) - MoreApplication.appContext?.startService(serviceIntent) + if (ViewManager.appInForeground.value) { + val serviceIntent = + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) + serviceIntent.action = SERVICE_RECEIVER_PAUSE_ACTION + serviceIntent.putExtra(SCHEDULE_ID, scheduleId) + MoreApplication.appContext?.startService(serviceIntent) + } else { + MoreApplication.shared?.observationManager?.pause(scheduleId) + } } fun stop(scheduleId: String) { - val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) - serviceIntent.action = SERVICE_RECEIVER_STOP_ACTION - serviceIntent.putExtra(SCHEDULE_ID, scheduleId) - MoreApplication.appContext?.startService(serviceIntent) + if (ViewManager.appInForeground.value) { + val serviceIntent = + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) + serviceIntent.action = SERVICE_RECEIVER_STOP_ACTION + serviceIntent.putExtra(SCHEDULE_ID, scheduleId) + MoreApplication.appContext?.startService(serviceIntent) + } else { + MoreApplication.shared?.observationManager?.stop(scheduleId) + } } fun stopAll() { - val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) - serviceIntent.action = SERVICE_RECEIVER_STOP_ALL_ACTION - MoreApplication.appContext?.startService(serviceIntent) + if (ViewManager.appInForeground.value) { + val serviceIntent = + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) + serviceIntent.action = SERVICE_RECEIVER_STOP_ALL_ACTION + MoreApplication.appContext?.startService(serviceIntent) + } else { + MoreApplication.shared?.observationManager?.stopAll() + } } fun restartAll() { val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) serviceIntent.action = SERVICE_RECEIVER_RESTART_ALL_STATES try { Handler(Looper.getMainLooper()).post { - MoreApplication.appContext?.startForegroundService(serviceIntent) + MoreApplication.appContext?.startForegroundService( + serviceIntent + ) } } catch (e: Exception) { Napier.e(e.stackTraceToString()) } } } -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/AndroidBluetoothConnector.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/AndroidBluetoothConnector.kt deleted file mode 100644 index fb3af53f3..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/AndroidBluetoothConnector.kt +++ /dev/null @@ -1,397 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.app.android.services.bluetooth - -import android.Manifest -import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCallback -import android.bluetooth.BluetoothManager -import android.bluetooth.BluetoothProfile -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanCallback -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import io.github.aakira.napier.Napier -import io.github.aakira.napier.Napier.i -import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnector -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnectorObserver -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothState -import android.bluetooth.BluetoothDevice as AndroidBluetoothDevice - -class AndroidBluetoothConnector(context: Context) : BluetoothConnector { - private val bluetoothAdapter: BluetoothAdapter? = - (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter - private var bluetoothLeScanner: BluetoothLeScanner? = null - private var isConnecting = false - private val foundBluetoothDevices = mutableSetOf() - - override val specificBluetoothConnectors: MutableMap = - mutableMapOf() - override var observer: MutableSet = mutableSetOf() - override var scanning = false - override var bluetoothState: BluetoothState = - if (bluetoothAdapter?.isEnabled == true) BluetoothState.ON else BluetoothState.OFF - - private val scanCallback = object : ScanCallback() { - @SuppressLint("MissingPermission") - override fun onScanResult(callbackType: Int, result: ScanResult?) { - super.onScanResult(callbackType, result) - if (scanning) { - result?.device?.let { device -> - device.name?.let { - if (device.address !in foundBluetoothDevices) { - foundBluetoothDevices.add(device.address) - i { "New Device with name discovered: $it" } - val bluetoothDevice = device.toBluetoothDevice() - if (device.bondState == AndroidBluetoothDevice.BOND_BONDED) { - deviceConnected(bluetoothDevice) - } else { - didDiscoverDevice(bluetoothDevice) - } - } - } - } - } - } - - override fun onScanFailed(errorCode: Int) { - super.onScanFailed(errorCode) - Napier.e { "Scanning failed with code: $errorCode" } - isScanning(false) - } - } - - private val gattCallback = object : BluetoothGattCallback() { - @SuppressLint("MissingPermission") - override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { - super.onConnectionStateChange(gatt, status, newState) - gatt?.device?.toBluetoothDevice()?.let { device -> - when (newState) { - BluetoothProfile.STATE_CONNECTED -> { - deviceConnected(device) - } - - BluetoothProfile.STATE_CONNECTING -> { - isConnectingToDevice(device) - } - - BluetoothProfile.STATE_DISCONNECTED -> { - if (status == BluetoothProfile.STATE_CONNECTING) { - i { "Problem connecting to device: $device" } - didFailToConnectToDevice(device) - } else { - didDisconnectFromDevice(device) - } - gatt.close() - } - - else -> { - - } - } - } - } - } - - private val bondStateReceiver = object : BroadcastReceiver() { - @SuppressLint("MissingPermission") - override fun onReceive(context: Context, intent: Intent) { - when { - AndroidBluetoothDevice.ACTION_BOND_STATE_CHANGED == intent.action -> { - i { "Bond State changed" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra( - AndroidBluetoothDevice.EXTRA_DEVICE, - AndroidBluetoothDevice::class.java - ) - } else { - intent.getParcelableExtra(AndroidBluetoothDevice.EXTRA_DEVICE) - }?.let { pairedDevice -> - val bondState: Int = intent.getIntExtra( - AndroidBluetoothDevice.EXTRA_BOND_STATE, - AndroidBluetoothDevice.ERROR - ) - val prevBondState: Int = intent.getIntExtra( - AndroidBluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, - AndroidBluetoothDevice.ERROR - ) - val device = pairedDevice.toBluetoothDevice() - i { "Bond state of device $device: $bondState. ${pairedDevice.bondState}" } - if (bondState == AndroidBluetoothDevice.BOND_BONDED && prevBondState != AndroidBluetoothDevice.BOND_BONDED) { - deviceConnected(device) - } else if (bondState == AndroidBluetoothDevice.BOND_NONE) { - isConnecting = false - disconnect(device) - foundBluetoothDevices.remove(device.address) - } else if (bondState == AndroidBluetoothDevice.BOND_BONDING) { - isConnectingToDevice(device) - } else { - } - } - } - - BluetoothAdapter.ACTION_STATE_CHANGED == intent.action -> { - when (intent.getIntExtra( - BluetoothAdapter.EXTRA_STATE, - BluetoothAdapter.ERROR - )) { - BluetoothAdapter.STATE_OFF -> { - onBluetoothStateChange(BluetoothState.OFF) - } - - BluetoothAdapter.STATE_ON -> { - onBluetoothStateChange(BluetoothState.ON) - } - } - } - } - } - } - - init { - if (ContextCompat.checkSelfPermission( - MoreApplication.appContext!!, - Manifest.permission.BLUETOOTH - ) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission( - MoreApplication.appContext!!, - Manifest.permission.BLUETOOTH_ADMIN - ) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission( - MoreApplication.appContext!!, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED - ) { - try { - specificBluetoothConnectors.values.forEach { - it - it.bluetoothState = this.bluetoothState - } - val bondStateChangedFilter = IntentFilter() - bondStateChangedFilter.addAction(AndroidBluetoothDevice.ACTION_BOND_STATE_CHANGED) - bondStateChangedFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED) - MoreApplication.appContext!!.registerReceiver( - bondStateReceiver, - bondStateChangedFilter - ) - } catch (exception: Exception) { - Napier.w { exception.stackTraceToString() } - } - - } else { - Napier.e { "Bluetooth permissions not given!" } - } - } - - override fun addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) { - specificBluetoothConnectors[key] = connector - } - - override fun addObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { - this.observer.add(bluetoothConnectorObserver) - if (observer.isNotEmpty()) { - replayStates() - } - } - - override fun removeObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { - this.observer.remove(bluetoothConnectorObserver) - if (observer.isEmpty()) { - stopScanning() - } - } - - override fun updateObserver(action: (BluetoothConnectorObserver) -> Unit) { - observer.forEach(action) - } - - override fun replayStates() { - isScanning(this.scanning) - onBluetoothStateChange(this.bluetoothState) - } - - @SuppressLint("MissingPermission") - override fun scan() { - if (bluetoothState == BluetoothState.ON && !scanning && !isConnecting) { - val scanSettings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .build() - - bluetoothLeScanner?.startScan(null, scanSettings, scanCallback) - isScanning(true) - } - } - - @SuppressLint("MissingPermission") - override fun connect(device: BluetoothDevice): Error? { - if (bluetoothState == BluetoothState.ON) { - i { "Connecting to device: $device" } - isConnecting = true - stopScanning() - val (hasSpecialConnector, error) = connectToSpecificConnectors(device) - if (hasSpecialConnector) { - isConnecting = false - return error - } - val androidBluetoothDevice = bluetoothAdapter?.getRemoteDevice(device.address) - if (androidBluetoothDevice != null) { - if (androidBluetoothDevice.bondState == AndroidBluetoothDevice.BOND_NONE) { - i { "Device already has bond state! Bonding..." } - androidBluetoothDevice.createBond() - } else { - MoreApplication.appContext?.let { - androidBluetoothDevice.connectGatt(it, false, gattCallback) - } - } - return null - } else { - updateObserver { it.didFailToConnectToDevice(device) } - } - return Error("Could not find device") - } - return Error("Bluetooth disabled!") - } - - @SuppressLint("MissingPermission") - override fun disconnect(device: BluetoothDevice) { - if (!disconnectFromSpecificConnectors(device)) { - val androidBluetoothDevice = bluetoothAdapter?.getRemoteDevice(device.address) - if (androidBluetoothDevice != null) { - val gatt = androidBluetoothDevice.connectGatt( - MoreApplication.appContext!!, - false, - gattCallback - ) - gatt.disconnect() - } - } else { - updateObserver { it.didDisconnectFromDevice(device) } - } - } - - @SuppressLint("MissingPermission") - override fun stopScanning() { - if (scanning) { - bluetoothLeScanner?.stopScan(scanCallback) - isScanning(false) - } - } - - override fun close() { - stopScanning() - foundBluetoothDevices.clear() - specificBluetoothConnectors.values.forEach { it.close() } - - try { - MoreApplication.appContext?.unregisterReceiver(bondStateReceiver) - } catch (exception: Exception) { - Napier.w { exception.stackTraceToString() } - } - } - - override fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) { - i { "Connecting to $bluetoothDevice..." } - updateObserver { it.isConnectingToDevice(bluetoothDevice) } - } - - override fun didConnectToDevice(bluetoothDevice: BluetoothDevice) { - i { "Connected to $bluetoothDevice!" } - updateObserver { it.didConnectToDevice(bluetoothDevice) } - isConnecting = false - } - - override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { - i { "Disconnected from $bluetoothDevice!" } - foundBluetoothDevices.remove(bluetoothDevice.address) - updateObserver { it.didDisconnectFromDevice(bluetoothDevice) } - } - - override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { - i { "Failed to connect to $bluetoothDevice!" } - updateObserver { didFailToConnectToDevice(bluetoothDevice) } - isConnecting = false - } - - override fun didDiscoverDevice(device: BluetoothDevice) { - i { "Discovered $device!" } - updateObserver { it.didDiscoverDevice(device) } - } - - override fun removeDiscoveredDevice(device: BluetoothDevice) { - i { "Removing discovered $device..." } - updateObserver { it.removeDiscoveredDevice(device) } - } - - override fun onBluetoothStateChange(bluetoothState: BluetoothState) { - i { "Bluetooth State changed to: $bluetoothState" } - this.bluetoothState = bluetoothState - if (bluetoothState == BluetoothState.ON) { - if (bluetoothAdapter != null && bluetoothAdapter.isEnabled) { - bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner - } else { - i { "Bluetooth Adapter not enabled!" } - } - } - updateObserver { it.onBluetoothStateChange(bluetoothState) } - } - - override fun isScanning(boolean: Boolean) { - this.scanning = boolean - updateObserver { it.isScanning(boolean) } - } - - private fun deviceConnected(bluetoothDevice: BluetoothDevice) { - val (hasSpecialConnector, error) = connectToSpecificConnectors(bluetoothDevice) - if (hasSpecialConnector) { - if (error != null) { - Napier.e { error.stackTraceToString() } - } - } - didConnectToDevice(bluetoothDevice) - } - - private fun connectToSpecificConnectors(device: BluetoothDevice): Pair { - return specificBluetoothConnectors.keys.firstOrNull { - device.deviceName?.lowercase()?.contains(it) ?: false - }?.let { - i { "Connecting with special connector \"$it\"..." } - Pair(true, specificBluetoothConnectors[it]?.connect(device)) - } ?: Pair(false, null) - } - - private fun disconnectFromSpecificConnectors(device: BluetoothDevice): Boolean { - return specificBluetoothConnectors.keys.firstOrNull { - i { "Disconnecting with special connector \"$it\"..." } - device.deviceName?.lowercase()?.contains(it) ?: false - }?.let { - specificBluetoothConnectors[it]?.disconnect(device) - true - } ?: false - } - - -} - -@SuppressLint("MissingPermission") -fun AndroidBluetoothDevice.toBluetoothDevice(): BluetoothDevice { - return BluetoothDevice.create(this.address, this.name, this.address) -} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/PolarConnector.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/PolarConnector.kt index 98cc6b2f3..407dea41a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/PolarConnector.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/PolarConnector.kt @@ -20,60 +20,55 @@ import io.github.aakira.napier.Napier import io.reactivex.rxjava3.disposables.Disposable import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.observations.HR.PolarConnectorListener -import io.redlink.more.app.android.observations.HR.PolarHeartRateObservation import io.redlink.more.app.android.observations.HR.PolarObserverCallback -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnector -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnectorObserver -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothState -import io.redlink.more.more_app_mutliplatform.util.Scope -import kotlinx.coroutines.delay +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.services.bluetooth.BluetoothConnector +import io.redlink.more.services.bluetooth.BluetoothConnectorObserver +import io.redlink.more.services.bluetooth.BluetoothState +import io.redlink.more.services.bluetooth.BluetoothStateManagement +import io.redlink.more.services.bluetooth.polar.PolarStates class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListener { private val polarObserverCallback: PolarObserverCallback = PolarObserverCallback() - val polarApi: PolarBleApi by lazy { - val api = PolarBleApiDefaultImpl.defaultImplementation( - context, setOf( - PolarBleApi.PolarBleSdkFeature.FEATURE_HR, - PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SDK_MODE, - PolarBleApi.PolarBleSdkFeature.FEATURE_BATTERY_INFO, - PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_OFFLINE_RECORDING, - PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ONLINE_STREAMING, - PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP, - PolarBleApi.PolarBleSdkFeature.FEATURE_DEVICE_INFO - ) + val polarApi: PolarBleApi = PolarBleApiDefaultImpl.defaultImplementation( + context, setOf( + PolarBleApi.PolarBleSdkFeature.FEATURE_HR, + PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SDK_MODE, + PolarBleApi.PolarBleSdkFeature.FEATURE_BATTERY_INFO, + PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_OFFLINE_RECORDING, + PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ONLINE_STREAMING, + PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP, + PolarBleApi.PolarBleSdkFeature.FEATURE_DEVICE_INFO ) - - api.setPolarFilter(true) - api.setApiCallback(polarObserverCallback) - api.setAutomaticReconnection(true) - api + ).apply { + setPolarFilter(true) + setApiCallback(polarObserverCallback) + setAutomaticReconnection(true) } + private val bleManager = BluetoothStateManagement + private var scanDisposable: Disposable? = null override val specificBluetoothConnectors: MutableMap = mutableMapOf() - override var scanning: Boolean = false override var observer: MutableSet = mutableSetOf() - override var bluetoothState: BluetoothState = BluetoothState.OFF - init { polarObserverCallback.connectionListener = this (MoreApplication.appContext!!.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.let { - this.bluetoothState = when (it.state) { + val state = when (it.state) { BluetoothAdapter.STATE_ON -> BluetoothState.ON BluetoothAdapter.STATE_TURNING_ON -> BluetoothState.ON else -> BluetoothState.OFF } - Napier.i(tag = "PolarConnector::init") { "Current Bluetooth State: $bluetoothState" } + bleManager.setBluetoothState(state == BluetoothState.ON) } } override fun scan() { - if (!scanning && observer.isNotEmpty() && bluetoothState == BluetoothState.ON) { - isScanning(true) + if (!bleManager.scanning.value && observer.isNotEmpty() && bleManager.bluetoothActive.value) { + bleManager.isScanning(true) Napier.i(tag = "PolarConnector::scan") { "Scanning started." } scanDisposable = polarApi.searchForDevice() .subscribe( @@ -87,7 +82,7 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe } } - override fun connect(device: BluetoothDevice): Error? { + override fun connect(device: BluetoothDeviceEntity): Error? { Napier.i(tag = "PolarConnector::connect") { "Connecting to device: $device" } return try { device.address?.let { @@ -100,7 +95,7 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe } } - override fun disconnect(device: BluetoothDevice) { + override fun disconnect(device: BluetoothDeviceEntity) { Napier.i(tag = "PolarConnector::disconnect") { "Disconnecting from device: $device" } try { device.address?.let { @@ -113,18 +108,18 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe override fun stopScanning() { Napier.i(tag = "PolarConnector::stopScanning") { "Stopping scanning." } - if (scanning) { + if (bleManager.scanning.value) { scanDisposable?.dispose() - polarApi.cleanup() - isScanning(false) + bleManager.isScanning(false) } } - override fun onPolarFeatureReady(feature: PolarBleApi.PolarBleSdkFeature) { if (feature == PolarBleApi.PolarBleSdkFeature.FEATURE_HR) { Napier.i(tag = "PolarConnector::onPolarFeatureReady") { "HR ready!" } - PolarHeartRateObservation.setHRFeature(true) + PolarStates.hrFeatureReady(true) + + Napier.d(tag = "PolarHeartRateObservation:::setHRFeature") { "HR Feature Ready!" } } } @@ -142,66 +137,51 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe isConnectingToDevice(polarDeviceInfo.toBluetoothDevice()) } - override fun onPowerChange(bluetoothState: BluetoothState) { - Napier.i(tag = "PolarConnector::onPowerChange") { "Bluetooth power change: $bluetoothState" } - onBluetoothStateChange(bluetoothState) - } - override fun close() { Napier.i(tag = "PolarConnector::close") { "Closing PolarConnector..." } stopScanning() } - override fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) { + override fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { it.isConnectingToDevice(bluetoothDevice) } } - override fun didConnectToDevice(bluetoothDevice: BluetoothDevice) { + override fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { it.didConnectToDevice(bluetoothDevice) } } - override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { + override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { it.didDisconnectFromDevice(bluetoothDevice) } + if (bleManager.connectedDevices.value.map { it.deviceName?.contains("polar") }.isEmpty()) { + PolarStates.hrFeatureReady(false) + } } - override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { + override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { it.didFailToConnectToDevice(bluetoothDevice) } } - override fun didDiscoverDevice(device: BluetoothDevice) { + override fun didDiscoverDevice(device: BluetoothDeviceEntity) { Napier.i { "Device Discovered: $device" } updateObserver { it.didDiscoverDevice(device) } } - override fun removeDiscoveredDevice(device: BluetoothDevice) { + override fun removeDiscoveredDevice(device: BluetoothDeviceEntity) { updateObserver { it.removeDiscoveredDevice(device) } } - override fun onBluetoothStateChange(bluetoothState: BluetoothState) { - this.bluetoothState = bluetoothState - updateObserver { it.onBluetoothStateChange(bluetoothState) } - if (MoreApplication.shared?.credentialRepository?.hasCredentials() == true && bluetoothState == BluetoothState.ON) { - Scope.launch { - scan() - delay(10000) - stopScanning() - } - } else { - stopScanning() - } + override fun resetAll() { + polarApi.cleanup() } override fun addObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { this.observer.add(bluetoothConnectorObserver) - if (this.observer.isNotEmpty()) { - replayStates() - } } override fun removeObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { @@ -220,21 +200,10 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe observer.forEach(action) } - override fun replayStates() { - onBluetoothStateChange(bluetoothState) - isScanning(scanning) - } - - override fun isScanning(boolean: Boolean) { - this.scanning = boolean - updateObserver { it.isScanning(boolean) } - } - override fun addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) { specificBluetoothConnectors[key] = connector } } -fun PolarDeviceInfo.toBluetoothDevice(): BluetoothDevice { - return BluetoothDevice.create(this.deviceId, this.name, this.address) -} \ No newline at end of file +fun PolarDeviceInfo.toBluetoothDevice(): BluetoothDeviceEntity = + BluetoothDeviceEntity.create(this.deviceId, this.name, this.address) \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt index 4fcd8d29f..d2ee2a5ed 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt @@ -1,3 +1,14 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + package io.redlink.more.app.android.services.sensorsListener import android.bluetooth.BluetoothAdapter @@ -7,7 +18,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.scopes.Scope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt index 85e6d9344..82ea2fe27 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt @@ -1,3 +1,14 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + package io.redlink.more.app.android.services.sensorsListener import android.content.BroadcastReceiver @@ -5,7 +16,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.location.LocationManager -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.scopes.Scope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Accordion.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Accordion.kt index d93e8da56..66ddc3c40 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Accordion.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Accordion.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -41,8 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.theme.MoreColors @Composable fun Accordion( @@ -65,34 +65,41 @@ fun Accordion( ) ) - Row(verticalAlignment = Alignment.Top, + Row( + verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) ) { - Column(modifier = Modifier - .weight(1f) - .padding(start = 8.dp)) { - + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { - Column(verticalArrangement = Arrangement.Center, + Column( + verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() - .clickable { + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { open.value = !open.value } .height(48.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically, + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() ) { - if(hasCheck) { + if (hasCheck) { IconInline( icon = Icons.Rounded.Done, color = MoreColors.Approved, @@ -100,17 +107,17 @@ fun Accordion( ) } - if(hasSmallTitle) { + if (hasSmallTitle) { SmallTitle(text = title, modifier = Modifier.weight(0.9f)) } else { MediumTitle(text = title, modifier = Modifier.weight(0.9f)) } - Icon( - Icons.Rounded.ExpandMore, - contentDescription = getStringResource(id = R.string.more_endpoint_rotatable_arrow_description), - tint = MoreColors.Primary, - modifier = Modifier.rotate(angle) - ) + Icon( + Icons.Rounded.ExpandMore, + contentDescription = getStringResource(id = R.string.more_endpoint_rotatable_arrow_description), + tint = MoreColors.Primary, + modifier = Modifier.rotate(angle) + ) } } @@ -121,16 +128,16 @@ fun Accordion( ) } - if(hasPreview) { + if (hasPreview) { Text( text = description, color = if (open.value) MoreColors.Primary else MoreColors.TextInactive, - maxLines = if(open.value) Int.MAX_VALUE else 1, + maxLines = if (open.value) Int.MAX_VALUE else 1, overflow = TextOverflow.Ellipsis, fontSize = if (open.value) TextUnit.Unspecified else 14.sp ) Spacer(Modifier.height(12.dp)) - } else if(open.value) { + } else if (open.value) { Text( text = description, color = MoreColors.Primary, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AccordionReadMore.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AccordionReadMore.kt index acd241702..c70522339 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AccordionReadMore.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AccordionReadMore.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -39,10 +40,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun AccordionReadMore (title: String, description: String, modifier: Modifier = Modifier) { +fun AccordionReadMore(title: String, description: String, modifier: Modifier = Modifier) { var overflow by remember { mutableStateOf(false) } val open = remember { mutableStateOf(false) @@ -56,15 +57,20 @@ fun AccordionReadMore (title: String, description: String, modifier: Modifier = ) Column( horizontalAlignment = Alignment.Start, - modifier = modifier) { + modifier = modifier + ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - .clickable { - open.value = !open.value - } + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + open.value = !open.value + } ) { MediumTitle( @@ -98,15 +104,21 @@ fun AccordionReadMore (title: String, description: String, modifier: Modifier = if (overflow) { Spacer(Modifier.padding(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically, + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .clickable { + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { open.value = !open.value }) { Text( - text = if (open.value) getStringResource(id = R.string.more_read_less) else getStringResource(id = R.string.more_read_more), + text = if (open.value) getStringResource(id = R.string.more_read_less) else getStringResource( + id = R.string.more_read_more + ), color = MoreColors.Primary, fontWeight = FontWeight.SemiBold, ) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ActivityProgressView.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ActivityProgressView.kt index b034a7f73..6b18698f3 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ActivityProgressView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ActivityProgressView.kt @@ -26,17 +26,24 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.theme.MoreColors @Composable -fun ActivityProgressView(modifier: Modifier = Modifier, finishedTasks: Int, totalTasks: Int, headline: String = getStringResource(id = R.string.more_main_completed_tasks)){ - val percent: Double = if(totalTasks > 0) finishedTasks.toDouble() / totalTasks.toDouble() else 0.0 +fun ActivityProgressView( + modifier: Modifier = Modifier, + finishedTasks: Int, + totalTasks: Int, + headline: String = getStringResource(id = R.string.more_main_completed_tasks) +) { + val percent: Double = + if (totalTasks > 0) finishedTasks.toDouble() / totalTasks.toDouble() else 0.0 Column( modifier = modifier - .fillMaxWidth() - .padding(vertical = 5.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, + .fillMaxWidth() + .padding(vertical = 5.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier .fillMaxWidth() diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AppVersion.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AppVersion.kt index f9a55e128..ade8f8c93 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AppVersion.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/AppVersion.kt @@ -25,14 +25,15 @@ import androidx.compose.ui.unit.sp import io.redlink.more.app.android.BuildConfig import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun AppVersion() { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(vertical = 10.dp) ) { Text( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/BasicText.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/BasicText.kt index 0d7b2c6ac..126d6f9d0 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/BasicText.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/BasicText.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun BasicText( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/DatapointCollectionView.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/DatapointCollectionView.kt index 0717399b5..e642a55ff 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/DatapointCollectionView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/DatapointCollectionView.kt @@ -27,12 +27,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.models.ScheduleState - +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.models.ScheduleState @Composable -fun DatapointCollectionView (datapoints: Long, scheduleState: ScheduleState?){ +fun DatapointCollectionView(datapoints: Long, scheduleState: ScheduleState?) { Column( verticalArrangement = Arrangement.SpaceBetween, @@ -40,8 +39,8 @@ fun DatapointCollectionView (datapoints: Long, scheduleState: ScheduleState?){ modifier = Modifier .fillMaxWidth() .padding(8.dp) - ){ - if(scheduleState == ScheduleState.RUNNING) { + ) { + if (scheduleState == ScheduleState.RUNNING) { CircularProgressIndicator( color = MoreColors.Approved, modifier = Modifier @@ -51,7 +50,7 @@ fun DatapointCollectionView (datapoints: Long, scheduleState: ScheduleState?){ Spacer(Modifier.padding(6.dp)) } - if(scheduleState == ScheduleState.RUNNING || datapoints > 0) { + if (scheduleState == ScheduleState.RUNNING || datapoints > 0) { MediumTitle(text = getStringResource(id = R.string.more_observation_datapoints)) Spacer(Modifier.padding(4.dp)) Text( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/EmptyListView.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/EmptyListView.kt index f682c1b36..ec9a00819 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/EmptyListView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/EmptyListView.kt @@ -16,15 +16,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @Composable fun EmptyListView(text: String) { - Box(contentAlignment = Alignment.CenterStart, + Box( + contentAlignment = Alignment.CenterStart, modifier = Modifier .fillMaxWidth() .height(70.dp) ) { - BasicText(text = text) + BasicText(text = text, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderDescription.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderDescription.kt index dc3b0bc6a..8115c056d 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderDescription.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderDescription.kt @@ -15,11 +15,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors - +import io.redlink.more.app.android.theme.MoreColors @Composable -fun HeaderDescription (description: String, color: Color = MoreColors.Primary) { +fun HeaderDescription(description: String, color: Color = MoreColors.Primary) { Text( text = description, fontWeight = FontWeight.Medium, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderTitle.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderTitle.kt index 2427a92e9..0d72f721c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderTitle.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/HeaderTitle.kt @@ -18,10 +18,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun HeaderTitle ( +fun HeaderTitle( title: String, modifier: Modifier = Modifier, textAlign: TextAlign? = null, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Heading.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Heading.kt index 96e552e49..a136a0dca 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Heading.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Heading.kt @@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun Heading(text: String, modifier: Modifier = Modifier) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/IconInline.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/IconInline.kt index 38d022c56..084a4bf39 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/IconInline.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/IconInline.kt @@ -17,10 +17,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun IconInline(icon: ImageVector, color: Color = MoreColors.Primary, contentDescription: String, modifier: Modifier = Modifier) { +fun IconInline( + icon: ImageVector, + color: Color = MoreColors.Primary, + contentDescription: String, + modifier: Modifier = Modifier +) { Icon( icon, contentDescription = contentDescription, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MediumTitle.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MediumTitle.kt index 77598091a..22cd3b407 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MediumTitle.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MediumTitle.kt @@ -16,10 +16,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun MediumTitle(text: String, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start) { +fun MediumTitle( + text: String, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Start +) { Text( text = text, fontWeight = FontWeight.Medium, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt index 3fb389e31..f7f3fd56e 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MessageAlertDialog.kt @@ -18,19 +18,22 @@ import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.dialog.AlertDialogModel @Composable fun MessageAlertDialog(model: AlertDialogModel) { + val context = LocalContext.current MessageAlertDialog( - title = model.title, - message = model.message, - positiveButtonTitle = model.positiveTitle, - negativeButtonTitle = model.negativeTitle, - onPositive = model.onPositive, - onNegative = model.onNegative) + title = model.title.toString(context), + message = model.message.toString(context), + positiveButtonTitle = model.confirmLabel.toString(context), + negativeButtonTitle = model.cancelLabel?.toString(context), + onPositive = model.onConfirm, + onNegative = model.onDecline + ) } @Composable @@ -41,10 +44,13 @@ fun MessageAlertDialog( positiveButtonColors: ButtonColors? = null, negativeButtonTitle: String? = null, negativeButtonColors: ButtonColors? = null, - onPositive: () -> Unit, - onNegative: () -> Unit = {}, + onPositive: (() -> Unit)? = null, + onNegative: (() -> Unit)? = null ) { - val defaultButtonColors = ButtonDefaults.textButtonColors(backgroundColor = MoreColors.PrimaryLight, contentColor = MoreColors.Primary) + val defaultButtonColors = ButtonDefaults.textButtonColors( + backgroundColor = MoreColors.PrimaryLight, + contentColor = MoreColors.Primary + ) AlertDialog( onDismissRequest = { }, title = { @@ -58,7 +64,8 @@ fun MessageAlertDialog( Text(text = message) }, confirmButton = { - TextButton(onClick = { onPositive() }, + TextButton( + onClick = { onPositive?.let { it() } }, colors = positiveButtonColors ?: defaultButtonColors ) { Text(text = positiveButtonTitle) @@ -66,7 +73,8 @@ fun MessageAlertDialog( }, dismissButton = { if (negativeButtonTitle != null) { - TextButton(onClick = { onNegative() }, + TextButton( + onClick = { onNegative?.let { it() } }, colors = negativeButtonColors ?: defaultButtonColors ) { Text(text = negativeButtonTitle) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt index ccf071e67..36e4df934 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreBackgroundComposable.kt @@ -31,17 +31,20 @@ import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBackIos import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.main.MainTabView -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.MorePlatformTheme -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.MorePlatformTheme +import io.redlink.more.dialog.AlertController +import io.redlink.more.viewModels.ViewManager @Composable fun MoreBackground( @@ -54,28 +57,32 @@ fun MoreBackground( tabSelectionIndex: Int = 0, onTabChange: (Int) -> Unit = {}, maxWidth: Float = 0.9F, - alertDialogModel: AlertDialogModel? = null, unreadNotificationCount: Int = 0, content: @Composable () -> Unit, ) { val context = LocalContext.current - if (MoreApplication.openSettings.value) { + val alertDialogModel by AlertController.alertDialogModel.collectAsStateWithLifecycle(null) + val showSettingsView by ViewManager.showSettingsView.collectAsStateWithLifecycle(false) + + if (MoreApplication.openSettings.value || showSettingsView) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", context.packageName, null) } context.startActivity(intent) MoreApplication.openSettings.value = false + ViewManager.showSettingsView(false) } MorePlatformTheme { - Scaffold(topBar = { - MoreTopAppBar( - navigationTitle, - showBackButton, - onBackButtonClick, - leftCornerContent, - rightCornerContent - ) - }, + Scaffold( + topBar = { + MoreTopAppBar( + navigationTitle, + showBackButton, + onBackButtonClick, + leftCornerContent, + rightCornerContent + ) + }, bottomBar = { if (showTabRow) { MoreBottomAppBar( @@ -181,8 +188,7 @@ fun MoreBottomAppBar(selectedIndex: Int, unreadNotificationCount: Int, onTabChan fun BackgroundPreview() { MoreBackground( navigationTitle = "Test", - true, - alertDialogModel = AlertDialogModel("Test", "Message", "Accept", "DEcline", {}) + true ) { Text("Hello WOrld") } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreDivider.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreDivider.kt index fb93d304c..ba1a975d8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreDivider.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/MoreDivider.kt @@ -16,9 +16,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun MoreDivider(modifier: Modifier = Modifier, thickness: Dp = 1.dp, color: Color = MoreColors.Divider) { +fun MoreDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = MoreColors.Divider +) { Divider(modifier = modifier, thickness = thickness, color = color) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationBarTitle.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationBarTitle.kt index d4492041b..57030c748 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationBarTitle.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationBarTitle.kt @@ -14,7 +14,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun NavigationBarTitle(text: String) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationText.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationText.kt index d51c349b0..c20fbaccd 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationText.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/NavigationText.kt @@ -14,7 +14,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun NavigationText(text: String) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleList.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleList.kt index c54484b8f..1af94f5b3 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleList.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleList.kt @@ -11,68 +11,91 @@ package io.redlink.more.app.android.shared_composables import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.dashboard.schedule.list.ScheduleListItem import io.redlink.more.app.android.extensions.formattedString +import io.redlink.more.app.android.extensions.jvmLocalDate +import io.redlink.more.navigation.model.NavigationRouteParameter @Composable fun ScheduleList(viewModel: ScheduleViewModel, navController: NavController, showButton: Boolean) { - LazyColumn { - if (viewModel.schedulesByDate.isNotEmpty()) { - viewModel.schedulesByDate.entries.sortedBy { it.key }.forEach { entry -> - if (entry.value.isNotEmpty()) { - item { - Heading( - text = entry.key.formattedString(), - modifier = Modifier.fillMaxWidth() - ) - } - itemsIndexed( - entry.value.sortedWith( - compareBy( - { it.start }, - { it.end }, - { it.observationTitle }, - { it.scheduleId }) + val schedulesByDate by viewModel.coreViewModel.schedulesByDate.collectAsStateWithLifecycle() + + val sortedScheduleEntries by remember(schedulesByDate) { + derivedStateOf { + schedulesByDate.entries + .filter { it.value.isNotEmpty() } + .sortedBy { it.key } + .map { entry -> + entry.key to entry.value.sortedWith( + compareBy( + { it.start }, + { it.end }, + { it.observationTitle }, + { it.scheduleId } ) - ) { _, item -> - MoreDivider(Modifier.fillMaxWidth()) - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate( - NavigationScreen.SCHEDULE_DETAILS.navigationRoute( - "scheduleId" to item.scheduleId, - "scheduleListType" to viewModel.scheduleListType - ) - ) - } + ) + } + } + } + + LazyColumn { + sortedScheduleEntries.forEach { (date, schedules) -> + item(key = "header_$date") { + Heading( + text = date.jvmLocalDate().formattedString(), + modifier = Modifier.fillMaxWidth() + ) + } + + items( + items = schedules, + key = { schedule -> schedule.scheduleId } + ) { scheduleModel -> + MoreDivider(Modifier.fillMaxWidth()) + Column( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } ) { - ScheduleListItem( - navController = navController, - scheduleModel = item, - viewModel, - showButton = showButton + navController.navigate( + NavigationScreen.SCHEDULE_DETAILS.navigationRoute( + NavigationRouteParameter.SCHEDULE_ID.key to scheduleModel.scheduleId, + NavigationRouteParameter.SCHEDULE_LIST_TYPE.key to viewModel.scheduleListType + ) ) } - } - item { - Spacer(modifier = Modifier.height(30.dp)) - } + ) { + ScheduleListItem( + navController = navController, + scheduleModel = { scheduleModel }, + viewModel = viewModel, + showButton = showButton + ) } } + + item(key = "spacer_$date") { + Spacer(modifier = Modifier.height(30.dp)) + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt index e139d2830..1d11148f7 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt @@ -19,19 +19,23 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.activities.dashboard.composables.FilterView +import io.redlink.more.app.android.activities.dashboard.filter.DashboardFilterViewModel import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.moreImportant +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreImportant @Composable fun ScheduleListHeader( @@ -39,16 +43,19 @@ fun ScheduleListHeader( navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { + val filterViewModel = + remember { DashboardFilterViewModel(viewModel.coreViewModel.coreFilterModel) } + val errorCount by viewModel.coreViewModel.numberOfErrors.collectAsStateWithLifecycle() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.height(IntrinsicSize.Min) ) { TaskCompletionBarView(taskCompletionBarViewModel) - if (viewModel.numberOfObservationErrors() > 0) { + if (errorCount > 0) { Box(modifier = Modifier.padding(vertical = 4.dp)) { SmallTextIconButton( - text = "${viewModel.numberOfObservationErrors()} ${getStringResource(id = R.string.error)}", + text = "$errorCount ${getStringResource(id = R.string.error)}", imageText = "Error", image = Icons.Default.Warning, imageTint = MoreColors.White, @@ -60,7 +67,7 @@ fun ScheduleListHeader( } FilterView( navController, - model = viewModel.filterModel, + model = filterViewModel, scheduleListType = viewModel.scheduleListType ) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextButton.kt index 5a5afe50b..a8b777bf9 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextButton.kt @@ -22,8 +22,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.morePrimary +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.morePrimary @Composable fun SmallTextButton( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextIconButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextIconButton.kt index 3d110e45d..069ec0f62 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextIconButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTextIconButton.kt @@ -21,8 +21,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import io.redlink.more.app.android.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.morePrimary +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.morePrimary @Composable fun SmallTextIconButton( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTitle.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTitle.kt index a393df6fb..ada618737 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTitle.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SmallTitle.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun SmallTitle( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SwipeButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SwipeButton.kt index 14c8cb13f..141d2f257 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SwipeButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/SwipeButton.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors import kotlin.math.roundToInt @Composable diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeFrameDays.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeFrameDays.kt index 64d2a84d1..97986a200 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeFrameDays.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeFrameDays.kt @@ -22,11 +22,11 @@ import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.formattedString import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors import java.time.LocalDate @Composable -fun TimeframeDays(startTime: LocalDate, endTime: LocalDate, modifier: Modifier = Modifier){ +fun TimeframeDays(startTime: LocalDate, endTime: LocalDate, modifier: Modifier = Modifier) { Row(modifier = modifier) { Icon( Icons.Default.CalendarMonth, @@ -38,9 +38,9 @@ fun TimeframeDays(startTime: LocalDate, endTime: LocalDate, modifier: Modifier = text = startTime.let { val start: String = startTime.formattedString("dd.MM.yyyy") val end: String = endTime.formattedString("dd.MM.yyyy") - if(start != end) { - "$start - $end" - }else { + if (start != end) { + "$start - $end" + } else { start } }, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeframeHours.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeframeHours.kt index 2d6fb85cf..35357bbbc 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeframeHours.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeframeHours.kt @@ -22,11 +22,15 @@ import androidx.compose.ui.unit.dp import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.formattedString import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors import java.time.LocalDateTime @Composable -fun TimeframeHours(startTime: LocalDateTime, endTime: LocalDateTime, modifier: Modifier = Modifier){ +fun TimeframeHours( + startTime: LocalDateTime, + endTime: LocalDateTime, + modifier: Modifier = Modifier +) { Row(modifier = modifier) { Icon( Icons.Default.AccessTimeFilled, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Title.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Title.kt index d65795b3a..7a5b04aae 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Title.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/Title.kt @@ -18,14 +18,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable -fun Title(text: String, - modifier: Modifier = Modifier, - color: Color = MoreColors.PrimaryDark, - textAlign: TextAlign = TextAlign.Start, - maxLines: Int = 2 +fun Title( + text: String, + modifier: Modifier = Modifier, + color: Color = MoreColors.PrimaryDark, + textAlign: TextAlign = TextAlign.Start, + maxLines: Int = 2 ) { Text( text = text, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Color.kt b/androidApp/src/main/java/io/redlink/more/app/android/theme/Color.kt similarity index 94% rename from androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Color.kt rename to androidApp/src/main/java/io/redlink/more/app/android/theme/Color.kt index ec7c69f46..594726ffd 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Color.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/theme/Color.kt @@ -8,8 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.app.android.ui.theme - +package io.redlink.more.app.android.theme import androidx.compose.foundation.BorderStroke import androidx.compose.material.ButtonDefaults @@ -41,15 +40,16 @@ class MoreColors { val ApprovedMedium = Color(0xffb0d8bf) val ApprovedLight = Color(0xffe9f4ed) - val White = Color (0xffFFFFFF) - + val White = Color(0xffFFFFFF) // Special Design ElementsA val Divider = PrimaryLight200 // Devider Line between elements val BackgroundOverlay = SecondaryMedium // border definitions - fun borderPrimary(active: Boolean) = BorderStroke(1.dp, if (active) Primary else PrimaryLight200) + fun borderPrimary(active: Boolean) = + BorderStroke(1.dp, if (active) Primary else PrimaryLight200) + fun borderImportant() = BorderStroke(1.dp, Important) fun borderApproved() = BorderStroke(1.dp, Approved) fun borderDefault() = BorderStroke(1.dp, Secondary) @@ -95,5 +95,5 @@ fun ButtonDefaults.moreApproved() = buttonColors( disabledContentColor = MoreColors.ApprovedLight, disabledBackgroundColor = MoreColors.ApprovedMedium, -) + ) diff --git a/androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Theme.kt b/androidApp/src/main/java/io/redlink/more/app/android/theme/Theme.kt similarity index 97% rename from androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Theme.kt rename to androidApp/src/main/java/io/redlink/more/app/android/theme/Theme.kt index 431206098..124afdc69 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Theme.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/theme/Theme.kt @@ -8,8 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.app.android.ui.theme - +package io.redlink.more.app.android.theme import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme diff --git a/androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Type.kt b/androidApp/src/main/java/io/redlink/more/app/android/theme/Type.kt similarity index 96% rename from androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Type.kt rename to androidApp/src/main/java/io/redlink/more/app/android/theme/Type.kt index 4396052c1..9fa00464f 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/ui/theme/Type.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/theme/Type.kt @@ -8,8 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.app.android.ui.theme - +package io.redlink.more.app.android.theme import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle diff --git a/androidApp/src/main/java/io/redlink/more/app/android/util/ActivityProvider.kt b/androidApp/src/main/java/io/redlink/more/app/android/util/ActivityProvider.kt new file mode 100644 index 000000000..fdd094dcb --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/util/ActivityProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.app.android.util + +import android.app.Activity +import java.lang.ref.WeakReference + +/** + * Utility class to safely store and retrieve the current activity + * Uses WeakReference to prevent memory leaks + */ +object ActivityProvider { + private var currentActivityRef: WeakReference? = null + + /** + * Sets the current activity + * This should be called in the activity's onResume method + * @param activity The current activity + */ + fun setCurrentActivity(activity: Activity) { + currentActivityRef = WeakReference(activity) + } + + /** + * Clears the current activity + * This should be called in the activity's onPause method + */ + fun clearCurrentActivity() { + currentActivityRef = null + } + + /** + * Gets the current activity + * @return The current activity, or null if no activity is set or the activity has been garbage collected + */ + fun getCurrentActivity(): Activity? { + return currentActivityRef?.get() + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/util/AlarmUtils.kt b/androidApp/src/main/java/io/redlink/more/app/android/util/AlarmUtils.kt new file mode 100644 index 000000000..8951a0a0f --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/util/AlarmUtils.kt @@ -0,0 +1,127 @@ +package io.redlink.more.app.android.util + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import io.redlink.more.app.android.MoreApplication +import org.json.JSONArray + +object AlarmUtils { + private const val TAG_ALARMS = ":alarms" + + fun addAlarm( + context: Context, + intent: Intent, + notificationId: String, + triggerAtMillis: Long + ) { + val alarmManager = + context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return + + val pendingIntent = PendingIntent.getBroadcast( + context, + notificationId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtMillis, + pendingIntent + ) + } else { + // Fallback: schedule inexact (OS may batch). Consider requesting SCHEDULE_EXACT_ALARM if exact timing is required. + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtMillis, + pendingIntent + ) + } + } else { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtMillis, + pendingIntent + ) + } + + saveAlarmId(context, notificationId.hashCode()) + } + + fun cancelAlarm(context: Context, intent: Intent, notificationId: Int) { + val alarmManager = + context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return + + val pendingIntent = PendingIntent.getBroadcast( + context, + notificationId, + intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + alarmManager.cancel(pendingIntent) + pendingIntent.cancel() + + removeAlarmId(context, notificationId) + } + + fun cancelAllAlarms(context: Context, intent: Intent) { + getAlarmIds(context).forEach { idAlarm -> + cancelAlarm(context, intent, idAlarm) + } + } + + fun hasAlarm(context: Context, intent: Intent, notificationId: String): Boolean { + val pi = PendingIntent.getBroadcast( + context, + notificationId.hashCode(), + intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) + return pi != null + } + + private fun saveAlarmId(context: Context, id: Int) { + val idsAlarms = getAlarmIds(context).toMutableList() + if (idsAlarms.contains(id)) return + + idsAlarms.add(id) + saveIdsInPreferences(context, idsAlarms) + } + + fun removeAlarmId(context: Context, id: Int) { + val idsAlarms = getAlarmIds(context).toMutableList() + idsAlarms.removeAll { it == id } + saveIdsInPreferences(context, idsAlarms) + } + + private fun getAlarmIds(context: Context): List { + return try { + val prefs = MoreApplication.shared!!.sharedStorageRepository + val key = context.packageName + TAG_ALARMS + val json = prefs.load(key, "[]") + val jsonArray = JSONArray(json) + + buildList { + for (i in 0 until jsonArray.length()) { + add(jsonArray.getInt(i)) + } + } + } catch (_: Exception) { + emptyList() + } + } + + private fun saveIdsInPreferences(context: Context, ids: List) { + val jsonArray = JSONArray() + ids.forEach { idAlarm -> jsonArray.put(idAlarm) } + + val prefs = MoreApplication.shared!!.sharedStorageRepository + prefs.store(context.packageName + TAG_ALARMS, jsonArray.toString()) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/workers/DataUploadWorker.kt b/androidApp/src/main/java/io/redlink/more/app/android/workers/DataUploadWorker.kt index fe42b61c3..bf2027548 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/workers/DataUploadWorker.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/workers/DataUploadWorker.kt @@ -16,12 +16,12 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import io.github.aakira.napier.Napier import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationDataRepository -import io.redlink.more.more_app_mutliplatform.services.network.NetworkService -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk -import io.redlink.more.more_app_mutliplatform.services.store.CredentialRepository -import io.redlink.more.more_app_mutliplatform.services.store.EndpointRepository -import io.redlink.more.more_app_mutliplatform.services.store.SharedPreferencesRepository +import io.redlink.more.services.network.NetworkServiceImpl +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.store.CredentialRepository +import io.redlink.more.services.store.CredentialRepositoryImpl +import io.redlink.more.services.store.EndpointRepositoryImpl +import io.redlink.more.services.store.SharedPreferencesRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -30,38 +30,42 @@ private const val TAG = "DataUploadWorker" /** * The Worker, which tries to upload given databulks. It receives a bulk of data ids, queries them from the SQLite and sends them to the DSB. Then it deletes those ids on a successful return. */ -class DataUploadWorker ( +class DataUploadWorker( context: Context, workerParams: WorkerParameters, ) : CoroutineWorker(context, workerParams) { private val workManager = WorkManager.getInstance(applicationContext) private val sharedPreferences = SharedPreferencesRepository(applicationContext) - private val credentialRepository: CredentialRepository = MoreApplication.shared?.credentialRepository ?: CredentialRepository(sharedPreferences) - private val networkService = MoreApplication.shared?.networkService ?: NetworkService(EndpointRepository(sharedPreferences), credentialRepository) + private val credentialRepository: CredentialRepository = + MoreApplication.shared?.credentialRepository ?: CredentialRepositoryImpl( + sharedPreferences + ) + private val networkService = + MoreApplication.shared?.networkService ?: NetworkServiceImpl( + EndpointRepositoryImpl(sharedPreferences), credentialRepository + ) private var stopped = false - private val observationDataRepository = ObservationDataRepository() override suspend fun doWork(): Result = withContext(Dispatchers.IO) { Napier.i { "Starting DataUploadWorker doWork()" } - if (!credentialRepository.hasCredentials()) { + if (!credentialRepository.hasCredentials.value) { Napier.i { "No credentials found, DataUploadWorker failure" } return@withContext Result.failure() } try { Napier.i { "Worker started!" } - return@withContext observationDataRepository.allAsBulk()?.let { bulk -> - if (bulk.dataPoints.isNotEmpty()) { - return@withContext uploadDataBulk(bulk).apply { - observationDataRepository.close() + return@withContext MoreApplication.shared!!.repositories.observationData.allAsBulk() + ?.let { bulk -> + if (bulk.dataPoints.isNotEmpty()) { + return@withContext uploadDataBulk(bulk) } - } - Napier.i { "No data points found, DataUploadWorker success" } - Result.success() - } ?: Result.failure() + Napier.i { "No data points found, DataUploadWorker success" } + Result.success() + } ?: Result.failure() } catch (err: Exception) { - Napier.e(throwable = err, message = "Exception in DataUploadWorker") + Napier.e(throwable = err, message = "Exception in DataUploadWorker") if (isStopped) { stopped = isStopped workManager.cancelAllWork() @@ -83,7 +87,9 @@ class DataUploadWorker ( Result.retry() } else { Napier.i { "Deleting observation data..." } - observationDataRepository.deleteAllWithId(ids) + MoreApplication.shared!!.repositories.observationData.deleteAllWithId( + ids + ) Napier.i { "Deleted ${ids.size} observation data points, success" } Result.success() } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/workers/NotificationDataHandlerWorker.kt b/androidApp/src/main/java/io/redlink/more/app/android/workers/NotificationDataHandlerWorker.kt index cb47bb865..9404011c3 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/workers/NotificationDataHandlerWorker.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/workers/NotificationDataHandlerWorker.kt @@ -16,9 +16,8 @@ import androidx.work.WorkerParameters import com.google.common.reflect.TypeToken import com.google.gson.Gson import io.github.aakira.napier.Napier -import io.realm.kotlin.ext.toRealmDictionary +import io.redlink.more.Shared import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.Shared import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.lang.reflect.Type @@ -43,15 +42,15 @@ class NotificationDataHandlerWorker(context: Context, workerParameters: WorkerPa override suspend fun doWork(): Result = withContext(Dispatchers.IO) { try { - Napier.i( "Notification Worker started!") + Napier.i("Notification Worker started!") val data = inputData.getString(NOTIFICATION_DATA) val type: Type = object : TypeToken>() {}.type val notificationData: Map = Gson().fromJson(data, type) - Napier.i( "NotificationData: $notificationData") - shared.notificationManager.handleNotificationData(shared, notificationData.toRealmDictionary()) + Napier.i("NotificationData: $notificationData") + shared.notificationManager.handleNotificationData(notificationData) Result.success() } catch (err: Exception) { - Napier.e( err.stackTraceToString()) + Napier.e(err.stackTraceToString()) Result.failure() } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/workers/ScheduleUpdateWorker.kt b/androidApp/src/main/java/io/redlink/more/app/android/workers/ScheduleUpdateWorker.kt index 4392d3b73..1cadf328c 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/workers/ScheduleUpdateWorker.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/workers/ScheduleUpdateWorker.kt @@ -14,9 +14,8 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import io.github.aakira.napier.Napier +import io.redlink.more.Shared import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -34,7 +33,7 @@ class ScheduleUpdateWorker(context: Context, workerParameters: WorkerParameters) override suspend fun doWork() = withContext(Dispatchers.IO) { Napier.i { "Running $WORKER_TAG! Updating Schedule..." } - ScheduleRepository().updateTaskStatesSync(shared.observationFactory, shared.dataRecorder) + shared.updateSchedules() return@withContext Result.success() } diff --git a/androidApp/src/main/res/values-de/alert-dialog-strings.xml b/androidApp/src/main/res/values-de/alert-dialog-strings.xml index 92e94e77e..579ea2b1c 100644 --- a/androidApp/src/main/res/values-de/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values-de/alert-dialog-strings.xml @@ -6,4 +6,9 @@ Wir bitten um Erlaubnis, Ihnen Push-Benachrichtigungen zu senden. Dies hilft, den aktuellen Status der Studie jederzeit aufrechtzuerhalten und dient als Erinnerung an Ihre Aufgaben. Zu den Einstellungen gehen Ohne Berechtigungen fortfahren + Fehlende Berechtigungen + Keine Internetverbindung + Bitte verbinden Sie das Gerät mit dem Internet und versuchen es erneut! + Annehmen + Ablehnen \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/error-strings.xml b/androidApp/src/main/res/values-de/error-strings.xml index 60961f0a2..7328032ef 100644 --- a/androidApp/src/main/res/values-de/error-strings.xml +++ b/androidApp/src/main/res/values-de/error-strings.xml @@ -13,4 +13,6 @@ Beobachtung kann nicht gestartet werden! Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind! Fehler beim Fortsetzen der Beobachtung! Es gab ein Verbindungsproblem mit einem Bluetooth-Sensor. Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind! Funktion zur Messung der Herzfrequenz nicht verfügbar + Es konnte Garmin Connect nicht aufgerufen werden. Bitte versuchen Sie es später erneut! + Kein verfügbares Gerät verbunden \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/filter-strings.xml b/androidApp/src/main/res/values-de/filter-strings.xml index a6cc9ce15..75e9b931a 100644 --- a/androidApp/src/main/res/values-de/filter-strings.xml +++ b/androidApp/src/main/res/values-de/filter-strings.xml @@ -22,8 +22,5 @@ Typ auswählen Filter auswählen - Alle Nachrichten - Nicht gelesene Nachrichten anzeigen - Wichtige Nachrichten anzeigen \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/login-strings.xml b/androidApp/src/main/res/values-de/login-strings.xml index ac499b34f..370e3d46b 100644 --- a/androidApp/src/main/res/values-de/login-strings.xml +++ b/androidApp/src/main/res/values-de/login-strings.xml @@ -8,9 +8,12 @@ Login URL der Studie eingeben Ein Fehler beim Token ist aufgetreten + "Fehler im Token oder in der URL" + System Error! Bitte versuchen Sie es später oder kontaktieren Sie Ihren Studien-Administrator! Scannen Sie bitte Ihren QR Code Öffnen Sie die Kamera um den QR code zu scannen oder - - + QR Code wird automatisch gescannt. + Um den QR Code zu scannen benötigen wir Zugriff zu deiner Kamera. + QR Code Scanner-Overlay schließen \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/navigation-strings.xml b/androidApp/src/main/res/values-de/navigation-strings.xml index 0cfb09961..80c51d684 100644 --- a/androidApp/src/main/res/values-de/navigation-strings.xml +++ b/androidApp/src/main/res/values-de/navigation-strings.xml @@ -9,7 +9,7 @@ Studiendetails Filter Filter - Simple Frage + Simple Frage Laufende Beobachtungen Vergangene Beobachtungen Studie verlassen diff --git a/androidApp/src/main/res/values-de/notification-view-strings.xml b/androidApp/src/main/res/values-de/notification-view-strings.xml index ddf1579a3..d0f8fdc5e 100644 --- a/androidApp/src/main/res/values-de/notification-view-strings.xml +++ b/androidApp/src/main/res/values-de/notification-view-strings.xml @@ -1,14 +1,7 @@ Es gibt keine Nachrichten - - Nachrichten - Gelesene Nachrichten - Ungelesene Nachrichten - Ungelesene Nachrichten anzeigen - Alle Nachrichten More Logo - Nchrichten Die Nachrichtenliste ist derzeit leer! \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/observation-type-strings.xml b/androidApp/src/main/res/values-de/observation-type-strings.xml new file mode 100644 index 000000000..73abff9b1 --- /dev/null +++ b/androidApp/src/main/res/values-de/observation-type-strings.xml @@ -0,0 +1,12 @@ + + + Frage + Fragebogen + Multiple Choice + GPS + Accelerometer + App Nutzung + Polar Verity + Garmin + LimeSurvey + \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/schedule_view_strings.xml b/androidApp/src/main/res/values-de/schedule_view_strings.xml index a2293043e..fc9058477 100644 --- a/androidApp/src/main/res/values-de/schedule_view_strings.xml +++ b/androidApp/src/main/res/values-de/schedule_view_strings.xml @@ -2,4 +2,5 @@ Zeitraum: Mehr Details + Keine Aufgaben gefunden \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/strings.xml b/androidApp/src/main/res/values-de/strings.xml index 522385973..bedba6dd2 100644 --- a/androidApp/src/main/res/values-de/strings.xml +++ b/androidApp/src/main/res/values-de/strings.xml @@ -11,4 +11,5 @@ Gefahr Fenster schließen Fertig + Neu laden \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/study_states_strings.xml b/androidApp/src/main/res/values-de/study_states_strings.xml index 1b286941e..267e2241c 100644 --- a/androidApp/src/main/res/values-de/study_states_strings.xml +++ b/androidApp/src/main/res/values-de/study_states_strings.xml @@ -4,7 +4,10 @@ Die Studie ist vom Studienleiter derzeit pausiert und wird in Kürze fortgesetzt Die Studienkonfiguration wird gerade aktualisiert! Bitte warten Sie kurz, bis die Aktualisierung beendet wurde + Studie lädt… Diese Studie wurde beendet Vielen Dank für Ihre Teilnahme Nachricht von Ihrem Studien-Administrator + Fehler beim Laden der Studie + Es gab ein Problem beim Laden Ihrer Studie.\nBitte versuchen Sie es später erneut oder kontaktieren Sie Ihren Studien Administrator \ No newline at end of file diff --git a/androidApp/src/main/res/values/alert-dialog-strings.xml b/androidApp/src/main/res/values/alert-dialog-strings.xml index 1909482df..2bbb54fae 100644 --- a/androidApp/src/main/res/values/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values/alert-dialog-strings.xml @@ -6,4 +6,9 @@ We request permission to send you push notifications. This assists in maintaining the study\'s current status at all times and serves as a reminder for your tasks. Proceed to Settings Proceed Without Granting Permissions + Missing permissions + No internet connection + Please connect to the internet and try again + Accept + Decline \ No newline at end of file diff --git a/androidApp/src/main/res/values/error-strings.xml b/androidApp/src/main/res/values/error-strings.xml index d8f8e3a92..bf8a4e0e8 100644 --- a/androidApp/src/main/res/values/error-strings.xml +++ b/androidApp/src/main/res/values/error-strings.xml @@ -13,4 +13,6 @@ Cannot start Observation! Please make sure to enable bluetooth and connect all necessary devices! Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices! Heart-rate measurement feature unavailable + Cannot connect to Garmin Connect. Please try again later! + No viable device connected \ No newline at end of file diff --git a/androidApp/src/main/res/values/filter-strings.xml b/androidApp/src/main/res/values/filter-strings.xml index a3081eb9b..d12d92ca9 100644 --- a/androidApp/src/main/res/values/filter-strings.xml +++ b/androidApp/src/main/res/values/filter-strings.xml @@ -22,8 +22,5 @@ Select Type Select Filter - All Notifications - Unread - Important \ No newline at end of file diff --git a/androidApp/src/main/res/values/login-strings.xml b/androidApp/src/main/res/values/login-strings.xml index 7880dc87d..a35350d51 100644 --- a/androidApp/src/main/res/values/login-strings.xml +++ b/androidApp/src/main/res/values/login-strings.xml @@ -8,9 +8,12 @@ Login Enter Study URL Token Error + Token or Endpoint invalid + System Error! Please try again later or contact your Study Administrator! Scan QR Code Open camera to scan a QR code or - - + QR Code will be scanned automatically. + Camera permissions needed to scan QR code. + Close QR Code Scanner. \ No newline at end of file diff --git a/androidApp/src/main/res/values/navigation-strings.xml b/androidApp/src/main/res/values/navigation-strings.xml index e11680c6a..d917b4f3f 100644 --- a/androidApp/src/main/res/values/navigation-strings.xml +++ b/androidApp/src/main/res/values/navigation-strings.xml @@ -9,11 +9,12 @@ Study Details Observation Filter Notification Filter - Simple Question + Simple Question Running Observations Past Observations Leave Study Confirm to leave the study Limesurvey Observation Errors + Garmin Connect \ No newline at end of file diff --git a/androidApp/src/main/res/values/notification-strings.xml b/androidApp/src/main/res/values/notification-strings.xml index d691c5d7f..313c92e61 100644 --- a/androidApp/src/main/res/values/notification-strings.xml +++ b/androidApp/src/main/res/values/notification-strings.xml @@ -2,9 +2,9 @@ io.redlink.more.app.android.urgent unread_notifications_channel - More Project - More Project Notification - The More Project Notification Channel provides newest information and health requests + PraeCura + PraeCura Notification + The PraeCura Notification Channel provides newest information and health requests %1$d unread notifications You have %1$d unread notifications. Please check them out. diff --git a/androidApp/src/main/res/values/notification-view-strings.xml b/androidApp/src/main/res/values/notification-view-strings.xml index 4dee22c58..c2e262cba 100644 --- a/androidApp/src/main/res/values/notification-view-strings.xml +++ b/androidApp/src/main/res/values/notification-view-strings.xml @@ -1,14 +1,7 @@ No notifications to show - - Notifications - Read Notifications - Unread Notification - Show only unread notifications - All Notifications More Logo - Notification The notification list is currently empty! \ No newline at end of file diff --git a/androidApp/src/main/res/values/observation-type-strings.xml b/androidApp/src/main/res/values/observation-type-strings.xml new file mode 100644 index 000000000..855579091 --- /dev/null +++ b/androidApp/src/main/res/values/observation-type-strings.xml @@ -0,0 +1,12 @@ + + + Question + Questionnaire + Multiple Choice + GPS + Accelerometer + App Usage + Polar Verity + Garmin + LimeSurvey + \ No newline at end of file diff --git a/androidApp/src/main/res/values/schedule_view_strings.xml b/androidApp/src/main/res/values/schedule_view_strings.xml index 7c0762131..8014a51a1 100644 --- a/androidApp/src/main/res/values/schedule_view_strings.xml +++ b/androidApp/src/main/res/values/schedule_view_strings.xml @@ -2,4 +2,5 @@ Timeframe: More Details + No tasks found \ No newline at end of file diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml index 8cfcc118b..22501b9c2 100644 --- a/androidApp/src/main/res/values/strings.xml +++ b/androidApp/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ app - More + More App Version Close @@ -12,4 +12,5 @@ Danger Close Overlay Done + Reload \ No newline at end of file diff --git a/androidApp/src/main/res/values/study_states_strings.xml b/androidApp/src/main/res/values/study_states_strings.xml index c323e61f0..e3d599802 100644 --- a/androidApp/src/main/res/values/study_states_strings.xml +++ b/androidApp/src/main/res/values/study_states_strings.xml @@ -4,7 +4,10 @@ This study is currently paused by the Study Operator and will be resumed shortly The study configuration is currently updating Please wait until this process is finished + Study is loading… Study was completed Thank you for your participation Message by the Study Administrator + Error loading Study + There was an issue loading your study.\nPlease try again later or contact your study administrator \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 97133f66a..0e9fb93e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ buildscript { dependencies { - classpath("com.google.gms:google-services:4.4.2") - classpath("com.google.firebase:firebase-crashlytics-gradle:3.0.2") + classpath("com.google.gms:google-services:4.4.4") + classpath("com.google.firebase:firebase-crashlytics-gradle:3.0.6") } repositories { google() // Google's Maven repository @@ -10,15 +10,20 @@ buildscript { } plugins { - //trick: for the same plugin versions in all sub-modules - id("com.android.application").version("8.2.2").apply(false) - id("com.android.library").version("8.2.2").apply(false) - kotlin("android").version("1.9.23").apply(false) - kotlin("multiplatform").version("1.9.23").apply(false) - kotlin("plugin.serialization").version("1.9.23").apply(false) + id("com.android.application").version("8.13.2").apply(false) + id("com.android.library").version("8.13.2").apply(false) + kotlin("android").version("2.3.10").apply(false) + kotlin("multiplatform").version("2.3.10").apply(false) + kotlin("plugin.serialization").version("2.3.10").apply(false) + id("org.jetbrains.kotlin.plugin.compose").version("2.3.10").apply(false) + id("androidx.room").version("2.8.4").apply(false) + id("com.google.devtools.ksp").version("2.3.5").apply(false) + + id("com.rickclephas.kmp.nativecoroutines").version("1.0.1").apply(false) + id("dev.icerock.mobile.multiplatform-resources").version("0.25.2").apply(false) } tasks.register("clean", Delete::class) { delete(rootProject.buildDir) -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index dc2c082de..4d6892454 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,13 +1,21 @@ +# +# Copyright LBI-DHP and/or licensed to LBI-DHP under one or more +# contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute +# for Digital Health and Prevention -- A research institute of the +# Ludwig Boltzmann Gesellschaft, sterreichische Vereinigung zur +# Frderung der wissenschaftlichen Forschung). +# Licensed under the Apache 2.0 license with Commons Clause +# (see https://www.apache.org/licenses/LICENSE-2.0 and +# https://commonsclause.com/). +# #Gradle -org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" - +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" #Kotlin kotlin.code.style=official - #Android android.useAndroidX=true android.nonTransitiveRClass=true - #MPP kotlin.mpp.enableCInteropCommonization=true -kotlin.mpp.androidSourceSetLayoutVersion=2 \ No newline at end of file +kotlin.mpp.androidSourceSetLayoutVersion=2 +moko.resources.disableStaticFrameworkWarning=true \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..c599630ca --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/39846e8427e64a3824c13e399d7d813c/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index abae42cd6..6f1202ba7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Aug 11 11:24:10 CEST 2023 +#Wed Sep 17 11:04:57 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/iosApp/More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements b/iosApp/BlendedCare-Notification-Service-Extension/BlendedCare-Notification-Service-Extension.entitlements similarity index 100% rename from iosApp/More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements rename to iosApp/BlendedCare-Notification-Service-Extension/BlendedCare-Notification-Service-Extension.entitlements diff --git a/iosApp/More-Notification-Service-Extension/Info.plist b/iosApp/BlendedCare-Notification-Service-Extension/Info.plist similarity index 100% rename from iosApp/More-Notification-Service-Extension/Info.plist rename to iosApp/BlendedCare-Notification-Service-Extension/Info.plist diff --git a/iosApp/More-Notification-Service-Extension/NotificationService.swift b/iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift similarity index 65% rename from iosApp/More-Notification-Service-Extension/NotificationService.swift rename to iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift index 68cd8196c..4cceae43e 100644 --- a/iosApp/More-Notification-Service-Extension/NotificationService.swift +++ b/iosApp/BlendedCare-Notification-Service-Extension/NotificationService.swift @@ -13,7 +13,7 @@ class NotificationService: UNNotificationServiceExtension { private static let notificationCountKey = "notification_count" private static let STUDY_UPDATE_NOTIFICATION_KEY = "key" private static let STUDY_UPDATE_NOTIFICATION_VALUE = "STUDY_STATE_CHANGED" - + var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? let defaults = UserDefaults(suiteName: appGroup) @@ -21,36 +21,37 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - let dict = bestAttemptContent?.userInfo.notNilStringDictionary() ?? [:] - var notificationCount = defaults?.integer(forKey: NotificationService.notificationCountKey) ?? 0 - + + let storedCount = defaults?.integer(forKey: NotificationService.notificationCountKey) ?? 0 + + let payload = bestAttemptContent?.userInfo as? [AnyHashable: Any] + let serverBadge = + (payload?["badge"] as? NSNumber)?.intValue + ?? (payload?["unread_count"] as? NSNumber)?.intValue + ?? Int((payload?["unread_count"] as? String) ?? "") + + let proposedCount: Int + if let serverBadge { + proposedCount = max(storedCount, serverBadge) + } else { + proposedCount = storedCount + 1 + } + + let adjusted = max(0, proposedCount) + if let bestAttemptContent { - bestAttemptContent.badge = (notificationCount + 1) as NSNumber - - defaults?.set(notificationCount + 1, forKey: NotificationService.notificationCountKey) + bestAttemptContent.badge = NSNumber(value: adjusted) + defaults?.set(adjusted, forKey: NotificationService.notificationCountKey) contentHandler(bestAttemptContent) } } - + override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { contentHandler(bestAttemptContent) } } } - -extension Dictionary where Key == AnyHashable { - func notNilStringDictionary() -> [String: String] { - var data = [String: String]() - - for (key, value) in self { - if let value = value as? String { - data[String(describing: key)] = value - } - } - return data - } -} diff --git a/iosApp/Gemfile b/iosApp/Gemfile new file mode 100644 index 000000000..7a118b49b --- /dev/null +++ b/iosApp/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/iosApp/Gemfile.lock b/iosApp/Gemfile.lock new file mode 100644 index 000000000..57a65b578 --- /dev/null +++ b/iosApp/Gemfile.lock @@ -0,0 +1,225 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1185.0) + aws-sdk-core (3.238.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.117.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.204.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + bigdecimal (3.3.1) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.1) + 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.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.228.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.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.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.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.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + 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.31.0) + google-apis-core (>= 0.11.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.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.16.0) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.17.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + optparse (0.8.0) + os (1.1.4) + plist (3.7.2) + public_suffix (6.0.2) + rake (13.3.1) + 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.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + 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.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.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.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-24 + ruby + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.7.2 diff --git a/iosApp/fastlane/Appfile b/iosApp/fastlane/Appfile new file mode 100644 index 000000000..9e1d30249 --- /dev/null +++ b/iosApp/fastlane/Appfile @@ -0,0 +1,9 @@ +# app_identifier +# apple_id + #username + #team_id +# scheme + + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile new file mode 100644 index 000000000..31aac9adc --- /dev/null +++ b/iosApp/fastlane/Fastfile @@ -0,0 +1,169 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +if ENV["CI"] + update_fastlane +end + +default_platform(:ios) + +platform :ios do + lane :setup_google_services do + google_services_path = File.expand_path("../iosApp/GoogleService-Info.plist") + if !File.exist?(google_services_path) && ENV["GOOGLE_API_KEY"] && !ENV["GOOGLE_API_KEY"].empty? + UI.message("GoogleService-Info.plist not found, creating from GOOGLE_API_KEY environment variable") + require 'base64' + File.write(google_services_path, Base64.decode64(ENV["GOOGLE_API_KEY"].gsub(/^"(.*)"$/, '\1'))) + UI.success("Created GoogleService-Info.plist from environment variable") + elsif File.exist?(google_services_path) + UI.message("GoogleService-Info.plist already exists, skipping creation from environment variable") + else + UI.important("GoogleService-Info.plist not found and GOOGLE_API_KEY not provided") + end + end + + before_all do + setup_google_services + create_keychain( + name: "temp_keychain", + password: ENV["FASTLANE_KEYCHAIN_PASSWORD"], + default_keychain: true, + unlock: true, + timeout: 3600, + lock_when_sleeps: false + ) + + api_key = app_store_connect_api_key( + key_id: ENV['APPLE_CONNECT_KEY_ID'], + issuer_id: ENV['APPLE_CONNECT_ISSUER_ID'], + key_content: ENV['APPLE_CONNECT_KEY_CONTENT'], + is_key_content_base64: true, + duration: 1000 + ) + lane_context[SharedValues::APP_STORE_CONNECT_API_KEY] = api_key + + if ENV["FASTLANE_MATCH_SECRET"] && !ENV["FASTLANE_MATCH_SECRET"].empty? + ENV["MATCH_PASSWORD"] = ENV["FASTLANE_MATCH_SECRET"] + end + end + + after_all do |lane| + delete_keychain(name: "temp_keychain") if File.exist?(File.expand_path("~/Library/Keychains/temp_keychain-db")) + end + + private_lane :generate_openapi do + gradle( + task: ":shared:generateOpenApiClasses", + project_dir: ".." + ) + end + + lane :test do + gradle( + task: ":shared:iosSimulatorArm64Test", + project_dir: "..", + ) + end + + desc "Bump build number and version to FASTLANE_BUILD_NUMBER" + lane :increment_build do + version = ENV["FASTLANE_BUILD_NUMBER"] + UI.user_error!("FASTLANE_BUILD_NUMBER environment variable is not set") if version.to_s.empty? + increment_build_number(build_number: version) + increment_version_number(version_number: version) + end + + desc "Build the app for App Store" + lane :build do + identifiers = ENV["APP_IDENTIFIERS"].to_s.split(",").map(&:strip) + + match( + type: "appstore", + app_identifier: identifiers, + api_key: lane_context[SharedValues::APP_STORE_CONNECT_API_KEY], + team_id: ENV["FASTLANE_TEAM_ID"], + git_basic_authorization: ENV["MATCH_GIT_BASIC_AUTHORIZATION"], + verbose: true, + keychain_name: "temp_keychain", + keychain_password: ENV["FASTLANE_KEYCHAIN_PASSWORD"] + ) + if ENV["CI"] + unlock_keychain( + path: "~/Library/Keychains/temp_keychain-db", + password: ENV["FASTLANE_KEYCHAIN_PASSWORD"] + ) + + target_name = ENV["TARGETS"].to_s.split(",").map(&:strip) + identifiers.each_with_index do |identifier, index| + update_code_signing_settings( + use_automatic_signing: false, + targets: [target_name[index]], + team_id: ENV["FASTLANE_TEAM_ID"], + code_sign_identity: ENV["CODE_SIGN_IDENTITY"], + profile_name: "match AppStore #{identifier}", + bundle_identifier: identifier + ) + end + end + + generate_openapi + test + build_app( + scheme: ENV["FASTLANE_IOS_SCHEME"], + clean: true, + output_directory: "build", + export_method: "app-store", + export_team_id: ENV["FASTLANE_TEAM_ID"] + ) + + end + + desc "Deploy a new beta to TestFlight" + lane :deploy_beta do + UI.user_error!("APP_IDENTIFIERS is not set") if ENV['APP_IDENTIFIERS'].to_s.empty? + UI.user_error!("FASTLANE_BUILD_NUMBER is not set") if ENV['FASTLANE_BUILD_NUMBER'].to_s.empty? + + increment_build + build + + upload_to_testflight( + app_identifier: ENV['APP_IDENTIFIERS'].split(',').first, + api_key: lane_context[SharedValues::APP_STORE_CONNECT_API_KEY], + skip_waiting_for_build_processing: true + ) + + UI.success("Successfully deployed to TestFlight") + end + + desc "Deploy a new version to the App Store (production)" + lane :deploy_appstore do + UI.user_error!("APP_IDENTIFIERS is not set") if ENV['APP_IDENTIFIERS'].to_s.empty? + UI.user_error!("FASTLANE_BUILD_NUMBER is not set") if ENV['FASTLANE_BUILD_NUMBER'].to_s.empty? + + increment_build + build + + upload_to_app_store( + app_identifier: ENV['APP_IDENTIFIERS'].split(',').first, + api_key: lane_context[SharedValues::APP_STORE_CONNECT_API_KEY], + submit_for_review: false, + automatic_release: false + ) + + UI.success("Successfully uploaded build to App Store Connect for review") + end + + error do |lane, exception| + UI.error("Lane #{lane} failed: #{exception.message}") + end +end diff --git a/iosApp/fastlane/Matchfile b/iosApp/fastlane/Matchfile new file mode 100644 index 000000000..f57e6e0fc --- /dev/null +++ b/iosApp/fastlane/Matchfile @@ -0,0 +1,8 @@ +git_url("https://github.com/MORE-Platform/more_platform_signing_certificates.git") + +storage_mode("git") + +type("development") # The default type, can be: appstore, adhoc, enterprise or development + +app_identifier(["ac.at.lbg.dhp.more", "ac.at.lbg.dhp.more.More-Notification-Service-Extension"]) +username("jan.cortiel@icloud.com") diff --git a/iosApp/fastlane/README.md b/iosApp/fastlane/README.md new file mode 100644 index 000000000..89ad1f01d --- /dev/null +++ b/iosApp/fastlane/README.md @@ -0,0 +1,72 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios setup_google_services + +```sh +[bundle exec] fastlane ios setup_google_services +``` + + + +### ios test + +```sh +[bundle exec] fastlane ios test +``` + + + +### ios increment_build + +```sh +[bundle exec] fastlane ios increment_build +``` + +Bump build number and version to FASTLANE_BUILD_NUMBER + +### ios build + +```sh +[bundle exec] fastlane ios build +``` + +Build the app for App Store + +### ios deploy_beta + +```sh +[bundle exec] fastlane ios deploy_beta +``` + +Deploy a new beta to TestFlight + +### ios deploy_appstore + +```sh +[bundle exec] fastlane ios deploy_appstore +``` + +Deploy a new version to the App Store (production) + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/iosApp/fastlane/test_output/report.html b/iosApp/fastlane/test_output/report.html new file mode 100644 index 000000000..4bb3d7887 --- /dev/null +++ b/iosApp/fastlane/test_output/report.html @@ -0,0 +1,126 @@ + + + + + Test Results | xcpretty + + + + +
+
+

Test Results

+
+
+
+

0 tests

+ +
+
+ AllFailingPassing +
+
+
+
+ +
+ + + diff --git a/iosApp/fastlane/test_output/report.junit b/iosApp/fastlane/test_output/report.junit new file mode 100644 index 000000000..1e19aa86f --- /dev/null +++ b/iosApp/fastlane/test_output/report.junit @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index e2744f023..b2898f648 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -11,8 +11,6 @@ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 07190FEA29E80A7C00A8CB1F /* LeaveStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07190FE929E80A7C00A8CB1F /* LeaveStudyView.swift */; }; 07190FEC29E834E800A8CB1F /* LeaveStudyConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07190FEB29E834E800A8CB1F /* LeaveStudyConfirmationView.swift */; }; - 071ABEF529ED8C440013C1CF /* TriggerSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071ABEF429ED8C440013C1CF /* TriggerSlider.swift */; }; - 071ABEF729ED8C660013C1CF /* TriggerSliderSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071ABEF629ED8C660013C1CF /* TriggerSliderSettings.swift */; }; 07BC54AC29CB2F3C00459267 /* StudyDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07BC54AB29CB2F3C00459267 /* StudyDetailsView.swift */; }; 07BC54AE29CB2F4F00459267 /* StudyDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07BC54AD29CB2F4F00459267 /* StudyDetailsViewModel.swift */; }; 07BC54B229CB47C400459267 /* DetailsTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07BC54B129CB47C400459267 /* DetailsTitle.swift */; }; @@ -26,7 +24,7 @@ 1F0026DF29CCA24F0034EF65 /* DataUploadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0026DE29CCA24F0034EF65 /* DataUploadManager.swift */; }; 1F09A02C29F69E660001177F /* BluetoothConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A02B29F69E660001177F /* BluetoothConnectionView.swift */; }; 1F09A02E29F69E710001177F /* BluetoothConnectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F09A02D29F69E710001177F /* BluetoothConnectionViewModel.swift */; }; - 1F0FA80729ED7A1300B8D80E /* TaskScheduleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0FA80629ED7A1300B8D80E /* TaskScheduleService.swift */; }; + 1F0A11C82F333C0F00EAE237 /* ObservationReminderBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0A11C72F333C0F00EAE237 /* ObservationReminderBackgroundTask.swift */; }; 1F0FA80929EFE67300B8D80E /* IOSBluetoothConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0FA80829EFE67300B8D80E /* IOSBluetoothConnector.swift */; }; 1F13BA432993951200938C1E /* ConsentList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13BA422993951200938C1E /* ConsentList.swift */; }; 1F13BA45299396B200938C1E /* ConsentListHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13BA44299396B200938C1E /* ConsentListHeader.swift */; }; @@ -35,10 +33,17 @@ 1F13BA4B299398FD00938C1E /* MoreTextStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13BA4A299398FD00938C1E /* MoreTextStyle.swift */; }; 1F13BA4D29939E4F00938C1E /* MoreListStyleEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13BA4C29939E4F00938C1E /* MoreListStyleEdgeInsets.swift */; }; 1F13BA4F2993A2A600938C1E /* ContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F13BA4E2993A2A600938C1E /* ContentViewModel.swift */; }; + 1F1E45D12E7842F400C82016 /* RegistrationObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F1E45D02E7842F400C82016 /* RegistrationObservable.swift */; }; + 1F1E45D42E7848D800C82016 /* KMPNativeCoroutinesAsync in Frameworks */ = {isa = PBXBuildFile; productRef = 1F1E45D32E7848D800C82016 /* KMPNativeCoroutinesAsync */; }; + 1F1E45D62E7848D800C82016 /* KMPNativeCoroutinesCombine in Frameworks */ = {isa = PBXBuildFile; productRef = 1F1E45D52E7848D800C82016 /* KMPNativeCoroutinesCombine */; }; + 1F1E45D82E7848D800C82016 /* KMPNativeCoroutinesCore in Frameworks */ = {isa = PBXBuildFile; productRef = 1F1E45D72E7848D800C82016 /* KMPNativeCoroutinesCore */; }; + 1F1E45DA2E7848D800C82016 /* KMPNativeCoroutinesRxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 1F1E45D92E7848D800C82016 /* KMPNativeCoroutinesRxSwift */; }; + 1F1E45DC2E795A5900C82016 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1F1E45DB2E795A5900C82016 /* Localizable.xcstrings */; }; 1F27536129CC372500324417 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F27536029CC372500324417 /* AppState.swift */; }; 1F27536429CC3B8500324417 /* CMSensorDataListExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F27536329CC3B8500324417 /* CMSensorDataListExtension.swift */; }; 1F27536629CC3CBB00324417 /* TimeIntervalExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F27536529CC3CBB00324417 /* TimeIntervalExtension.swift */; }; 1F27536B29CC68FC00324417 /* ObservationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F27536A29CC68FC00324417 /* ObservationExtension.swift */; }; + 1F29C68C2E7A78CA003693C5 /* StudyLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F29C68B2E7A78CA003693C5 /* StudyLoadingView.swift */; }; 1F2C4BDD2A6F976F00C29888 /* StudyUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C4BDC2A6F976F00C29888 /* StudyUpdateView.swift */; }; 1F34797129B8AFE10030CA15 /* IOSObservationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F34797029B8AFE10030CA15 /* IOSObservationFactory.swift */; }; 1F34797529B8BECB0030CA15 /* AccelerometerObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F34797429B8BECB0030CA15 /* AccelerometerObservation.swift */; }; @@ -53,8 +58,11 @@ 1F43998929C0FF3E00687906 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F43998829C0FF3E00687906 /* MainTabView.swift */; }; 1F43998C29C1006800687906 /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F43998B29C1006800687906 /* NotificationView.swift */; }; 1F43998F29C1010400687906 /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F43998E29C1010400687906 /* InfoView.swift */; }; + 1F43DE212EC6137300B6F07B /* GarminConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F43DE202EC6137300B6F07B /* GarminConnectView.swift */; }; + 1F43DE232EC6137F00B6F07B /* GarminConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F43DE222EC6137F00B6F07B /* GarminConnectViewModel.swift */; }; + 1F45E5BB2F288D7500EE8487 /* ExitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F45E5BA2F288D7500EE8487 /* ExitButton.swift */; }; + 1F45E5BD2F288DD600EE8487 /* ReloadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F45E5BC2F288DD600EE8487 /* ReloadButton.swift */; }; 1F5A248D29C893B3008140CF /* AccelerometerBackgroundObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5A248C29C893B3008140CF /* AccelerometerBackgroundObservation.swift */; }; - 1F5F842429E67B370010C2D2 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1F5F842329E67B370010C2D2 /* GoogleService-Info.plist */; }; 1F5F842629E6C67A0010C2D2 /* LocalPushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5F842529E6C67A0010C2D2 /* LocalPushNotificationService.swift */; }; 1F60C58629951A5F00858581 /* ErrorText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F60C58529951A5F00858581 /* ErrorText.swift */; }; 1F638B7429D6B46300455B66 /* CMLogItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F638B7329D6B46300455B66 /* CMLogItemExtension.swift */; }; @@ -64,10 +72,6 @@ 1F6A4E3E29F6D0D200F0247F /* BluetoothDeviceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */; }; 1F6C31512A121EA500EED533 /* WebViewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6C31502A121EA500EED533 /* WebViewViewModel.swift */; }; 1F6C31532A13EB7F00EED533 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */; }; - 1F6D338129C1D2C70036532B /* ViewModifierExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6D338029C1D2C70036532B /* ViewModifierExtension.swift */; }; - 1F709AF32C0D8FB700FC6F5A /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 1F709AF22C0D8FB700FC6F5A /* RealmSwift */; }; - 1F709AF42C0D8FB700FC6F5A /* RealmSwift in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 1F709AF22C0D8FB700FC6F5A /* RealmSwift */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 1F750CA82A6F9C9B006E455E /* StudyStates.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F750CAA2A6F9C9B006E455E /* StudyStates.strings */; }; 1F750CAD2A6FA771006E455E /* StudyPausedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */; }; 1F750CAF2A6FA8AE006E455E /* StudyClosedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F750CAE2A6FA8AE006E455E /* StudyClosedView.swift */; }; 1F7F094E29D40EC800081B88 /* ObservationDataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7F094D29D40EC800081B88 /* ObservationDataCollector.swift */; }; @@ -93,7 +97,6 @@ 1F8847EC2992C3240023EF10 /* MoreFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8847EB2992C3240023EF10 /* MoreFrame.swift */; }; 1F8847EF29938C610023EF10 /* ConsentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8847EE29938C610023EF10 /* ConsentView.swift */; }; 1F8847F129938C6B0023EF10 /* ConsentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8847F029938C6B0023EF10 /* ConsentViewModel.swift */; }; - 1F8937852BFE31400083D20E /* Errors.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F8937832BFE31400083D20E /* Errors.strings */; }; 1F8937882BFF0DAB0083D20E /* ObservationErrorListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8937872BFF0DAB0083D20E /* ObservationErrorListView.swift */; }; 1F89378B2BFF1EBF0083D20E /* ObservationErrorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F89378A2BFF1EBF0083D20E /* ObservationErrorsView.swift */; }; 1F89378D2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F89378C2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift */; }; @@ -102,38 +105,24 @@ 1F8EA2D12A0CC2EA00F32602 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8EA2D02A0CC2EA00F32602 /* WebView.swift */; }; 1F8EA2D32A0CC7D600F32602 /* ObservationActionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8EA2D22A0CC7D600F32602 /* ObservationActionDelegate.swift */; }; 1F8EA2D52A0CE5EE00F32602 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8EA2D42A0CE5EE00F32602 /* ModalView.swift */; }; - 1F9B81BD2A28CBF70013738A /* KotlinMutableSetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9B81BC2A28CBF70013738A /* KotlinMutableSetExtension.swift */; }; + 1F988B3D2F2B93E60094F99F /* Napier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F988B3C2F2B93E60094F99F /* Napier.swift */; }; + 1F988B3F2F2BA0CA0094F99F /* DailyBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */; }; 1F9C3E8E298AAC1A00B9AC82 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9C3E8D298AAC1A00B9AC82 /* LoginView.swift */; }; 1F9C3E90298AACC100B9AC82 /* MoreMainBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9C3E8F298AACC100B9AC82 /* MoreMainBackgroundView.swift */; }; - 1F9C74302A30C72B003AE946 /* LoginView.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74322A30C72B003AE946 /* LoginView.strings */; }; - 1F9C74332A30C733003AE946 /* Default.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74352A30C733003AE946 /* Default.strings */; }; - 1F9C74362A30C752003AE946 /* ConsentView.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74382A30C752003AE946 /* ConsentView.strings */; }; - 1F9C74392A30C755003AE946 /* DashboardView.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C743B2A30C755003AE946 /* DashboardView.strings */; }; - 1F9C743C2A30C758003AE946 /* ScheduleListView.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C743E2A30C758003AE946 /* ScheduleListView.strings */; }; - 1F9C743F2A30C75A003AE946 /* SettingsView.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74412A30C75A003AE946 /* SettingsView.strings */; }; - 1F9C74422A30C75D003AE946 /* Navigation.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74442A30C75D003AE946 /* Navigation.strings */; }; - 1F9C74452A30C75F003AE946 /* StudyDetailsView.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74472A30C75F003AE946 /* StudyDetailsView.strings */; }; - 1F9C74482A30C763003AE946 /* TaskDetail.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C744A2A30C763003AE946 /* TaskDetail.strings */; }; - 1F9C744B2A30C766003AE946 /* ExpandableText.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C744D2A30C766003AE946 /* ExpandableText.strings */; }; - 1F9C744E2A30C768003AE946 /* NotificationView.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74502A30C768003AE946 /* NotificationView.strings */; }; - 1F9C74512A30C76B003AE946 /* DashboardFilter.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74532A30C76B003AE946 /* DashboardFilter.strings */; }; - 1F9C74542A30C76D003AE946 /* ObservationDetails.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74562A30C76D003AE946 /* ObservationDetails.strings */; }; - 1F9C74572A30C770003AE946 /* Info.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74592A30C770003AE946 /* Info.strings */; }; - 1F9C745A2A30C773003AE946 /* BluetoothConnection.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C745C2A30C773003AE946 /* BluetoothConnection.strings */; }; - 1F9C745D2A30C775003AE946 /* SimpleQuestionObservation.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C745F2A30C775003AE946 /* SimpleQuestionObservation.strings */; }; - 1F9C74602A30C779003AE946 /* LimeSurvey.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F9C74622A30C779003AE946 /* LimeSurvey.strings */; }; 1F9DB1A0298CF44000DBB7DB /* MoreColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */; }; 1F9DB1A3298D022E00DBB7DB /* MoreImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F9DB1A2298D022E00DBB7DB /* MoreImages.xcassets */; }; 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */; }; + 1FA044462F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */; }; 1FA763E82A42F834007C1CF9 /* NotificationFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA763E62A42F834007C1CF9 /* NotificationFilterViewModel.swift */; }; 1FA763E92A42F834007C1CF9 /* NotificationFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA763E72A42F834007C1CF9 /* NotificationFilterView.swift */; }; + 1FA9ADD12F5F0993004DF4ED /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1FA9ADD02F5F0993004DF4ED /* GoogleService-Info.plist */; }; + 1FB338B72E853936006BA594 /* StudyLoadingErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */; }; + 1FBCF72B2F699971002ABE61 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1FBCF72A2F699971002ABE61 /* InfoPlist.xcstrings */; }; 1FBD513A2BBC3E2D0029D185 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */; }; 1FBD513C2BBC3E8A0029D185 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 1FBD513B2BBC3E8A0029D185 /* FirebaseCrashlytics */; }; 1FC4F87429D2B86100F65026 /* DataUploadBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */; }; 1FC9574E2C072B7900EB92D6 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC9574D2C072B7900EB92D6 /* NotificationService.swift */; }; 1FC957522C072B7900EB92D6 /* More-Notification-Service-Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1FC9574B2C072B7900EB92D6 /* More-Notification-Service-Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 1FD531B52B693C3400C2D9FB /* AlertDialog.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1FD531B72B693C3400C2D9FB /* AlertDialog.strings */; }; - 1FDC264629C1C60F0011D8A4 /* ListViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDC264529C1C60F0011D8A4 /* ListViewExtension.swift */; }; 1FDC264829C1CEF40011D8A4 /* InfoListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDC264729C1CEF40011D8A4 /* InfoListItem.swift */; }; 1FDC264A29C1CFE80011D8A4 /* NavigationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDC264929C1CFE80011D8A4 /* NavigationText.swift */; }; 1FDC264C29C1D1660011D8A4 /* InfoList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDC264B29C1D1660011D8A4 /* InfoList.swift */; }; @@ -143,15 +132,23 @@ 1FF5B28B2A8274C10076EF8E /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF5B28A2A8274C10076EF8E /* AppVersion.swift */; }; 1FF5B28D2A8275790076EF8E /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF5B28C2A8275790076EF8E /* Bundle.swift */; }; 1FF6D8AD2BC516270050AF10 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1F9283C02BC512E500D459A7 /* PrivacyInfo.xcprivacy */; }; + 1FF8D2962F48687800C57A01 /* CheckboxField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF8D2952F48687800C57A01 /* CheckboxField.swift */; }; + 1FF8D29C2F486B5300C57A01 /* SingleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF8D29B2F486B5300C57A01 /* SingleChoiceView.swift */; }; + 1FF8D29E2F486B5500C57A01 /* MultiChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF8D29D2F486B5500C57A01 /* MultiChoiceView.swift */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 3007C4E152846B66C9FD5A69 /* SetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3007C5E9B53BC561D105D2B3 /* SetExtension.swift */; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; + B70888492DF2D4290048A4AC /* QRCodeScanDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70888482DF2D4220048A4AC /* QRCodeScanDelegate.swift */; }; + B708884B2DF2E6730048A4AC /* ScanQrCodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B708884A2DF2E66E0048A4AC /* ScanQrCodeViewModel.swift */; }; + B708884D2DF2EE600048A4AC /* CameraPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B708884C2DF2EE510048A4AC /* CameraPreviewView.swift */; }; B72C646029DC812900F5A7C6 /* MoreTextFieldHL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B72C645E29DC812900F5A7C6 /* MoreTextFieldHL.swift */; }; B72C646129DC812900F5A7C6 /* MoreTextFieldSmBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = B72C645F29DC812900F5A7C6 /* MoreTextFieldSmBottom.swift */; }; + B746706D2DF03FE800676D79 /* ScanQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B746706C2DF03FC100676D79 /* ScanQRCodeView.swift */; }; + B746706F2DF04BE200676D79 /* QRCodeCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B746706E2DF04BDD00676D79 /* QRCodeCameraView.swift */; }; B748DF3629CC43990026C348 /* ExpandableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = B748DF3529CC43990026C348 /* ExpandableText.swift */; }; B748DF3C29CC6B380026C348 /* ObservatinoDetailsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B748DF3B29CC6B380026C348 /* ObservatinoDetailsData.swift */; }; - B748DF4829D1C07F0026C348 /* SimpleQuestionObservationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B748DF4729D1C07F0026C348 /* SimpleQuestionObservationViewModel.swift */; }; - B748DF4A29D1C08D0026C348 /* SimpleQuetionObservationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B748DF4929D1C08D0026C348 /* SimpleQuetionObservationView.swift */; }; + B748DF4829D1C07F0026C348 /* QuestionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B748DF4729D1C07F0026C348 /* QuestionViewModel.swift */; }; + B748DF4A29D1C08D0026C348 /* QuestionObservationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B748DF4929D1C08D0026C348 /* QuestionObservationView.swift */; }; B748DF4E29D1CAE50026C348 /* RadioButtonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B748DF4D29D1CAE50026C348 /* RadioButtonField.swift */; }; B74DDB6629E69F44006FEA74 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74DDB6529E69F44006FEA74 /* NotificationViewModel.swift */; }; B74DDB6A29E6AEDE006FEA74 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74DDB6929E6AEDE006FEA74 /* NotificationItem.swift */; }; @@ -167,21 +164,16 @@ B78B4A8429F04BEA00A1BA58 /* ExpandableContentWithLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B78B4A8329F04BEA00A1BA58 /* ExpandableContentWithLink.swift */; }; B79BECBF29F17EE900966AD1 /* InfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79BECBE29F17EE900966AD1 /* InfoViewModel.swift */; }; B79BECC129F1811000966AD1 /* ContactInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79BECC029F1811000966AD1 /* ContactInfo.swift */; }; - B79DBA3729D312F200A1F547 /* ErrorLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA3629D312F200A1F547 /* ErrorLogin.swift */; }; B79DBA3929D3130600A1F547 /* ExpandableInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA3829D3130600A1F547 /* ExpandableInput.swift */; }; B79DBA3B29D3135E00A1F547 /* ForwardButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA3A29D3135E00A1F547 /* ForwardButton.swift */; }; B79DBA4729D3141400A1F547 /* NavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA4529D3141400A1F547 /* NavigationLinkButton.swift */; }; - B79DBA4929D3144500A1F547 /* UIToggleButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA4829D3144400A1F547 /* UIToggleButtonView.swift */; }; - B79DBA4E29D3162700A1F547 /* LoginQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA4D29D3162700A1F547 /* LoginQRCodeView.swift */; }; B79DBA5129D3431100A1F547 /* DashboardFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA5029D3431100A1F547 /* DashboardFilterView.swift */; }; B79DBA5329D3431B00A1F547 /* DashboardFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA5229D3431B00A1F547 /* DashboardFilterViewModel.swift */; }; B79DBA5729D3484D00A1F547 /* MoreFilterOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA5629D3484D00A1F547 /* MoreFilterOption.swift */; }; B79DBA5929D349DB00A1F547 /* MoreFilterText.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79DBA5829D349DB00A1F547 /* MoreFilterText.swift */; }; B7B4888229FBCAFA00999A0A /* ViewAdaptsToOpenKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7B4888129FBCAFA00999A0A /* ViewAdaptsToOpenKeyboard.swift */; }; ED16C5E729B72E2F00DA8AFE /* MoreFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED16C5E629B72E2F00DA8AFE /* MoreFilter.swift */; }; - ED16C5E929B7309F00DA8AFE /* StudyTitleForwardButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED16C5E829B7309F00DA8AFE /* StudyTitleForwardButton.swift */; }; - ED16C5EB29B7380300DA8AFE /* DashboardPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED16C5EA29B7380300DA8AFE /* DashboardPicker.swift */; }; - ED474B4C29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED474B4B29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift */; }; + ED474B4C29FAAC7A0077AD5C /* QuestionThankYouView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED474B4B29FAAC7A0077AD5C /* QuestionThankYouView.swift */; }; ED6A7F3F29DC405B00E266EC /* FCMService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED6A7F3E29DC405B00E266EC /* FCMService.swift */; }; ED74FF4629DEC73600BAA1EF /* KotlinLongExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED74FF4529DEC73600BAA1EF /* KotlinLongExtension.swift */; }; ED7AE1EB29C8A2B200616B93 /* TaskDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED7AE1EA29C8A2B200616B93 /* TaskDetailsViewModel.swift */; }; @@ -196,9 +188,7 @@ EDBB2B4A29B8CFF000CA973E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDBB2B4929B8CFF000CA973E /* SettingsView.swift */; }; EDCCC31229DAC3D6006D252F /* ObservationTimeDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDCCC31129DAC3D6006D252F /* ObservationTimeDetails.swift */; }; EDD1DEFF29F7B595009BC8FB /* ScheduleListHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD1DEFE29F7B595009BC8FB /* ScheduleListHeader.swift */; }; - EDD808D729BF4C3A005779DC /* MoreBackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD808D629BF4C3A005779DC /* MoreBackButton.swift */; }; EDDAEDB029B5DAD700141491 /* UISegmentedControlExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDAEDAF29B5DAD700141491 /* UISegmentedControlExtension.swift */; }; - EDEBA13329B0D2EE00533F79 /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDEBA13229B0D2EE00533F79 /* DashboardViewModel.swift */; }; EDEBA13529B0D30200533F79 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDEBA13429B0D30200533F79 /* DashboardView.swift */; }; EDEF4C8329EFD4CA00E830DA /* RunningSchedules.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDEF4C8229EFD4CA00E830DA /* RunningSchedules.swift */; }; /* End PBXBuildFile section */ @@ -231,7 +221,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 1F709AF42C0D8FB700FC6F5A /* RealmSwift in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -243,8 +232,6 @@ 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 07190FE929E80A7C00A8CB1F /* LeaveStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveStudyView.swift; sourceTree = ""; }; 07190FEB29E834E800A8CB1F /* LeaveStudyConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveStudyConfirmationView.swift; sourceTree = ""; }; - 071ABEF429ED8C440013C1CF /* TriggerSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerSlider.swift; sourceTree = ""; }; - 071ABEF629ED8C660013C1CF /* TriggerSliderSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerSliderSettings.swift; sourceTree = ""; }; 07BC54AB29CB2F3C00459267 /* StudyDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyDetailsView.swift; sourceTree = ""; }; 07BC54AD29CB2F4F00459267 /* StudyDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyDetailsViewModel.swift; sourceTree = ""; }; 07BC54B129CB47C400459267 /* DetailsTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsTitle.swift; sourceTree = ""; }; @@ -258,7 +245,7 @@ 1F0026DE29CCA24F0034EF65 /* DataUploadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataUploadManager.swift; sourceTree = ""; }; 1F09A02B29F69E660001177F /* BluetoothConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothConnectionView.swift; sourceTree = ""; }; 1F09A02D29F69E710001177F /* BluetoothConnectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothConnectionViewModel.swift; sourceTree = ""; }; - 1F0FA80629ED7A1300B8D80E /* TaskScheduleService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskScheduleService.swift; sourceTree = ""; }; + 1F0A11C72F333C0F00EAE237 /* ObservationReminderBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationReminderBackgroundTask.swift; sourceTree = ""; }; 1F0FA80829EFE67300B8D80E /* IOSBluetoothConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSBluetoothConnector.swift; sourceTree = ""; }; 1F13BA422993951200938C1E /* ConsentList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentList.swift; sourceTree = ""; }; 1F13BA44299396B200938C1E /* ConsentListHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentListHeader.swift; sourceTree = ""; }; @@ -267,10 +254,13 @@ 1F13BA4A299398FD00938C1E /* MoreTextStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreTextStyle.swift; sourceTree = ""; }; 1F13BA4C29939E4F00938C1E /* MoreListStyleEdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreListStyleEdgeInsets.swift; sourceTree = ""; }; 1F13BA4E2993A2A600938C1E /* ContentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewModel.swift; sourceTree = ""; }; + 1F1E45D02E7842F400C82016 /* RegistrationObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationObservable.swift; sourceTree = ""; }; + 1F1E45DB2E795A5900C82016 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 1F27536029CC372500324417 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 1F27536329CC3B8500324417 /* CMSensorDataListExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMSensorDataListExtension.swift; sourceTree = ""; }; 1F27536529CC3CBB00324417 /* TimeIntervalExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeIntervalExtension.swift; sourceTree = ""; }; 1F27536A29CC68FC00324417 /* ObservationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationExtension.swift; sourceTree = ""; }; + 1F29C68B2E7A78CA003693C5 /* StudyLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyLoadingView.swift; sourceTree = ""; }; 1F2C4BDC2A6F976F00C29888 /* StudyUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyUpdateView.swift; sourceTree = ""; }; 1F34797029B8AFE10030CA15 /* IOSObservationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSObservationFactory.swift; sourceTree = ""; }; 1F34797429B8BECB0030CA15 /* AccelerometerObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccelerometerObservation.swift; sourceTree = ""; }; @@ -285,8 +275,11 @@ 1F43998829C0FF3E00687906 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; 1F43998B29C1006800687906 /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; 1F43998E29C1010400687906 /* InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = ""; }; + 1F43DE202EC6137300B6F07B /* GarminConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminConnectView.swift; sourceTree = ""; }; + 1F43DE222EC6137F00B6F07B /* GarminConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminConnectViewModel.swift; sourceTree = ""; }; + 1F45E5BA2F288D7500EE8487 /* ExitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExitButton.swift; sourceTree = ""; }; + 1F45E5BC2F288DD600EE8487 /* ReloadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadButton.swift; sourceTree = ""; }; 1F5A248C29C893B3008140CF /* AccelerometerBackgroundObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccelerometerBackgroundObservation.swift; sourceTree = ""; }; - 1F5F842329E67B370010C2D2 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 1F5F842529E6C67A0010C2D2 /* LocalPushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPushNotificationService.swift; sourceTree = ""; }; 1F60C58529951A5F00858581 /* ErrorText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorText.swift; sourceTree = ""; }; 1F638B7329D6B46300455B66 /* CMLogItemExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMLogItemExtension.swift; sourceTree = ""; }; @@ -296,9 +289,6 @@ 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDeviceExtension.swift; sourceTree = ""; }; 1F6C31502A121EA500EED533 /* WebViewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewViewModel.swift; sourceTree = ""; }; 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; - 1F6D338029C1D2C70036532B /* ViewModifierExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModifierExtension.swift; sourceTree = ""; }; - 1F750CA92A6F9C9B006E455E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/StudyStates.strings; sourceTree = ""; }; - 1F750CAB2A6F9C9E006E455E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/StudyStates.strings; sourceTree = ""; }; 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyPausedView.swift; sourceTree = ""; }; 1F750CAE2A6FA8AE006E455E /* StudyClosedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyClosedView.swift; sourceTree = ""; }; 1F7F094D29D40EC800081B88 /* ObservationDataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationDataCollector.swift; sourceTree = ""; }; @@ -324,8 +314,6 @@ 1F8847EB2992C3240023EF10 /* MoreFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreFrame.swift; sourceTree = ""; }; 1F8847EE29938C610023EF10 /* ConsentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentView.swift; sourceTree = ""; }; 1F8847F029938C6B0023EF10 /* ConsentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentViewModel.swift; sourceTree = ""; }; - 1F8937842BFE31400083D20E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Errors.strings; sourceTree = ""; }; - 1F8937862BFE32890083D20E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Errors.strings; sourceTree = ""; }; 1F8937872BFF0DAB0083D20E /* ObservationErrorListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationErrorListView.swift; sourceTree = ""; }; 1F89378A2BFF1EBF0083D20E /* ObservationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationErrorsView.swift; sourceTree = ""; }; 1F89378C2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationErrorsViewModel.swift; sourceTree = ""; }; @@ -335,56 +323,24 @@ 1F8EA2D22A0CC7D600F32602 /* ObservationActionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationActionDelegate.swift; sourceTree = ""; }; 1F8EA2D42A0CE5EE00F32602 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; 1F9283C02BC512E500D459A7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; - 1F9B81BC2A28CBF70013738A /* KotlinMutableSetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinMutableSetExtension.swift; sourceTree = ""; }; + 1F988B3C2F2B93E60094F99F /* Napier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Napier.swift; sourceTree = ""; }; + 1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyBackgroundTask.swift; sourceTree = ""; }; 1F9C3E8D298AAC1A00B9AC82 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 1F9C3E8F298AACC100B9AC82 /* MoreMainBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMainBackgroundView.swift; sourceTree = ""; }; - 1F9C74312A30C72B003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/LoginView.strings; sourceTree = ""; }; - 1F9C74342A30C733003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Default.strings; sourceTree = ""; }; - 1F9C74372A30C752003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ConsentView.strings; sourceTree = ""; }; - 1F9C743A2A30C755003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/DashboardView.strings; sourceTree = ""; }; - 1F9C743D2A30C758003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ScheduleListView.strings; sourceTree = ""; }; - 1F9C74402A30C75A003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/SettingsView.strings; sourceTree = ""; }; - 1F9C74432A30C75D003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Navigation.strings; sourceTree = ""; }; - 1F9C74462A30C75F003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/StudyDetailsView.strings; sourceTree = ""; }; - 1F9C74492A30C763003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/TaskDetail.strings; sourceTree = ""; }; - 1F9C744C2A30C766003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ExpandableText.strings; sourceTree = ""; }; - 1F9C744F2A30C768003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/NotificationView.strings; sourceTree = ""; }; - 1F9C74522A30C76B003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/DashboardFilter.strings; sourceTree = ""; }; - 1F9C74552A30C76D003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ObservationDetails.strings; sourceTree = ""; }; - 1F9C74582A30C770003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Info.strings; sourceTree = ""; }; - 1F9C745B2A30C773003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/BluetoothConnection.strings; sourceTree = ""; }; - 1F9C745E2A30C775003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/SimpleQuestionObservation.strings; sourceTree = ""; }; - 1F9C74612A30C779003AE946 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/LimeSurvey.strings; sourceTree = ""; }; - 1F9C74632A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/LoginView.strings; sourceTree = ""; }; - 1F9C74642A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Default.strings; sourceTree = ""; }; - 1F9C74652A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ConsentView.strings; sourceTree = ""; }; - 1F9C74662A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/DashboardView.strings; sourceTree = ""; }; - 1F9C74672A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ScheduleListView.strings; sourceTree = ""; }; - 1F9C74682A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/SettingsView.strings; sourceTree = ""; }; - 1F9C74692A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Navigation.strings; sourceTree = ""; }; - 1F9C746A2A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/StudyDetailsView.strings; sourceTree = ""; }; - 1F9C746B2A30C7A2003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/TaskDetail.strings; sourceTree = ""; }; - 1F9C746C2A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ExpandableText.strings; sourceTree = ""; }; - 1F9C746D2A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/NotificationView.strings; sourceTree = ""; }; - 1F9C746E2A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/DashboardFilter.strings; sourceTree = ""; }; - 1F9C746F2A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ObservationDetails.strings; sourceTree = ""; }; - 1F9C74702A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Info.strings; sourceTree = ""; }; - 1F9C74712A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/BluetoothConnection.strings; sourceTree = ""; }; - 1F9C74722A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/SimpleQuestionObservation.strings; sourceTree = ""; }; - 1F9C74732A30C7A3003AE946 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/LimeSurvey.strings; sourceTree = ""; }; 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreColor.swift; sourceTree = ""; }; 1F9DB1A2298D022E00DBB7DB /* MoreImages.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MoreImages.xcassets; sourceTree = ""; }; 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MoreColors.xcassets; sourceTree = ""; }; + 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSObservationPermissionObserver.swift; sourceTree = ""; }; 1FA763E62A42F834007C1CF9 /* NotificationFilterViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationFilterViewModel.swift; sourceTree = ""; }; 1FA763E72A42F834007C1CF9 /* NotificationFilterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationFilterView.swift; sourceTree = ""; }; + 1FA9ADD02F5F0993004DF4ED /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyLoadingErrorView.swift; sourceTree = ""; }; + 1FBCF72A2F699971002ABE61 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataUploadBackgroundTask.swift; sourceTree = ""; }; 1FC9574B2C072B7900EB92D6 /* More-Notification-Service-Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "More-Notification-Service-Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 1FC9574D2C072B7900EB92D6 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 1FC9574F2C072B7900EB92D6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 1FC957572C072C1F00EB92D6 /* More-Notification-Service-Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "More-Notification-Service-Extension.entitlements"; sourceTree = ""; }; - 1FD531B62B693C3400C2D9FB /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/AlertDialog.strings; sourceTree = ""; }; - 1FD531B82B693C3900C2D9FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/AlertDialog.strings; sourceTree = ""; }; - 1FDC264529C1C60F0011D8A4 /* ListViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewExtension.swift; sourceTree = ""; }; + 1FC957572C072C1F00EB92D6 /* BlendedCare-Notification-Service-Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "BlendedCare-Notification-Service-Extension.entitlements"; sourceTree = ""; }; 1FDC264729C1CEF40011D8A4 /* InfoListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoListItem.swift; sourceTree = ""; }; 1FDC264929C1CFE80011D8A4 /* NavigationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationText.swift; sourceTree = ""; }; 1FDC264B29C1D1660011D8A4 /* InfoList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoList.swift; sourceTree = ""; }; @@ -393,17 +349,25 @@ 1FE4447129C85D94006AA11C /* IOSDataRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSDataRecorder.swift; sourceTree = ""; }; 1FF5B28A2A8274C10076EF8E /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; 1FF5B28C2A8275790076EF8E /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; + 1FF8D2952F48687800C57A01 /* CheckboxField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxField.swift; sourceTree = ""; }; + 1FF8D29B2F486B5300C57A01 /* SingleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceView.swift; sourceTree = ""; }; + 1FF8D29D2F486B5500C57A01 /* MultiChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiChoiceView.swift; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 3007C5E9B53BC561D105D2B3 /* SetExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetExtension.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* More.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = More.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B70888482DF2D4220048A4AC /* QRCodeScanDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScanDelegate.swift; sourceTree = ""; }; + B708884A2DF2E66E0048A4AC /* ScanQrCodeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQrCodeViewModel.swift; sourceTree = ""; }; + B708884C2DF2EE510048A4AC /* CameraPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewView.swift; sourceTree = ""; }; B72C645E29DC812900F5A7C6 /* MoreTextFieldHL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoreTextFieldHL.swift; sourceTree = ""; }; B72C645F29DC812900F5A7C6 /* MoreTextFieldSmBottom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoreTextFieldSmBottom.swift; sourceTree = ""; }; + B746706C2DF03FC100676D79 /* ScanQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeView.swift; sourceTree = ""; }; + B746706E2DF04BDD00676D79 /* QRCodeCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeCameraView.swift; sourceTree = ""; }; B748DF3529CC43990026C348 /* ExpandableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableText.swift; sourceTree = ""; }; B748DF3B29CC6B380026C348 /* ObservatinoDetailsData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservatinoDetailsData.swift; sourceTree = ""; }; - B748DF4729D1C07F0026C348 /* SimpleQuestionObservationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQuestionObservationViewModel.swift; sourceTree = ""; }; - B748DF4929D1C08D0026C348 /* SimpleQuetionObservationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQuetionObservationView.swift; sourceTree = ""; }; + B748DF4729D1C07F0026C348 /* QuestionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionViewModel.swift; sourceTree = ""; }; + B748DF4929D1C08D0026C348 /* QuestionObservationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionObservationView.swift; sourceTree = ""; }; B748DF4D29D1CAE50026C348 /* RadioButtonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButtonField.swift; sourceTree = ""; }; B74DDB6529E69F44006FEA74 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = ""; }; B74DDB6929E6AEDE006FEA74 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; @@ -419,29 +383,16 @@ B78B4A8329F04BEA00A1BA58 /* ExpandableContentWithLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableContentWithLink.swift; sourceTree = ""; }; B79BECBE29F17EE900966AD1 /* InfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoViewModel.swift; sourceTree = ""; }; B79BECC029F1811000966AD1 /* ContactInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInfo.swift; sourceTree = ""; }; - B79DBA3229D2E7F000A1F547 /* NavigationLinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationLinkButton.swift; sourceTree = ""; }; - B79DBA3629D312F200A1F547 /* ErrorLogin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorLogin.swift; sourceTree = ""; }; B79DBA3829D3130600A1F547 /* ExpandableInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExpandableInput.swift; sourceTree = ""; }; B79DBA3A29D3135E00A1F547 /* ForwardButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForwardButton.swift; sourceTree = ""; }; - B79DBA3E29D313D000A1F547 /* MoreTextFieldHL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoreTextFieldHL.swift; sourceTree = ""; }; - B79DBA4029D313E400A1F547 /* MoreTextFieldSmBottom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoreTextFieldSmBottom.swift; sourceTree = ""; }; B79DBA4529D3141400A1F547 /* NavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationLinkButton.swift; sourceTree = ""; }; - B79DBA4829D3144400A1F547 /* UIToggleButtonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIToggleButtonView.swift; sourceTree = ""; }; - B79DBA4D29D3162700A1F547 /* LoginQRCodeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LoginQRCodeView.swift; path = LoginQRCode/LoginQRCodeView.swift; sourceTree = ""; }; B79DBA5029D3431100A1F547 /* DashboardFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardFilterView.swift; sourceTree = ""; }; B79DBA5229D3431B00A1F547 /* DashboardFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardFilterViewModel.swift; sourceTree = ""; }; B79DBA5629D3484D00A1F547 /* MoreFilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreFilterOption.swift; sourceTree = ""; }; B79DBA5829D349DB00A1F547 /* MoreFilterText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreFilterText.swift; sourceTree = ""; }; B7B4888129FBCAFA00999A0A /* ViewAdaptsToOpenKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewAdaptsToOpenKeyboard.swift; sourceTree = ""; }; ED16C5E629B72E2F00DA8AFE /* MoreFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreFilter.swift; sourceTree = ""; }; - ED16C5E829B7309F00DA8AFE /* StudyTitleForwardButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTitleForwardButton.swift; sourceTree = ""; }; - ED16C5EA29B7380300DA8AFE /* DashboardPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPicker.swift; sourceTree = ""; }; - ED16C5EF29B74FD900DA8AFE /* ObservationDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationDetails.swift; sourceTree = ""; }; - ED16C5F629B764DC00DA8AFE /* ScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleView.swift; sourceTree = ""; }; - ED16C5FA29B764FE00DA8AFE /* ScheduleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleViewModel.swift; sourceTree = ""; }; - ED474B4B29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQuestionThankYouView.swift; sourceTree = ""; }; - ED5EBB3429B21A4000BAF0A6 /* ScheduleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleList.swift; sourceTree = ""; }; - ED5EBB3629B21A8A00BAF0A6 /* ScheduleListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleListItem.swift; sourceTree = ""; }; + ED474B4B29FAAC7A0077AD5C /* QuestionThankYouView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionThankYouView.swift; sourceTree = ""; }; ED6A7F3E29DC405B00E266EC /* FCMService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMService.swift; sourceTree = ""; }; ED74FF4529DEC73600BAA1EF /* KotlinLongExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinLongExtension.swift; sourceTree = ""; }; ED7AE1EA29C8A2B200616B93 /* TaskDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskDetailsViewModel.swift; sourceTree = ""; }; @@ -451,16 +402,12 @@ EDAF93EE29F17A340093F1DA /* CompletedSchedules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedSchedules.swift; sourceTree = ""; }; EDB16E2A29F933EF00701C27 /* TaskCompletionBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionBarViewModel.swift; sourceTree = ""; }; EDB16E2F29F9357300701C27 /* TaskCompletionBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionBarView.swift; sourceTree = ""; }; - EDBB2B4129B8ACD400CA973E /* ScheduleDateWithList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleDateWithList.swift; sourceTree = ""; }; EDBB2B4329B8B2A100CA973E /* Int64Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Int64Extension.swift; sourceTree = ""; }; EDBB2B4729B8CFE400CA973E /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; EDBB2B4929B8CFF000CA973E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - EDC89B1029B899D400E0A160 /* StartObservationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartObservationButton.swift; sourceTree = ""; }; EDCCC31129DAC3D6006D252F /* ObservationTimeDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationTimeDetails.swift; sourceTree = ""; }; EDD1DEFE29F7B595009BC8FB /* ScheduleListHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleListHeader.swift; sourceTree = ""; }; - EDD808D629BF4C3A005779DC /* MoreBackButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreBackButton.swift; sourceTree = ""; }; EDDAEDAF29B5DAD700141491 /* UISegmentedControlExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UISegmentedControlExtension.swift; sourceTree = ""; }; - EDEBA13229B0D2EE00533F79 /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; }; EDEBA13429B0D30200533F79 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; EDEF4C8229EFD4CA00E830DA /* RunningSchedules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningSchedules.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -477,9 +424,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1F1E45D42E7848D800C82016 /* KMPNativeCoroutinesAsync in Frameworks */, + 1F1E45D62E7848D800C82016 /* KMPNativeCoroutinesCombine in Frameworks */, + 1F1E45D82E7848D800C82016 /* KMPNativeCoroutinesCore in Frameworks */, + 1F1E45DA2E7848D800C82016 /* KMPNativeCoroutinesRxSwift in Frameworks */, 1FBD513A2BBC3E2D0029D185 /* FirebaseMessaging in Frameworks */, 1FBD513C2BBC3E8A0029D185 /* FirebaseCrashlytics in Frameworks */, - 1F709AF32C0D8FB700FC6F5A /* RealmSwift in Frameworks */, EDACF62729D2F31D0032327B /* PolarBleSdk in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -513,20 +463,12 @@ path = Bluetooth; sourceTree = ""; }; - 1F0BFA2D29B8D399003B3AB9 /* Recovered References */ = { + 1F1E45CF2E7842E300C82016 /* Registration */ = { isa = PBXGroup; children = ( - ED16C5F629B764DC00DA8AFE /* ScheduleView.swift */, - ED16C5FA29B764FE00DA8AFE /* ScheduleViewModel.swift */, - ED16C5EF29B74FD900DA8AFE /* ObservationDetails.swift */, - ED5EBB3629B21A8A00BAF0A6 /* ScheduleListItem.swift */, - EDBB2B4129B8ACD400CA973E /* ScheduleDateWithList.swift */, - EDC89B1029B899D400E0A160 /* StartObservationButton.swift */, - ED5EBB3429B21A4000BAF0A6 /* ScheduleList.swift */, - B79DBA4029D313E400A1F547 /* MoreTextFieldSmBottom.swift */, - B79DBA3E29D313D000A1F547 /* MoreTextFieldHL.swift */, - ); - name = "Recovered References"; + 1F1E45D02E7842F400C82016 /* RegistrationObservable.swift */, + ); + path = Registration; sourceTree = ""; }; 1F27536229CC39CA00324417 /* Services */ = { @@ -538,7 +480,6 @@ 1F0026DE29CCA24F0034EF65 /* DataUploadManager.swift */, ED6A7F3E29DC405B00E266EC /* FCMService.swift */, 1F5F842529E6C67A0010C2D2 /* LocalPushNotificationService.swift */, - 1F0FA80629ED7A1300B8D80E /* TaskScheduleService.swift */, ); path = Services; sourceTree = ""; @@ -546,6 +487,8 @@ 1F27536929CC53FE00324417 /* BackgroundTasks */ = { isa = PBXGroup; children = ( + 1F0A11C72F333C0F00EAE237 /* ObservationReminderBackgroundTask.swift */, + 1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */, 1F0026DC29CC8F710034EF65 /* BackgroundTaskHandler.swift */, 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */, ); @@ -558,6 +501,8 @@ 1F2C4BDC2A6F976F00C29888 /* StudyUpdateView.swift */, 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */, 1F750CAE2A6FA8AE006E455E /* StudyClosedView.swift */, + 1F29C68B2E7A78CA003693C5 /* StudyLoadingView.swift */, + 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */, ); path = StudyStates; sourceTree = ""; @@ -565,6 +510,7 @@ 1F34796F29B8AFCB0030CA15 /* Observations */ = { isa = PBXGroup; children = ( + 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */, 1F34797029B8AFE10030CA15 /* IOSObservationFactory.swift */, 1F34797429B8BECB0030CA15 /* AccelerometerObservation.swift */, 1F80EC4629C83F74004667B1 /* iOSObservationDataManager.swift */, @@ -601,6 +547,22 @@ path = Info; sourceTree = ""; }; + 1F43DE1F2EC6136700B6F07B /* GarminConnect */ = { + isa = PBXGroup; + children = ( + 1F43DE202EC6137300B6F07B /* GarminConnectView.swift */, + 1F43DE222EC6137F00B6F07B /* GarminConnectViewModel.swift */, + ); + path = GarminConnect; + sourceTree = ""; + }; + 1F60BFC92FA8A423007CD41A /* PC_Components */ = { + isa = PBXGroup; + children = ( + ); + path = PC_Components; + sourceTree = ""; + }; 1F6A4E3A29F6C94B00F0247F /* Bluetooth */ = { isa = PBXGroup; children = ( @@ -629,18 +591,15 @@ 1F13BA422993951200938C1E /* ConsentList.swift */, 1F13BA44299396B200938C1E /* ConsentListHeader.swift */, 1F13BA462993972E00938C1E /* ConsentListItem.swift */, - B79DBA3629D312F200A1F547 /* ErrorLogin.swift */, 1F60C58529951A5F00858581 /* ErrorText.swift */, B79DBA3829D3130600A1F547 /* ExpandableInput.swift */, B79DBA3A29D3135E00A1F547 /* ForwardButton.swift */, 1F13BA482993985800938C1E /* InactiveText.swift */, 1F8847E129917F5D0023EF10 /* MoreActionButton.swift */, - EDD808D629BF4C3A005779DC /* MoreBackButton.swift */, 1F8847D729915C030023EF10 /* MoreTextField.swift */, B72C645E29DC812900F5A7C6 /* MoreTextFieldHL.swift */, B72C645F29DC812900F5A7C6 /* MoreTextFieldSmBottom.swift */, B79DBA4529D3141400A1F547 /* NavigationLinkButton.swift */, - B79DBA3229D2E7F000A1F547 /* NavigationLinkButton.swift */, 1FDC264929C1CFE80011D8A4 /* NavigationText.swift */, 07BC54B129CB47C400459267 /* DetailsTitle.swift */, B784168F29C8B1DA0035D830 /* InlineAbortButton.swift */, @@ -653,19 +612,19 @@ 1F8847CD299155F50023EF10 /* SectionHeading.swift */, 1F8847B929914C8D0023EF10 /* Title.swift */, B784169129C9E3F50035D830 /* Title2.swift */, - B79DBA4829D3144400A1F547 /* UIToggleButtonView.swift */, 1F8847D1299158880023EF10 /* UIToggleFoldViewButton.swift */, B74DDB6929E6AEDE006FEA74 /* NotificationItem.swift */, 07FD293B29D57F0300853108 /* ModuleListItem.swift */, 07FD293D29D58F7C00853108 /* ExpandableContent.swift */, B78B4A8329F04BEA00A1BA58 /* ExpandableContentWithLink.swift */, EDD1DEFE29F7B595009BC8FB /* ScheduleListHeader.swift */, - 071ABEF429ED8C440013C1CF /* TriggerSlider.swift */, - 071ABEF629ED8C660013C1CF /* TriggerSliderSettings.swift */, 07F67D8929EECF1A006DCFB5 /* BasicNavLinkButton.swift */, 1F6A4E3829F6C7C000F0247F /* EmptyListView.swift */, 1FF5B28A2A8274C10076EF8E /* AppVersion.swift */, 1FDFB9842B62A81000CB87B1 /* MoreAlertDialog.swift */, + 1F45E5BA2F288D7500EE8487 /* ExitButton.swift */, + 1F45E5BC2F288DD600EE8487 /* ReloadButton.swift */, + 1FF8D2952F48687800C57A01 /* CheckboxField.swift */, ); path = Components; sourceTree = ""; @@ -692,26 +651,7 @@ 1F8847C4299151980023EF10 /* Strings */ = { isa = PBXGroup; children = ( - 1F9C74322A30C72B003AE946 /* LoginView.strings */, - 1F9C74352A30C733003AE946 /* Default.strings */, - 1F9C74382A30C752003AE946 /* ConsentView.strings */, - 1F9C743B2A30C755003AE946 /* DashboardView.strings */, - 1F9C743E2A30C758003AE946 /* ScheduleListView.strings */, - 1F9C74412A30C75A003AE946 /* SettingsView.strings */, - 1F9C74442A30C75D003AE946 /* Navigation.strings */, - 1F9C74472A30C75F003AE946 /* StudyDetailsView.strings */, - 1F9C744A2A30C763003AE946 /* TaskDetail.strings */, - 1F9C744D2A30C766003AE946 /* ExpandableText.strings */, - 1F9C74502A30C768003AE946 /* NotificationView.strings */, - 1F9C74532A30C76B003AE946 /* DashboardFilter.strings */, - 1F9C74562A30C76D003AE946 /* ObservationDetails.strings */, - 1F9C74592A30C770003AE946 /* Info.strings */, - 1F9C745C2A30C773003AE946 /* BluetoothConnection.strings */, - 1F9C745F2A30C775003AE946 /* SimpleQuestionObservation.strings */, - 1F9C74622A30C779003AE946 /* LimeSurvey.strings */, - 1F750CAA2A6F9C9B006E455E /* StudyStates.strings */, - 1FD531B72B693C3400C2D9FB /* AlertDialog.strings */, - 1F8937832BFE31400083D20E /* Errors.strings */, + 1F1E45DB2E795A5900C82016 /* Localizable.xcstrings */, ); path = Strings; sourceTree = ""; @@ -719,12 +659,10 @@ 1F8847CB299154650023EF10 /* Extensions */ = { isa = PBXGroup; children = ( - 1FDC264529C1C60F0011D8A4 /* ListViewExtension.swift */, 1F8847C029914FD60023EF10 /* StringExtension.swift */, EDDAEDAF29B5DAD700141491 /* UISegmentedControlExtension.swift */, EDBB2B4329B8B2A100CA973E /* Int64Extension.swift */, 1F43998029B8EA5100687906 /* DictionaryExtension.swift */, - 1F6D338029C1D2C70036532B /* ViewModifierExtension.swift */, 1F80EC4429C2524F004667B1 /* NavigationScreen.swift */, 1F27536329CC3B8500324417 /* CMSensorDataListExtension.swift */, 1F27536529CC3CBB00324417 /* TimeIntervalExtension.swift */, @@ -734,7 +672,6 @@ 1F638B7329D6B46300455B66 /* CMLogItemExtension.swift */, ED74FF4529DEC73600BAA1EF /* KotlinLongExtension.swift */, 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */, - 1F9B81BC2A28CBF70013738A /* KotlinMutableSetExtension.swift */, 1FF5B28C2A8275790076EF8E /* Bundle.swift */, 3007C5E9B53BC561D105D2B3 /* SetExtension.swift */, ); @@ -768,9 +705,20 @@ path = LimeSurvey; sourceTree = ""; }; + 1F988B3B2F2B93BF0094F99F /* Utils */ = { + isa = PBXGroup; + children = ( + 1F988B3C2F2B93E60094F99F /* Napier.swift */, + ); + path = Utils; + sourceTree = ""; + }; 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( + 1F60BFC92FA8A423007CD41A /* PC_Components */, + 1F43DE1F2EC6136700B6F07B /* GarminConnect */, + 1F1E45CF2E7842E300C82016 /* Registration */, 1F8937892BFF1EB20083D20E /* ObservationErrors */, 1F2C4BDB2A6F974900C29888 /* StudyStates */, 1F8EA2CB2A0BDF8D00F32602 /* LimeSurvey */, @@ -839,24 +787,32 @@ path = Filter; sourceTree = ""; }; - 1FC9574C2C072B7900EB92D6 /* More-Notification-Service-Extension */ = { + 1FC9574C2C072B7900EB92D6 /* BlendedCare-Notification-Service-Extension */ = { isa = PBXGroup; children = ( - 1FC957572C072C1F00EB92D6 /* More-Notification-Service-Extension.entitlements */, + 1FC957572C072C1F00EB92D6 /* BlendedCare-Notification-Service-Extension.entitlements */, 1FC9574D2C072B7900EB92D6 /* NotificationService.swift */, 1FC9574F2C072B7900EB92D6 /* Info.plist */, ); - path = "More-Notification-Service-Extension"; + path = "BlendedCare-Notification-Service-Extension"; + sourceTree = ""; + }; + 1FF8D2922F4867EC00C57A01 /* QuestionTypeViews */ = { + isa = PBXGroup; + children = ( + 1FF8D29B2F486B5300C57A01 /* SingleChoiceView.swift */, + 1FF8D29D2F486B5500C57A01 /* MultiChoiceView.swift */, + ); + path = QuestionTypeViews; sourceTree = ""; }; 7555FF72242A565900829871 = { isa = PBXGroup; children = ( 7555FF7D242A565900829871 /* iosApp */, - 1FC9574C2C072B7900EB92D6 /* More-Notification-Service-Extension */, + 1FC9574C2C072B7900EB92D6 /* BlendedCare-Notification-Service-Extension */, 7555FF7C242A565900829871 /* Products */, 7555FFB0242A642200829871 /* Frameworks */, - 1F0BFA2D29B8D399003B3AB9 /* Recovered References */, ); sourceTree = ""; }; @@ -872,16 +828,17 @@ 7555FF7D242A565900829871 /* iosApp */ = { isa = PBXGroup; children = ( + 1FA9ADD02F5F0993004DF4ED /* GoogleService-Info.plist */, 1F9283C02BC512E500D459A7 /* PrivacyInfo.xcprivacy */, - 1F5F842329E67B370010C2D2 /* GoogleService-Info.plist */, EDAC149529DEF2010084CE1C /* iosApp.entitlements */, + 1F8847C22991517F0023EF10 /* Resources */, 1F27536929CC53FE00324417 /* BackgroundTasks */, 1F27536229CC39CA00324417 /* Services */, 1F34796F29B8AFCB0030CA15 /* Observations */, - 1F8847C22991517F0023EF10 /* Resources */, 1F8847CB299154650023EF10 /* Extensions */, 1F9DB1A1298CF57600DBB7DB /* Style */, 1F9C3E8B298AAC0000B9AC82 /* Views */, + 1F988B3B2F2B93BF0094F99F /* Utils */, 7555FF82242A565900829871 /* ContentView.swift */, 1F13BA4E2993A2A600938C1E /* ContentViewModel.swift */, 7555FF8C242A565B00829871 /* Info.plist */, @@ -890,6 +847,7 @@ 1F0026DA29CC8E730034EF65 /* AppDelegate.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */, + 1FBCF72A2F699971002ABE61 /* InfoPlist.xcstrings */, ); path = iosApp; sourceTree = ""; @@ -904,9 +862,10 @@ B748DF4629D1C00D0026C348 /* QuestionObservation */ = { isa = PBXGroup; children = ( - B748DF4729D1C07F0026C348 /* SimpleQuestionObservationViewModel.swift */, - B748DF4929D1C08D0026C348 /* SimpleQuetionObservationView.swift */, - ED474B4B29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift */, + 1FF8D2922F4867EC00C57A01 /* QuestionTypeViews */, + B748DF4729D1C07F0026C348 /* QuestionViewModel.swift */, + B748DF4929D1C08D0026C348 /* QuestionObservationView.swift */, + ED474B4B29FAAC7A0077AD5C /* QuestionThankYouView.swift */, ); path = QuestionObservation; sourceTree = ""; @@ -923,7 +882,11 @@ B79DBA4C29D3161600A1F547 /* LoginQRCode */ = { isa = PBXGroup; children = ( - B79DBA4D29D3162700A1F547 /* LoginQRCodeView.swift */, + B708884C2DF2EE510048A4AC /* CameraPreviewView.swift */, + B708884A2DF2E66E0048A4AC /* ScanQrCodeViewModel.swift */, + B70888482DF2D4220048A4AC /* QRCodeScanDelegate.swift */, + B746706E2DF04BDD00676D79 /* QRCodeCameraView.swift */, + B746706C2DF03FC100676D79 /* ScanQRCodeView.swift */, ); name = LoginQRCode; sourceTree = ""; @@ -1017,10 +980,7 @@ isa = PBXGroup; children = ( B79DBA4F29D342FE00A1F547 /* DashboardFilter */, - ED16C5E829B7309F00DA8AFE /* StudyTitleForwardButton.swift */, EDEBA13429B0D30200533F79 /* DashboardView.swift */, - EDEBA13229B0D2EE00533F79 /* DashboardViewModel.swift */, - ED16C5EA29B7380300DA8AFE /* DashboardPicker.swift */, ); path = Dashboard; sourceTree = ""; @@ -1063,7 +1023,7 @@ 7555FF79242A565900829871 /* Resources */, 7555FFB4242A642300829871 /* Embed Frameworks */, 1FC957532C072B7900EB92D6 /* Embed Foundation Extensions */, - 1F8E9DB22A80D31D006E4AF4 /* ShellScript */, + 1F988B402F30941C0094F99F /* Run Script */, ); buildRules = ( ); @@ -1075,7 +1035,10 @@ EDACF62629D2F31D0032327B /* PolarBleSdk */, 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */, 1FBD513B2BBC3E8A0029D185 /* FirebaseCrashlytics */, - 1F709AF22C0D8FB700FC6F5A /* RealmSwift */, + 1F1E45D32E7848D800C82016 /* KMPNativeCoroutinesAsync */, + 1F1E45D52E7848D800C82016 /* KMPNativeCoroutinesCombine */, + 1F1E45D72E7848D800C82016 /* KMPNativeCoroutinesCore */, + 1F1E45D92E7848D800C82016 /* KMPNativeCoroutinesRxSwift */, ); productName = iosApp; productReference = 7555FF7B242A565900829871 /* More.app */; @@ -1112,7 +1075,7 @@ packageReferences = ( EDACF62529D2F31D0032327B /* XCRemoteSwiftPackageReference "polar-ble-sdk" */, 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, - 1F9283BB2BC510D100D459A7 /* XCRemoteSwiftPackageReference "realm-swift" */, + 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -1136,40 +1099,22 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1F9C74572A30C770003AE946 /* Info.strings in Resources */, - 1F9C74302A30C72B003AE946 /* LoginView.strings in Resources */, - 1F9C74392A30C755003AE946 /* DashboardView.strings in Resources */, - 1F9C74332A30C733003AE946 /* Default.strings in Resources */, - 1F9C744E2A30C768003AE946 /* NotificationView.strings in Resources */, - 1F9C74422A30C75D003AE946 /* Navigation.strings in Resources */, - 1F9C74452A30C75F003AE946 /* StudyDetailsView.strings in Resources */, - 1F8937852BFE31400083D20E /* Errors.strings in Resources */, 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */, + 1FA9ADD12F5F0993004DF4ED /* GoogleService-Info.plist in Resources */, 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, + 1FBCF72B2F699971002ABE61 /* InfoPlist.xcstrings in Resources */, 1F9DB1A3298D022E00DBB7DB /* MoreImages.xcassets in Resources */, - 1F9C74482A30C763003AE946 /* TaskDetail.strings in Resources */, - 1F9C745A2A30C773003AE946 /* BluetoothConnection.strings in Resources */, - 1F9C74362A30C752003AE946 /* ConsentView.strings in Resources */, - 1F9C743F2A30C75A003AE946 /* SettingsView.strings in Resources */, - 1F9C74512A30C76B003AE946 /* DashboardFilter.strings in Resources */, - 1F9C745D2A30C775003AE946 /* SimpleQuestionObservation.strings in Resources */, 1FF6D8AD2BC516270050AF10 /* PrivacyInfo.xcprivacy in Resources */, - 1F750CA82A6F9C9B006E455E /* StudyStates.strings in Resources */, - 1F5F842429E67B370010C2D2 /* GoogleService-Info.plist in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, - 1F9C74602A30C779003AE946 /* LimeSurvey.strings in Resources */, - 1F9C74542A30C76D003AE946 /* ObservationDetails.strings in Resources */, - 1FD531B52B693C3400C2D9FB /* AlertDialog.strings in Resources */, - 1F9C743C2A30C758003AE946 /* ScheduleListView.strings in Resources */, 1F6C31532A13EB7F00EED533 /* Launch Screen.storyboard in Resources */, - 1F9C744B2A30C766003AE946 /* ExpandableText.strings in Resources */, + 1F1E45DC2E795A5900C82016 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1F8E9DB22A80D31D006E4AF4 /* ShellScript */ = { + 1F988B402F30941C0094F99F /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -1177,19 +1122,15 @@ inputFileListPaths = ( ); inputPaths = ( - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", - "$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", - "$(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_PATH)", ); + name = "Run Script"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"$SRCROOT/../gradlew\" -p \"$SRCROOT/../\" :shared:copyFrameworkResourcesToApp \\\n -Pmoko.resources.PLATFORM_NAME=\"$PLATFORM_NAME\" \\\n -Pmoko.resources.CONFIGURATION=\"${KOTLIN_FRAMEWORK_BUILD_TYPE:-$CONFIGURATION}\" \\\n -Pmoko.resources.ARCHS=\"$ARCHS\" \\\n -Pmoko.resources.BUILT_PRODUCTS_DIR=\"$BUILT_PRODUCTS_DIR\" \\\n -Pmoko.resources.CONTENTS_FOLDER_PATH=\"$CONTENTS_FOLDER_PATH\" \n"; }; 7555FFB5242A651A00829871 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; @@ -1226,17 +1167,20 @@ 1F89378D2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift in Sources */, 1F5A248D29C893B3008140CF /* AccelerometerBackgroundObservation.swift in Sources */, 1FF5B28D2A8275790076EF8E /* Bundle.swift in Sources */, + 1F29C68C2E7A78CA003693C5 /* StudyLoadingView.swift in Sources */, 1FA763E82A42F834007C1CF9 /* NotificationFilterViewModel.swift in Sources */, B784169029C8B1DA0035D830 /* InlineAbortButton.swift in Sources */, B748DF4E29D1CAE50026C348 /* RadioButtonField.swift in Sources */, 1F13BA432993951200938C1E /* ConsentList.swift in Sources */, 1F8847E6299182C90023EF10 /* LoginButton.swift in Sources */, + 1F0A11C82F333C0F00EAE237 /* ObservationReminderBackgroundTask.swift in Sources */, EDD1DEFF29F7B595009BC8FB /* ScheduleListHeader.swift in Sources */, 1F7F094E29D40EC800081B88 /* ObservationDataCollector.swift in Sources */, B79DBA3929D3130600A1F547 /* ExpandableInput.swift in Sources */, 1F8847E4299180980023EF10 /* MoreBorder.swift in Sources */, 1F8847CA299154120023EF10 /* MoreFont.swift in Sources */, 1F43998929C0FF3E00687906 /* MainTabView.swift in Sources */, + 1F45E5BB2F288D7500EE8487 /* ExitButton.swift in Sources */, B79BECBF29F17EE900966AD1 /* InfoViewModel.swift in Sources */, 1F8847E229917F5D0023EF10 /* MoreActionButton.swift in Sources */, 1F6A4E3E29F6D0D200F0247F /* BluetoothDeviceExtension.swift in Sources */, @@ -1250,6 +1194,7 @@ 1F8847D4299158BD0023EF10 /* MoreImage.swift in Sources */, 1F8847C82991535B0023EF10 /* MoreFontWeight.swift in Sources */, 1F13BA4B299398FD00938C1E /* MoreTextStyle.swift in Sources */, + 1FF8D29E2F486B5500C57A01 /* MultiChoiceView.swift in Sources */, 1F43998529C0FE2200687906 /* Navigation.swift in Sources */, 1F43998129B8EA5100687906 /* DictionaryExtension.swift in Sources */, 1FE4447029C849DC006AA11C /* Semaphore.swift in Sources */, @@ -1264,27 +1209,27 @@ 1F7F095029D4185200081B88 /* ArrayExtension.swift in Sources */, 1F8847EF29938C610023EF10 /* ConsentView.swift in Sources */, 1F8EA2D12A0CC2EA00F32602 /* WebView.swift in Sources */, - B79DBA4929D3144500A1F547 /* UIToggleButtonView.swift in Sources */, B79DBA5929D349DB00A1F547 /* MoreFilterText.swift in Sources */, 1F80EC4729C83F74004667B1 /* iOSObservationDataManager.swift in Sources */, 07BC54AE29CB2F4F00459267 /* StudyDetailsViewModel.swift in Sources */, 1F43998F29C1010400687906 /* InfoView.swift in Sources */, - B748DF4A29D1C08D0026C348 /* SimpleQuetionObservationView.swift in Sources */, + B748DF4A29D1C08D0026C348 /* QuestionObservationView.swift in Sources */, EDBB2B4A29B8CFF000CA973E /* SettingsView.swift in Sources */, - 071ABEF529ED8C440013C1CF /* TriggerSlider.swift in Sources */, 1FDC264C29C1D1660011D8A4 /* InfoList.swift in Sources */, B784169229C9E3F50035D830 /* Title2.swift in Sources */, - EDD808D729BF4C3A005779DC /* MoreBackButton.swift in Sources */, 1F8847BA29914C8D0023EF10 /* Title.swift in Sources */, 1F8937882BFF0DAB0083D20E /* ObservationErrorListView.swift in Sources */, + 1F1E45D12E7842F400C82016 /* RegistrationObservable.swift in Sources */, 1F9C3E8E298AAC1A00B9AC82 /* LoginView.swift in Sources */, + 1FF8D29C2F486B5300C57A01 /* SingleChoiceView.swift in Sources */, 1F6A4E3929F6C7C000F0247F /* EmptyListView.swift in Sources */, + B746706F2DF04BE200676D79 /* QRCodeCameraView.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, EDDAEDB029B5DAD700141491 /* UISegmentedControlExtension.swift in Sources */, 1F69081329D44D900079C219 /* DateExtension.swift in Sources */, - EDEBA13329B0D2EE00533F79 /* DashboardViewModel.swift in Sources */, 1FF5B28B2A8274C10076EF8E /* AppVersion.swift in Sources */, 1F09A02C29F69E660001177F /* BluetoothConnectionView.swift in Sources */, + 1F43DE212EC6137300B6F07B /* GarminConnectView.swift in Sources */, B72C646129DC812900F5A7C6 /* MoreTextFieldSmBottom.swift in Sources */, 1F43997D29B8D70800687906 /* ScheduleListItem.swift in Sources */, B79DBA5729D3484D00A1F547 /* MoreFilterOption.swift in Sources */, @@ -1292,21 +1237,19 @@ 1F43997B29B8D70800687906 /* ObservationButton.swift in Sources */, 07190FEA29E80A7C00A8CB1F /* LeaveStudyView.swift in Sources */, 1F8847B729911E6E0023EF10 /* LoginViewModel.swift in Sources */, + 1F988B3D2F2B93E60094F99F /* Napier.swift in Sources */, 1F13BA4D29939E4F00938C1E /* MoreListStyleEdgeInsets.swift in Sources */, 1F8EA2D52A0CE5EE00F32602 /* ModalView.swift in Sources */, 1F8847D2299158880023EF10 /* UIToggleFoldViewButton.swift in Sources */, 1F13BA45299396B200938C1E /* ConsentListHeader.swift in Sources */, - 1FDC264629C1C60F0011D8A4 /* ListViewExtension.swift in Sources */, 1FA763E92A42F834007C1CF9 /* NotificationFilterView.swift in Sources */, + 1F988B3F2F2BA0CA0094F99F /* DailyBackgroundTask.swift in Sources */, 1F43997129B8D6FB00687906 /* ScheduleViewModel.swift in Sources */, B784169829CA08850035D830 /* CircleActivityIndicator.swift in Sources */, B748DF3C29CC6B380026C348 /* ObservatinoDetailsData.swift in Sources */, - 071ABEF729ED8C660013C1CF /* TriggerSliderSettings.swift in Sources */, 1FDC264829C1CEF40011D8A4 /* InfoListItem.swift in Sources */, - ED16C5EB29B7380300DA8AFE /* DashboardPicker.swift in Sources */, 1FE4447229C85D94006AA11C /* IOSDataRecorder.swift in Sources */, 1F638B7429D6B46300455B66 /* CMLogItemExtension.swift in Sources */, - 1F0FA80729ED7A1300B8D80E /* TaskScheduleService.swift in Sources */, 1F8847C129914FD60023EF10 /* StringExtension.swift in Sources */, 1F09A02E29F69E710001177F /* BluetoothConnectionViewModel.swift in Sources */, 1F0026DD29CC8F710034EF65 /* BackgroundTaskHandler.swift in Sources */, @@ -1314,28 +1257,31 @@ ED6A7F3F29DC405B00E266EC /* FCMService.swift in Sources */, 1F5F842629E6C67A0010C2D2 /* LocalPushNotificationService.swift in Sources */, B77F20112A014C8400C44118 /* NavigationModalState.swift in Sources */, + B708884D2DF2EE600048A4AC /* CameraPreviewView.swift in Sources */, 1F2C4BDD2A6F976F00C29888 /* StudyUpdateView.swift in Sources */, - B79DBA3729D312F200A1F547 /* ErrorLogin.swift in Sources */, EDCCC31229DAC3D6006D252F /* ObservationTimeDetails.swift in Sources */, + 1FB338B72E853936006BA594 /* StudyLoadingErrorView.swift in Sources */, 07F67D8A29EECF1A006DCFB5 /* BasicNavLinkButton.swift in Sources */, + 1F45E5BD2F288DD600EE8487 /* ReloadButton.swift in Sources */, B79DBA3B29D3135E00A1F547 /* ForwardButton.swift in Sources */, 1F34797529B8BECB0030CA15 /* AccelerometerObservation.swift in Sources */, B79DBA5329D3431B00A1F547 /* DashboardFilterViewModel.swift in Sources */, 07D9046829C85166003D2912 /* GPSObservation.swift in Sources */, EDB16E3029F9357300701C27 /* TaskCompletionBarView.swift in Sources */, - ED474B4C29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift in Sources */, + ED474B4C29FAAC7A0077AD5C /* QuestionThankYouView.swift in Sources */, + B708884B2DF2E6730048A4AC /* ScanQrCodeViewModel.swift in Sources */, 1F13BA472993972E00938C1E /* ConsentListItem.swift in Sources */, B74DDB6629E69F44006FEA74 /* NotificationViewModel.swift in Sources */, 1FDC264A29C1CFE80011D8A4 /* NavigationText.swift in Sources */, 1F9C3E90298AACC100B9AC82 /* MoreMainBackgroundView.swift in Sources */, - 1F9B81BD2A28CBF70013738A /* KotlinMutableSetExtension.swift in Sources */, 07190FEC29E834E800A8CB1F /* LeaveStudyConfirmationView.swift in Sources */, + 1FA044462F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift in Sources */, 1F6C31512A121EA500EED533 /* WebViewViewModel.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, 1F8847E0299160400023EF10 /* MoreTextFieldStyle.swift in Sources */, + 1FF8D2962F48687800C57A01 /* CheckboxField.swift in Sources */, 1F27536429CC3B8500324417 /* CMSensorDataListExtension.swift in Sources */, B77F20172A02541700C44118 /* InfoListItemModal.swift in Sources */, - ED16C5E929B7309F00DA8AFE /* StudyTitleForwardButton.swift in Sources */, 1F8EA2CF2A0CC22200F32602 /* LimeSurveyView.swift in Sources */, 1F13BA4F2993A2A600938C1E /* ContentViewModel.swift in Sources */, ED7AE1ED29C8A2BF00616B93 /* TaskDetailsView.swift in Sources */, @@ -1346,13 +1292,12 @@ 07FD293E29D58F7C00853108 /* ExpandableContent.swift in Sources */, B78B4A7C29F041F900A1BA58 /* ObservationDetailsView.swift in Sources */, 1F43997029B8D6FB00687906 /* ScheduleView.swift in Sources */, - B79DBA4E29D3162700A1F547 /* LoginQRCodeView.swift in Sources */, 1F60C58629951A5F00858581 /* ErrorText.swift in Sources */, 07BC54AC29CB2F3C00459267 /* StudyDetailsView.swift in Sources */, 1F43997929B8D70800687906 /* ScheduleList.swift in Sources */, EDEBA13529B0D30200533F79 /* DashboardView.swift in Sources */, B78B4A8429F04BEA00A1BA58 /* ExpandableContentWithLink.swift in Sources */, - B748DF4829D1C07F0026C348 /* SimpleQuestionObservationViewModel.swift in Sources */, + B748DF4829D1C07F0026C348 /* QuestionViewModel.swift in Sources */, 1F8847EA2992C0060023EF10 /* MoreContainer.swift in Sources */, 1F89378B2BFF1EBF0083D20E /* ObservationErrorsView.swift in Sources */, B74DDB6A29E6AEDE006FEA74 /* NotificationItem.swift in Sources */, @@ -1360,6 +1305,8 @@ 1F8847EC2992C3240023EF10 /* MoreFrame.swift in Sources */, 1F8847E82992BEE30023EF10 /* BasicText.swift in Sources */, 1F750CAF2A6FA8AE006E455E /* StudyClosedView.swift in Sources */, + B746706D2DF03FE800676D79 /* ScanQRCodeView.swift in Sources */, + 1F43DE232EC6137F00B6F07B /* GarminConnectViewModel.swift in Sources */, B78B4A7E29F0420700A1BA58 /* ObservationDetailsViewModel.swift in Sources */, 07EF6C4129B5E0C700CEF37D /* PermissionManager.swift in Sources */, 1F8EA2CD2A0BDF9A00F32602 /* LimeSurveyViewModel.swift in Sources */, @@ -1372,11 +1319,11 @@ EDB16E2B29F933EF00701C27 /* TaskCompletionBarViewModel.swift in Sources */, 07FD293C29D57F0300853108 /* ModuleListItem.swift in Sources */, 1F9DB1A0298CF44000DBB7DB /* MoreColor.swift in Sources */, + B70888492DF2D4290048A4AC /* QRCodeScanDelegate.swift in Sources */, B79DBA4729D3141400A1F547 /* NavigationLinkButton.swift in Sources */, 07BC54B229CB47C400459267 /* DetailsTitle.swift in Sources */, 1F8847D629915ADE0023EF10 /* MoreAnimation.swift in Sources */, B79BECC129F1811000966AD1 /* ContactInfo.swift in Sources */, - 1F6D338129C1D2C70036532B /* ViewModifierExtension.swift in Sources */, EDACF62929D2FB200032327B /* PolarVerityHeartRateObservation.swift in Sources */, 1F8847F129938C6B0023EF10 /* ConsentViewModel.swift in Sources */, EDBB2B4829B8CFE400CA973E /* SettingsViewModel.swift in Sources */, @@ -1396,208 +1343,26 @@ }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - 1F750CAA2A6F9C9B006E455E /* StudyStates.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F750CA92A6F9C9B006E455E /* de */, - 1F750CAB2A6F9C9E006E455E /* en */, - ); - name = StudyStates.strings; - sourceTree = ""; - }; - 1F8937832BFE31400083D20E /* Errors.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F8937842BFE31400083D20E /* en */, - 1F8937862BFE32890083D20E /* de */, - ); - name = Errors.strings; - sourceTree = ""; - }; - 1F9C74322A30C72B003AE946 /* LoginView.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74312A30C72B003AE946 /* en */, - 1F9C74632A30C7A2003AE946 /* de */, - ); - name = LoginView.strings; - sourceTree = ""; - }; - 1F9C74352A30C733003AE946 /* Default.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74342A30C733003AE946 /* en */, - 1F9C74642A30C7A2003AE946 /* de */, - ); - name = Default.strings; - sourceTree = ""; - }; - 1F9C74382A30C752003AE946 /* ConsentView.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74372A30C752003AE946 /* en */, - 1F9C74652A30C7A2003AE946 /* de */, - ); - name = ConsentView.strings; - sourceTree = ""; - }; - 1F9C743B2A30C755003AE946 /* DashboardView.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C743A2A30C755003AE946 /* en */, - 1F9C74662A30C7A2003AE946 /* de */, - ); - name = DashboardView.strings; - sourceTree = ""; - }; - 1F9C743E2A30C758003AE946 /* ScheduleListView.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C743D2A30C758003AE946 /* en */, - 1F9C74672A30C7A2003AE946 /* de */, - ); - name = ScheduleListView.strings; - sourceTree = ""; - }; - 1F9C74412A30C75A003AE946 /* SettingsView.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74402A30C75A003AE946 /* en */, - 1F9C74682A30C7A2003AE946 /* de */, - ); - name = SettingsView.strings; - sourceTree = ""; - }; - 1F9C74442A30C75D003AE946 /* Navigation.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74432A30C75D003AE946 /* en */, - 1F9C74692A30C7A2003AE946 /* de */, - ); - name = Navigation.strings; - sourceTree = ""; - }; - 1F9C74472A30C75F003AE946 /* StudyDetailsView.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74462A30C75F003AE946 /* en */, - 1F9C746A2A30C7A2003AE946 /* de */, - ); - name = StudyDetailsView.strings; - sourceTree = ""; - }; - 1F9C744A2A30C763003AE946 /* TaskDetail.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74492A30C763003AE946 /* en */, - 1F9C746B2A30C7A2003AE946 /* de */, - ); - name = TaskDetail.strings; - sourceTree = ""; - }; - 1F9C744D2A30C766003AE946 /* ExpandableText.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C744C2A30C766003AE946 /* en */, - 1F9C746C2A30C7A3003AE946 /* de */, - ); - name = ExpandableText.strings; - sourceTree = ""; - }; - 1F9C74502A30C768003AE946 /* NotificationView.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C744F2A30C768003AE946 /* en */, - 1F9C746D2A30C7A3003AE946 /* de */, - ); - name = NotificationView.strings; - sourceTree = ""; - }; - 1F9C74532A30C76B003AE946 /* DashboardFilter.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74522A30C76B003AE946 /* en */, - 1F9C746E2A30C7A3003AE946 /* de */, - ); - name = DashboardFilter.strings; - sourceTree = ""; - }; - 1F9C74562A30C76D003AE946 /* ObservationDetails.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74552A30C76D003AE946 /* en */, - 1F9C746F2A30C7A3003AE946 /* de */, - ); - name = ObservationDetails.strings; - sourceTree = ""; - }; - 1F9C74592A30C770003AE946 /* Info.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74582A30C770003AE946 /* en */, - 1F9C74702A30C7A3003AE946 /* de */, - ); - name = Info.strings; - sourceTree = ""; - }; - 1F9C745C2A30C773003AE946 /* BluetoothConnection.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C745B2A30C773003AE946 /* en */, - 1F9C74712A30C7A3003AE946 /* de */, - ); - name = BluetoothConnection.strings; - sourceTree = ""; - }; - 1F9C745F2A30C775003AE946 /* SimpleQuestionObservation.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C745E2A30C775003AE946 /* en */, - 1F9C74722A30C7A3003AE946 /* de */, - ); - name = SimpleQuestionObservation.strings; - sourceTree = ""; - }; - 1F9C74622A30C779003AE946 /* LimeSurvey.strings */ = { - isa = PBXVariantGroup; - children = ( - 1F9C74612A30C779003AE946 /* en */, - 1F9C74732A30C7A3003AE946 /* de */, - ); - name = LimeSurvey.strings; - sourceTree = ""; - }; - 1FD531B72B693C3400C2D9FB /* AlertDialog.strings */ = { - isa = PBXVariantGroup; - children = ( - 1FD531B62B693C3400C2D9FB /* de */, - 1FD531B82B693C3900C2D9FB /* en */, - ); - name = AlertDialog.strings; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ 1FC957542C072B7900EB92D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CODE_SIGN_ENTITLEMENTS = "More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = "BlendedCare-Notification-Service-Extension/BlendedCare-Notification-Service-Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 1.0.0; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = VX2DSGURUH; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "More-Notification-Service-Extension/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "More-Notification-Service-Extension"; + INFOPLIST_FILE = "BlendedCare-Notification-Service-Extension/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "BlendedCare-Notification-Service-Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Redlink GmbH. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1607,6 +1372,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "ac.at.lbg.dhp.more.More-Notification-Service-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1624,17 +1390,19 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CODE_SIGN_ENTITLEMENTS = "More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = "BlendedCare-Notification-Service-Extension/BlendedCare-Notification-Service-Extension.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 1.0.0; DEVELOPMENT_TEAM = VX2DSGURUH; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "More-Notification-Service-Extension/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "More-Notification-Service-Extension"; + INFOPLIST_FILE = "BlendedCare-Notification-Service-Extension/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "BlendedCare-Notification-Service-Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Redlink GmbH. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1644,6 +1412,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "ac.at.lbg.dhp.more.More-Notification-Service-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1689,6 +1458,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1708,12 +1478,15 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRIP_STYLE = debugging; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -1752,6 +1525,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -1765,11 +1539,13 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; @@ -1794,11 +1570,12 @@ INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = More; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.14; + MARKETING_VERSION = 0.0.11; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1807,10 +1584,12 @@ ); PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_STRICT_MEMORY_SAFETY = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1822,6 +1601,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = VX2DSGURUH; @@ -1834,11 +1614,12 @@ INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = More; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.14; + MARKETING_VERSION = 0.0.11; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1847,10 +1628,12 @@ ); PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_STRICT_MEMORY_SAFETY = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1889,12 +1672,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 1F9283BB2BC510D100D459A7 /* XCRemoteSwiftPackageReference "realm-swift" */ = { + 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/realm/realm-swift.git"; + repositoryURL = "https://github.com/rickclephas/KMP-NativeCoroutines.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 10.49.1; + branch = master; + kind = branch; }; }; 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { @@ -1902,7 +1685,7 @@ repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 10.23.1; + minimumVersion = 12.2.0; }; }; EDACF62529D2F31D0032327B /* XCRemoteSwiftPackageReference "polar-ble-sdk" */ = { @@ -1910,16 +1693,31 @@ repositoryURL = "https://github.com/polarofficial/polar-ble-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.5.0; + minimumVersion = 6.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1F709AF22C0D8FB700FC6F5A /* RealmSwift */ = { + 1F1E45D32E7848D800C82016 /* KMPNativeCoroutinesAsync */ = { + isa = XCSwiftPackageProductDependency; + package = 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */; + productName = KMPNativeCoroutinesAsync; + }; + 1F1E45D52E7848D800C82016 /* KMPNativeCoroutinesCombine */ = { + isa = XCSwiftPackageProductDependency; + package = 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */; + productName = KMPNativeCoroutinesCombine; + }; + 1F1E45D72E7848D800C82016 /* KMPNativeCoroutinesCore */ = { + isa = XCSwiftPackageProductDependency; + package = 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */; + productName = KMPNativeCoroutinesCore; + }; + 1F1E45D92E7848D800C82016 /* KMPNativeCoroutinesRxSwift */ = { isa = XCSwiftPackageProductDependency; - package = 1F9283BB2BC510D100D459A7 /* XCRemoteSwiftPackageReference "realm-swift" */; - productName = RealmSwift; + package = 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */; + productName = KMPNativeCoroutinesRxSwift; }; 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7b609dffa..9f4ea9909 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "035e8c2986e70eb80117b23f041bf88071e4c3e2137683d75a051eed044bef2e", + "originHash" : "430e2595b142619f759799345883dfdc8c641d1198a191acc715dc9ff38e23db", "pins" : [ { "identity" : "abseil-cpp-binary", "kind" : "remoteSourceControl", "location" : "https://github.com/google/abseil-cpp-binary.git", "state" : { - "revision" : "748c7837511d0e6a507737353af268484e1745e2", - "version" : "1.2024011601.1" + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "076b241a625e25eac22f8849be256dfb960fcdfe", - "version" : "10.19.1" + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" } }, { @@ -24,8 +24,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "8bcaf973b1d84e119b7c7c119abad72ed460979f", - "version" : "10.27.0" + "revision" : "e2a3c3fbcaa1df3fd549b5b3f1175bc8a11912fd", + "version" : "12.2.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", + "version" : "2.3.0" } }, { @@ -33,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "70df02431e216bed98dd461e0c4665889245ba70", - "version" : "10.27.0" + "revision" : "1c7edd5cc6e4ad22d2b81166bb9b208b9e63058a", + "version" : "12.2.0" } }, { @@ -42,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", - "version" : "9.4.0" + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" } }, { @@ -51,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", - "version" : "7.13.3" + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" } }, { @@ -60,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/grpc-binary.git", "state" : { - "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", - "version" : "1.62.2" + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" } }, { @@ -78,8 +87,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/interop-ios-for-google-sdks.git", "state" : { - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", - "version" : "100.0.0" + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "kmp-nativecoroutines", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rickclephas/KMP-NativeCoroutines.git", + "state" : { + "branch" : "master", + "revision" : "e4b66be72f3e904b8fa096ac255486c81bacce5f" } }, { @@ -105,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/polarofficial/polar-ble-sdk.git", "state" : { - "revision" : "784f7ed2392bfa2f5a295e7b26a153ce6120d97f", - "version" : "5.5.0" + "revision" : "299639de113d0946c4a84f4c15e67bbca2173bad", + "version" : "6.7.0" } }, { @@ -118,24 +136,6 @@ "version" : "2.4.0" } }, - { - "identity" : "realm-core", - "kind" : "remoteSourceControl", - "location" : "https://github.com/realm/realm-core.git", - "state" : { - "revision" : "9cf7ef4ad8e2f4c7a519c9a395ca3d253bb87aa8", - "version" : "14.6.2" - } - }, - { - "identity" : "realm-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/realm/realm-swift.git", - "state" : { - "revision" : "6e0772315809ff0a11cd265126350039a6aac59d", - "version" : "10.50.1" - } - }, { "identity" : "rxswift", "kind" : "remoteSourceControl", @@ -153,6 +153,15 @@ "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", "version" : "1.26.0" } + }, + { + "identity" : "zip", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/Zip.git", + "state" : { + "revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76", + "version" : "2.1.2" + } } ], "version" : 3 diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme index 9fa15ce85..b9dbb68f4 100644 --- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -35,8 +35,7 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" language = "de" launchStyle = "0" - useCustomWorkingDirectory = "YES" - customWorkingDirectory = "/Users/jancortiel/Documents/More/LBI/more-multiplatform-app" + useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" @@ -53,12 +52,12 @@ diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index db72fd24a..426663523 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -14,29 +14,36 @@ // import BackgroundTasks -import Foundation -import shared -import UIKit import FirebaseCore -import FirebaseMessaging import FirebaseCrashlyticsSwift +import FirebaseMessaging +import Foundation +import UIKit +import shared class AppDelegate: NSObject, UIApplicationDelegate { - static let appGroup = "group.ac.at.lbg.dhp.more.group" + static let bundleId = Bundle.main.bundleIdentifier ?? "ac.at.lbg.dhp.more.group" + static let appGroup = "group." + bundleId static let appGroupUserDefaults = UserDefaults(suiteName: appGroup) - static let navigationScreenHandler = NavigationModalState() + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + static let repositories = MainRepositoryImpl(appDatabase: database) + static let navigationScreenHandler = NavigationModalState(repos: repositories) static let polarConnector = PolarConnector() static let dataUploadManager = DataUploadManager() static let shared: Shared = { - let dataManager = iOSObservationDataManager() + let dataManager = iOSObservationDataManager(repository: repositories, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) + let userDefaults = UserDefaultsRepository() return Shared( localNotificationListener: LocalPushNotifications(), - sharedStorageRepository: UserDefaultsRepository(), + repositories: repositories, + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: polarConnector, - observationFactory: IOSObservationFactory(dataManager: dataManager), - dataRecorder: IOSDataRecorder() + observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager, userDefaults: userDefaults), + dataRecorder: IOSDataRecorder(), + reminderNotificationSchedulingLimit: 30, + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() ) }() @@ -44,28 +51,33 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { #if DEBUG - NapierProxyKt.napierDebugBuild(antilog: nil) + NapierProxyKt.napierDebugBuild(antilog: nil) #endif + EventCollection.shared.addObserver(observer: EventConsumer()) + FirebaseApp.configure() FirebaseConfiguration.shared.setLoggerLevel(.debug) fcmService.register() AppDelegate.registerForNotifications() DataUploadBackgroundTask.setupBackgroundTasks() + DailyBackgroundTask.setupBackgroundTasks() + ObservationReminderBackgroundTask.setupBackgroundTasks() + + let routes = Set(NavigationScreen.allCases.map { $0.values.navigationLink.route }) - AppDelegate.shared.deeplinkManager.addAvailableDeepLinks(deepLinks: Set(NavigationScreen.allCases.map { $0.values.navigationLink })) + AppDelegate.shared.deeplinkManager.addAvailableDeepLinks(deepLinks: routes) + AppDelegate.shared.deeplinkManager.setProtocol(protocolReplacement: Shared.companion.PROTOCOL.localized()) + AppDelegate.shared.deeplinkManager.setHost(hostReplacement: Shared.companion.HOST.localized()) return true } - func applicationWillTerminate(_ application: UIApplication) { - } - - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { print("Notification Received: \(userInfo)") - AppDelegate.shared.notificationManager.handleNotificationDataAsync(shared: AppDelegate.shared, data: userInfo.notNilStringDictionary()) - + AppDelegate.shared.notificationManager.handleNotificationDataAsync(data: userInfo.notNilStringDictionary()) + completionHandler(.newData) } @@ -80,16 +92,20 @@ class AppDelegate: NSObject, UIApplicationDelegate { func cancelBackgroundTasks() { BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: DataUploadBackgroundTask.taskID) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: DailyBackgroundTask.taskID) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: ObservationReminderBackgroundTask.taskID) } func scheduleTasks() { DataUploadBackgroundTask.schedule() + DailyBackgroundTask.schedule() + ObservationReminderBackgroundTask.schedule() } static func registerForNotifications() { DispatchQueue.main.async { if !UIApplication.shared.isRegisteredForRemoteNotifications { - UIApplication.shared.registerForRemoteNotifications() + UIApplication.shared.registerForRemoteNotifications() } } } @@ -107,3 +123,10 @@ extension AppDelegate: MessagingDelegate { userInfo: tokenDict) } } + +class EventConsumer: EventObserver { + func onEvent(event: LogEvent, message: String?) { + // Handle events, e.g., send to Analytics + print("Received Event: \(event.key) - \(message ?? "")") + } +} diff --git a/iosApp/iosApp/AppState.swift b/iosApp/iosApp/AppState.swift index 77e677747..2d9f93adb 100644 --- a/iosApp/iosApp/AppState.swift +++ b/iosApp/iosApp/AppState.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,8 +18,8 @@ import SwiftUI class AppState: ObservableObject { static let shared = AppState() - + @Published var scenePhase: ScenePhase = .active - + private init() {} } diff --git a/iosApp/iosApp/BackgroundTasks/BackgroundTaskHandler.swift b/iosApp/iosApp/BackgroundTasks/BackgroundTaskHandler.swift index 5209740c7..f37ec10d7 100644 --- a/iosApp/iosApp/BackgroundTasks/BackgroundTaskHandler.swift +++ b/iosApp/iosApp/BackgroundTasks/BackgroundTaskHandler.swift @@ -7,14 +7,14 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import Foundation import BackgroundTasks +import Foundation protocol BackgroundTaskHandler { func handleProcessingTask(task: BGProcessingTask) diff --git a/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift new file mode 100644 index 000000000..e6a3e7638 --- /dev/null +++ b/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift @@ -0,0 +1,62 @@ +// +// DailyBackgroundTask.swift +// +// Created to run a daily background refresh with a placeholder body. +// + +import Foundation +import BackgroundTasks +import shared + +enum DailyBackgroundTask { + // IMPORTANT: Add this identifier to Info.plist under BGTaskSchedulerPermittedIdentifiers + // and ensure it matches your app's bundle identifier conventions if needed. + static let taskID = AppDelegate.bundleId + ".dailyRefresh" + + static func setupBackgroundTasks() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + handle(task: refreshTask) + } + } + + static func schedule() { + let request = BGAppRefreshTaskRequest(identifier: taskID) + request.earliestBeginDate = Date(timeIntervalSinceNow: 24 * 60 * 60) + do { + try BGTaskScheduler.shared.submit(request) + Napier.d("Scheduled DailyBackgroundTask") + } catch { + Napier.e("Failed to schedule DailyBackgroundTask: \(error)") + } + } + + private static func handle(task: BGAppRefreshTask) { + schedule() + + let operationQueue = OperationQueue() + operationQueue.maxConcurrentOperationCount = 1 + + var finished = false + task.expirationHandler = { + if !finished { + Napier.w("DailyBackgroundTask expired before completion") + task.setTaskCompleted(success: false) + } + } + + AppDelegate.shared.updateSchedules { error in + finished = true + if let error { + Napier.e("Updating Schedule Tasks failed: \(error)") + task.setTaskCompleted(success: false) + } else { + Napier.i("Updating scheduling tasks was successful!") + task.setTaskCompleted(success: true) + } + } + } +} diff --git a/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift index 49543ae0f..8080f9145 100644 --- a/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift +++ b/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift @@ -1,55 +1,67 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + import BackgroundTasks class DataUploadBackgroundTask { - static let taskID = "io.redlink.more.app.multiplatform.data-upload" + static let taskID = AppDelegate.bundleId + ".data-upload" + static let defaultInterval: TimeInterval = 15 * 60 static func schedule(earliestBeginDate: Date? = nil) { let request = BGProcessingTaskRequest(identifier: taskID) if let earliestDate = earliestBeginDate { request.earliestBeginDate = earliestDate } else { - request.earliestBeginDate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) + request.earliestBeginDate = Date(timeIntervalSinceNow: defaultInterval) } request.requiresNetworkConnectivity = true request.requiresExternalPower = false do { try BGTaskScheduler.shared.submit(request) - print("DataUploadBackgroundTask::schedule - Background Task scheduled") + Napier.i("DataUploadBackgroundTask::schedule - Background Task scheduled for \(request.earliestBeginDate ?? Date())") } catch { - print("DataUploadBackgroundTask::schedule - Error requesting a background task: \(error.localizedDescription)") + Napier.e("DataUploadBackgroundTask::schedule - Error requesting a background task: \(error.localizedDescription)") } } private let dataCollector = ObservationDataCollector() private func collectRecordedData(completion: @escaping () -> Void) { - print("DataUploadBackgroundTask::collectRecordedData - \(Date()): Collecting recorded data...") + Napier.i("DataUploadBackgroundTask::collectRecordedData - \(Date()): Collecting recorded data...") dataCollector.collectData { dataCollected in if dataCollected { - print("DataUploadBackgroundTask::collectRecordedData - \(Date()): Data collected") + Napier.i("DataUploadBackgroundTask::collectRecordedData - \(Date()): Data collected") } else { - print("DataUploadBackgroundTask::collectRecordedData - \(Date()): No data collected") + Napier.i("DataUploadBackgroundTask::collectRecordedData - \(Date()): No data collected") } completion() } } private func close() { - print("DataUploadBackgroundTask::close - Cleaning up resources") + Napier.i("DataUploadBackgroundTask::close - Cleaning up resources") } } -extension DataUploadBackgroundTask: BackgroundTaskHandler { +extension DataUploadBackgroundTask: @preconcurrency BackgroundTaskHandler { @MainActor func handleProcessingTask(task: BGProcessingTask) { - print("DataUploadBackgroundTask::handleProcessingTask - Starting Background Processing Task") + Napier.i("Starting Background Processing Task") task.expirationHandler = { - print("\(Date()): Task will soon expire! Cleaning up...") + Napier.w("\(Date()): Task will soon expire! Cleaning up...") self.close() DataUploadBackgroundTask.schedule() - print("\(Date()): Cleaned up!") + Napier.i("\(Date()): Cleaned up!") DispatchQueue.main.async { task.setTaskCompleted(success: false) } @@ -71,7 +83,7 @@ extension DataUploadBackgroundTask: BackgroundTaskHandler { } func handleRefreshTask(task: BGAppRefreshTask) { - print("DataUploadBackgroundTask::handleRefreshTask - Handling Refresh Task") + Napier.i("Handling Refresh Task") task.setTaskCompleted(success: true) } @@ -86,3 +98,4 @@ extension DataUploadBackgroundTask: BackgroundTaskHandler { } } } + diff --git a/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift new file mode 100644 index 000000000..601dcc05a --- /dev/null +++ b/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift @@ -0,0 +1,62 @@ +import Foundation +import BackgroundTasks +import shared + +// Schedules periodic refreshes to update observation reminders by +// calling AppDelegate.shared.observationService.scheduleObservationReminder(). +// NOTE: Add the identifier below to Info.plist under BGTaskSchedulerPermittedIdentifiers. +// iOS ultimately decides exact run frequency; we request the minimum practical interval. +enum ObservationReminderBackgroundTask { + // IMPORTANT: Add this identifier to Info.plist -> BGTaskSchedulerPermittedIdentifiers + static let taskID = AppDelegate.bundleId + ".observation-reminder-refresh" + + // Smallest practical interval you can request for BGAppRefresh (system may delay) + static let minimumInterval: TimeInterval = 15 * 60 + + static func setupBackgroundTasks() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + handle(task: refreshTask) + } + } + + static func schedule(earliestBeginDate: Date? = nil) { + let request = BGAppRefreshTaskRequest(identifier: taskID) + request.earliestBeginDate = earliestBeginDate ?? Date(timeIntervalSinceNow: minimumInterval) + do { + try BGTaskScheduler.shared.submit(request) + Napier.i("ObservationReminderBackgroundTask::schedule - scheduled for \(request.earliestBeginDate ?? Date())") + } catch { + Napier.e("ObservationReminderBackgroundTask::schedule - failed to schedule: \(error)") + } + } + + private static func handle(task: BGAppRefreshTask) { + // Always reschedule next run + schedule() + + var finished = false + task.expirationHandler = { + if !finished { + Napier.w("ObservationReminderBackgroundTask expired before completion") + task.setTaskCompleted(success: false) + } + } + + Task { @MainActor in + do { + try await AppDelegate.shared.observationService.scheduleObservationReminder() + finished = true + Napier.i("ObservationReminderBackgroundTask completed successfully") + task.setTaskCompleted(success: true) + } catch { + finished = true + Napier.e("ObservationReminderBackgroundTask failed: \(error)") + task.setTaskCompleted(success: false) + } + } + } +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 1bd4e4626..5f22d8814 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -11,44 +11,19 @@ import shared import SwiftUI struct ContentView: View { - @StateObject var viewModel: ContentViewModel + @ObservedObject var viewModel: ContentViewModel @StateObject private var navigationModalState = AppDelegate.navigationScreenHandler var body: some View { ZStack { - MoreMainBackgroundView() { + MoreMainBackgroundView { VStack { if viewModel.hasCredentials { - if !navigationModalState.mayChangeViewStructure() { - if navigationModalState.studyIsUpdating { - StudyUpdateView() - .padding(.horizontal, navigationModalState.horizontalContentPadding) - } else if navigationModalState.currentStudyState == StudyState.paused { - StudyPausedView() - .padding(.horizontal, navigationModalState.horizontalContentPadding) - } else if navigationModalState.currentStudyState == StudyState.closed { - StudyClosedView(viewModel: viewModel) - .padding(.horizontal, navigationModalState.horizontalContentPadding) - } - } else { - MainTabView() - .sheet(isPresented: $viewModel.showBleView) { - MoreMainBackgroundView(contentPadding: navigationModalState.horizontalContentPadding) { - BluetoothConnectionView(viewModel: viewModel.bluetoothViewModel, viewOpen: $viewModel.showBleView, showAsSeparateView: true) - } - } - } + CredentialsView(navigationModalState: navigationModalState, viewModel: viewModel) + } else if !viewModel.credentialsLoaded || (viewModel.hasCredentials && navigationModalState.currentStudyState == .none) { + StudyLoadingView() + .padding(.horizontal, navigationModalState.horizontalContentPadding) } else { - VStack { - if viewModel.loginViewScreenNr == 0 { - LoginView(model: viewModel.loginViewModel) - .onAppear { - navigationModalState.clearViews() - navigationModalState.tagState = 0 - } - } else { - ConsentView(viewModel: viewModel.consentViewModel) - } - } + RegistrationView(navigationModalState: navigationModalState) } } } @@ -62,6 +37,54 @@ struct ContentView: View { } } +struct CredentialsView: View { + @ObservedObject var navigationModalState: NavigationModalState + @ObservedObject var viewModel: ContentViewModel + var body: some View { + VStack { + if !navigationModalState.mayChangeViewStructure() { + VStack { + if navigationModalState.studyIsUpdating { + StudyUpdateView() + } else if navigationModalState.studyLoadingError { + StudyLoadingErrorView() + } else if navigationModalState.currentStudyState == StudyState.paused { + StudyPausedView() + } else if navigationModalState.currentStudyState == StudyState.closed { + StudyClosedView() + } + } + .padding(.horizontal, navigationModalState.horizontalContentPadding) + } else { + MainTabView() + .sheet(isPresented: $viewModel.showBleView) { + MoreMainBackgroundView(contentPadding: navigationModalState.horizontalContentPadding) { + BluetoothConnectionView(viewOpen: $viewModel.showBleView, showAsSeparateView: true) + } + } + } + } + } +} + +struct RegistrationView: View { + @ObservedObject var navigationModalState: NavigationModalState + @StateObject private var registration = RegistrationObservable(service: RegistrationService(shared: AppDelegate.shared)) + var body: some View { + VStack { + if registration.study != nil { + ConsentView(registration: registration) + } else { + LoginView(registration: registration) + .onAppear { + navigationModalState.clearViews() + navigationModalState.tagState = 0 + } + } + } + } +} + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView(viewModel: ContentViewModel()) diff --git a/iosApp/iosApp/ContentViewModel.swift b/iosApp/iosApp/ContentViewModel.swift index 8e620f04e..f7e85baae 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -7,175 +7,97 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import BackgroundTasks +import Combine import Foundation +import KMPNativeCoroutinesCombine import shared -import BackgroundTasks class ContentViewModel: ObservableObject { - private let registrationService = RegistrationService(shared: AppDelegate.shared) - @Published var hasCredentials = false - @Published var loginViewScreenNr = 0 + @Published var credentialsLoaded = false @Published var isLeaveStudyOpen: Bool = false @Published var isLeaveStudyConfirmOpen: Bool = false @Published var showBleView = false - + @Published var mainTabViewSelection = 0 - - @Published var finishText: String? = nil + @Published var alertDialogModel: AlertDialogModel? = nil @Published var unreadNotificationCount: Int = 0 - lazy var loginViewModel: LoginViewModel = { - let viewModel = LoginViewModel(registrationService: registrationService) - viewModel.delegate = self - return viewModel - }() - lazy var consentViewModel: ConsentViewModel = { - let viewModel = ConsentViewModel(registrationService: registrationService) - viewModel.delegate = self - return viewModel - }() - - lazy var taskDetailsVM: TaskDetailsViewModel = { - TaskDetailsViewModel(dataRecorder: AppDelegate.shared.dataRecorder) - }() - - lazy var simpleQuestionVM = SimpleQuestionObservationViewModel() - lazy var limeSurveyVM = LimeSurveyViewModel() - - var dashboardViewModel: DashboardViewModel = DashboardViewModel(scheduleViewModel: ScheduleViewModel(scheduleListType: .manuals)) + let manualSchedule = ScheduleViewModel(scheduleListType: .manuals) lazy var runningViewModel = ScheduleViewModel(scheduleListType: .running) lazy var completedViewModel = ScheduleViewModel(scheduleListType: .completed) - lazy var settingsViewModel: SettingsViewModel = { - let viewModel = SettingsViewModel() - viewModel.delegate = self - return viewModel - }() - - var notificationViewModel: NotificationViewModel - - var notificationFilterViewModel: NotificationFilterViewModel - + lazy var settingsViewModel: SettingsViewModel = SettingsViewModel() + + let coreNotificationFilterViewModel = CoreNotificationFilterViewModel() + lazy var infoViewModel = InfoViewModel() - lazy var bluetoothViewModel: BluetoothConnectionViewModel = BluetoothConnectionViewModel() - + private var cancellables = Set() + init() { - let coreNotificationFilterViewModel = CoreNotificationFilterViewModel() - notificationViewModel = NotificationViewModel(filterViewModel: coreNotificationFilterViewModel) - notificationFilterViewModel = NotificationFilterViewModel(coreViewModel: coreNotificationFilterViewModel) - hasCredentials = AppDelegate.shared.credentialRepository.hasCredentials() - - ViewManager.shared.studyIsUpdatingAsClosure { kBool in - AppDelegate.navigationScreenHandler.studyIsUpdating(kBool.boolValue) - } - - ViewManager.shared.showBluetoothViewAsClosure { [weak self] kBool in - if kBool.boolValue { - self?.showBleView = kBool.boolValue - } + createPublisher(for: AppDelegate.shared.credentialRepository.credentials) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] credentials in + self?.hasCredentials = credentials != nil } - - AppDelegate.shared.onStudyStateChange { [weak self] studyState in - self?.finishText = AppDelegate.shared.finishText - AppDelegate.navigationScreenHandler.setStudyState(studyState) - } - - AlertController.shared.onNewAlertDialogModel { [weak self] alertDialogModel in - self?.alertDialogModel = alertDialogModel + .store(in: &cancellables) + + createPublisher(for: AppDelegate.shared.credentialRepository.credentialsLoaded) + .map { + $0.boolValue } - - AppDelegate.shared.unreadNotificationCountAsClosure { [weak self] kInt in - self?.unreadNotificationCount = kInt.intValue + .first(where: { $0 == true }) + .receive(on: DispatchQueue.main) + .sink { completion in + print("Credentials have loaded with completion: \(completion)") + } receiveValue: { [weak self] loaded in + self?.credentialsLoaded = loaded } - } - - func showLoginView() { - DispatchQueue.main.async { - self.registrationService.reset() - self.loginViewScreenNr = 0 - self.hasCredentials = false + .store(in: &cancellables) + + createPublisher(for: ViewManager.shared.studyIsUpdating) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { updating in + AppDelegate.navigationScreenHandler.studyIsUpdating(updating.boolValue) } - } - - func showConsentView() { - DispatchQueue.main.async { - self.loginViewScreenNr = 1 - self.consentViewModel.onAppear() + .store(in: &cancellables) + + createPublisher(for: ViewManager.shared.bleViewActive) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] show in + self?.showBleView = show.boolValue } - } - - func getTaskDetailsVM(navigationState: NavigationState) -> TaskDetailsViewModel { - if let scheduleId = navigationState.scheduleId { - taskDetailsVM.setSchedule(scheduleId: scheduleId) + .store(in: &cancellables) + + createPublisher(for: AlertController.shared.alertDialogModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] alertDialogModel in + self?.alertDialogModel = alertDialogModel } - return taskDetailsVM - } - - func getSimpleQuestionObservationVM(navigationState: NavigationState) -> SimpleQuestionObservationViewModel { - simpleQuestionVM.setScheduleId(navigationState: navigationState) - return simpleQuestionVM - } - - func getLimeSurveyVM(navigationModalState: NavigationModalState) -> LimeSurveyViewModel { - limeSurveyVM.setNavigationModalState(navigationModalState: navigationModalState) - return limeSurveyVM + .store(in: &cancellables) + + createPublisher(for: AppDelegate.shared.notificationManager.unreadUserCount) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in}) { [weak self] notificationCount in + self?.unreadNotificationCount = notificationCount.intValue + } + .store(in: &cancellables).self } - private func reinitAllViewModels() { - dashboardViewModel = DashboardViewModel(scheduleViewModel: ScheduleViewModel(scheduleListType: .manuals)) runningViewModel = ScheduleViewModel(scheduleListType: .running) completedViewModel = ScheduleViewModel(scheduleListType: .completed) - - let coreNotificationFilterViewModel = CoreNotificationFilterViewModel() - notificationViewModel = NotificationViewModel(filterViewModel: coreNotificationFilterViewModel) - notificationFilterViewModel = NotificationFilterViewModel(coreViewModel: coreNotificationFilterViewModel) - - settingsViewModel = SettingsViewModel() - settingsViewModel.delegate = self - - bluetoothViewModel = BluetoothConnectionViewModel() - infoViewModel = InfoViewModel() - } -} -extension ContentViewModel: LoginViewModelListener { - func tokenValid(study: Study) { - DispatchQueue.main.async { - self.consentViewModel.consentInfo = study.consentInfo - self.consentViewModel.buildConsentModel() - self.showConsentView() - } - } -} - -extension ContentViewModel: ConsentViewModelListener { - func decline() { - showLoginView() - } - - func credentialsStored() { - reinitAllViewModels() - DispatchQueue.main.async { [weak self] in - self?.hasCredentials = true - } - AppDelegate.shared.doNewLogin() - } + settingsViewModel = SettingsViewModel() - func credentialsDeleted() { - DispatchQueue.main.async { [weak self] in - if let self { - self.hasCredentials = false - } - } - showLoginView() + infoViewModel = InfoViewModel() } } diff --git a/iosApp/iosApp/Extensions/ArrayExtension.swift b/iosApp/iosApp/Extensions/ArrayExtension.swift index 5350521eb..68cb24a85 100644 --- a/iosApp/iosApp/Extensions/ArrayExtension.swift +++ b/iosApp/iosApp/Extensions/ArrayExtension.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -23,16 +23,16 @@ extension Array where Element: Collection { extension Array where Element: Equatable { mutating func remove(_ elementToRemove: Element) { - if let i = self.firstIndex(of: elementToRemove) { - self.remove(at: i) + if let i = firstIndex(of: elementToRemove) { + remove(at: i) } } mutating func pop(_ elementToPop: Element) -> Int { - if let index = self.lastIndex(of: elementToPop) { - self.remove(at: index) + if let index = lastIndex(of: elementToPop) { + remove(at: index) return index } return -1 } -} \ No newline at end of file +} diff --git a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift index 94ac7955c..5e562e5ff 100644 --- a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift +++ b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift @@ -7,18 +7,18 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import Foundation -import shared import PolarBleSdk +import shared -extension BluetoothDevice { - static func fromPolarDevice(polarInfo: PolarDeviceInfo) -> BluetoothDevice { - BluetoothDevice.Companion().create(deviceId: polarInfo.deviceId, deviceName: polarInfo.name, address: polarInfo.address.uuidString) +extension BluetoothDeviceEntity { + static func fromPolarDevice(polarInfo: PolarDeviceInfo) -> BluetoothDeviceEntity { + BluetoothDeviceEntity.companion.create(deviceId: polarInfo.deviceId, deviceName: polarInfo.name, address: polarInfo.address.uuidString) } } diff --git a/iosApp/iosApp/Extensions/Bundle.swift b/iosApp/iosApp/Extensions/Bundle.swift index c231322ab..e0cda2146 100644 --- a/iosApp/iosApp/Extensions/Bundle.swift +++ b/iosApp/iosApp/Extensions/Bundle.swift @@ -7,23 +7,42 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import Foundation + extension Bundle { - public var appName: String { getInfo("CFBundleName") } - public var displayName: String { getInfo("CFBundleDisplayName") } - public var language: String { getInfo("CFBundleDevelopmentRegion") } - public var identifier: String { getInfo("CFBundleIdentifier") } - public var copyright: String { getInfo("NSHumanReadableCopyright").replacingOccurrences(of: "\\\\n", with: "\n") } - - public var appBuild: String { getInfo("CFBundleVersion") } - public var appVersionLong: String { getInfo("CFBundleShortVersionString") } - public var appVersionShort: String { getInfo("CFBundleShortVersion") } - - fileprivate func getInfo(_ str: String) -> String { infoDictionary?[str] as? String ?? "⚠️" } + public var appName: String { + getInfo("CFBundleName") + } + public var displayName: String { + getInfo("CFBundleDisplayName") + } + public var language: String { + getInfo("CFBundleDevelopmentRegion") + } + public var identifier: String { + getInfo("CFBundleIdentifier") + } + public var copyright: String { + getInfo("NSHumanReadableCopyright").replacingOccurrences(of: "\\\\n", with: "\n") + } + + public var appBuild: String { + getInfo("CFBundleVersion") + } + public var appVersionLong: String { + getInfo("CFBundleShortVersionString") + } + public var appVersionShort: String { + getInfo("CFBundleShortVersion") + } + + fileprivate func getInfo(_ str: String) -> String { + infoDictionary?[str] as? String ?? "⚠️" + } } diff --git a/iosApp/iosApp/Extensions/CMLogItemExtension.swift b/iosApp/iosApp/Extensions/CMLogItemExtension.swift index a27e164d9..568b39c1b 100644 --- a/iosApp/iosApp/Extensions/CMLogItemExtension.swift +++ b/iosApp/iosApp/Extensions/CMLogItemExtension.swift @@ -7,19 +7,19 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import Foundation import CoreMotion +import Foundation extension CMLogItem { static let bootTime = Date(timeIntervalSinceNow: -ProcessInfo.processInfo.systemUptime) func startTime() -> Date { - return CMLogItem.bootTime.addingTimeInterval(self.timestamp) + return CMLogItem.bootTime.addingTimeInterval(timestamp) } } diff --git a/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift b/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift index f49d53f17..dbcea549d 100644 --- a/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift +++ b/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift @@ -7,16 +7,16 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import Foundation import CoreMotion +import Foundation -extension CMSensorDataList: Sequence { +extension CMSensorDataList: @retroactive Sequence { public typealias Iterator = NSFastEnumerationIterator public func makeIterator() -> NSFastEnumerationIterator { return NSFastEnumerationIterator(self) diff --git a/iosApp/iosApp/Extensions/DateExtension.swift b/iosApp/iosApp/Extensions/DateExtension.swift index b978844a1..ad061ef70 100644 --- a/iosApp/iosApp/Extensions/DateExtension.swift +++ b/iosApp/iosApp/Extensions/DateExtension.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // diff --git a/iosApp/iosApp/Extensions/DictionaryExtension.swift b/iosApp/iosApp/Extensions/DictionaryExtension.swift index 58c7802aa..6531365f0 100644 --- a/iosApp/iosApp/Extensions/DictionaryExtension.swift +++ b/iosApp/iosApp/Extensions/DictionaryExtension.swift @@ -25,12 +25,26 @@ extension Dictionary { func filterValues(predicate: (V) -> Bool) -> [Key: Set] where Value == Set { return mapValues { $0.filter(predicate) } } + + func mapKeys(_ transform: (Key) throws -> NewKey) rethrows -> [NewKey: Value] where NewKey: Hashable { + try reduce(into: [NewKey: Value]()) { result, element in + let newKey = try transform(element.key) + result[newKey] = element.value + } + } + + func mapValues(_ transform: (Value) throws -> NewValue) rethrows -> [Key: NewValue] { + try reduce(into: [Key: NewValue]()) { result, element in + let newValue = try transform(element.value) + result[element.key] = newValue + } + } } extension Dictionary where Value == Set { func flattenValues() -> Set { var resultSet = Set() - for valueSet in self.values { + for valueSet in values { resultSet.formUnion(valueSet) } return resultSet diff --git a/iosApp/iosApp/Extensions/Int64Extension.swift b/iosApp/iosApp/Extensions/Int64Extension.swift index df7f597c5..6c070962c 100644 --- a/iosApp/iosApp/Extensions/Int64Extension.swift +++ b/iosApp/iosApp/Extensions/Int64Extension.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,21 +18,21 @@ import shared extension Int64 { func toDateString(dateFormat: String) -> String { - toDate().formattedString(dateFormat: dateFormat) + toDate().formattedString(dateFormat: dateFormat) } - + func toKotlinLong() -> KotlinLong { return KotlinLong(value: self) } - + func dateWithoutTime() -> String { toDateString(dateFormat: "dd.MM.yyyy") } - + func toDate() -> Date { Date(timeIntervalSince1970: TimeInterval(self)) } - + func startOfDate() -> Date { Calendar.current.startOfDay(for: toDate()) } diff --git a/iosApp/iosApp/Extensions/KotlinLongExtension.swift b/iosApp/iosApp/Extensions/KotlinLongExtension.swift index b717e230d..f05e97621 100644 --- a/iosApp/iosApp/Extensions/KotlinLongExtension.swift +++ b/iosApp/iosApp/Extensions/KotlinLongExtension.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,10 +17,7 @@ import Foundation import shared extension KotlinLong { - func toInt64() -> Int64 { - return self.int64Value + return int64Value } } - - diff --git a/iosApp/iosApp/Extensions/KotlinMutableSetExtension.swift b/iosApp/iosApp/Extensions/KotlinMutableSetExtension.swift deleted file mode 100644 index 21d88bdfe..000000000 --- a/iosApp/iosApp/Extensions/KotlinMutableSetExtension.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// KotlinMutableSetExtension.swift -// More -// -// Created by Jan Cortiel on 01.06.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import Foundation -import shared - - diff --git a/iosApp/iosApp/Extensions/ListViewExtension.swift b/iosApp/iosApp/Extensions/ListViewExtension.swift deleted file mode 100644 index 0bcf0c1f0..000000000 --- a/iosApp/iosApp/Extensions/ListViewExtension.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// ListView.swift -// iosApp -// -// Created by Jan Cortiel on 15.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - - -struct ClearListStyleModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 16, *) { - content.scrollContentBackground(.hidden) - } else { - content - } - } -} - -struct ListRowModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 16, *) { - content - .listRowSeparator(.hidden) - } else { - content - } - } -} - -extension View { - @ViewBuilder - func clearListBackground() -> some View { - self.modifier(ClearListStyleModifier()) - } - - @ViewBuilder - func hideListRowSeparator() -> some View { - self.modifier(ListRowModifier()) - } -} diff --git a/iosApp/iosApp/Extensions/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index 20824c170..939960a68 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -14,23 +14,18 @@ // import Foundation +import shared struct NavigationScreenValues { let screenName: String - let navigationLink: String - var parameters: [NavigationParameter] = [] + let navigationLink: NavigationRoute + var parameters: [NavigationRouteParameter] = [] var fullScreen: Bool = false } -enum NavigationParameter: String { - case observationId - case notificaitonId - case scheduleId -} - enum NavigationScreen: CaseIterable, Equatable, Identifiable { var id: Self { self } - + case dashboard case notifications case info @@ -50,67 +45,115 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { case withdrawStudyConfirm case limeSurvey case observationErrors + case garminConnect var values: NavigationScreenValues { switch self { case .dashboard: - return NavigationScreenValues(screenName: "Dashboard", navigationLink: "/dashboard") + return NavigationScreenValues(screenName: "Dashboard", navigationLink: .dashboard) case .notifications: - return NavigationScreenValues(screenName: "Notifications", navigationLink: "/notifications") + return NavigationScreenValues(screenName: "Notifications", navigationLink: .notifications) case .info: - return NavigationScreenValues(screenName: "Information", navigationLink: "/info") + return NavigationScreenValues(screenName: "Information", navigationLink: .info) case .settings: - return NavigationScreenValues(screenName: "Settings", navigationLink: "/settings") + return NavigationScreenValues(screenName: "Settings", navigationLink: .settings) case .bluetoothConnections: - return NavigationScreenValues(screenName: "Devices", navigationLink: "/devices") + return NavigationScreenValues(screenName: "Devices", navigationLink: .bluetoothConnection) case .taskDetails: - return NavigationScreenValues(screenName: "Task Details", navigationLink: "/task-details", parameters: [.observationId, .notificaitonId, .scheduleId]) + return NavigationScreenValues(screenName: "Task Details", navigationLink: .scheduleDetails, parameters: [.observationId, .notificationId, .scheduleId]) case .studyDetails: - return NavigationScreenValues(screenName: "Study Details", navigationLink: "/study-details") + return NavigationScreenValues(screenName: "Study Details", navigationLink: .studyDetails) case .scanQRCode: - return NavigationScreenValues(screenName: "Scan QR Code", navigationLink: "/scan-qr-code") + return NavigationScreenValues(screenName: "Scan QR Code", navigationLink: .qrCode) case .questionObservation: - return NavigationScreenValues(screenName: "Question Observation", navigationLink: "/question-observation", parameters: [.observationId, .notificaitonId, .scheduleId], fullScreen: true) + return NavigationScreenValues(screenName: "Question Observation", navigationLink: .question, parameters: [.observationId, .notificationId, .scheduleId], fullScreen: true) case .questionObservationThanks: - return NavigationScreenValues(screenName: "Question Thanks", navigationLink: "/question-thanks", fullScreen: true) + return NavigationScreenValues(screenName: "Question Thanks", navigationLink: .questionnaireResponse, fullScreen: true) case .dashboardFilter: - return NavigationScreenValues(screenName: "Dashboard Filter", navigationLink: "/dashboard-filter") + return NavigationScreenValues(screenName: "Dashboard Filter", navigationLink: .observationFilter) case .notificationFilter: - return NavigationScreenValues(screenName: "Notification Filter", navigationLink: "/notification-filter") + return NavigationScreenValues(screenName: "Notification Filter", navigationLink: .notificationFilter) case .pastObservations: - return NavigationScreenValues(screenName: "Past Observations", navigationLink: "/past-observations") + return NavigationScreenValues(screenName: "Past Observations", navigationLink: .completedSchedules) case .runningObservations: - return NavigationScreenValues(screenName: "Running Observations", navigationLink: "/running-observations") + return NavigationScreenValues(screenName: "Running Observations", navigationLink: .runningSchedules) case .observationDetails: - return NavigationScreenValues(screenName: "Observation Details", navigationLink: "/observation-details", parameters: [.observationId]) + return NavigationScreenValues(screenName: "Observation Details", navigationLink: .observationDetails, parameters: [.observationId]) case .withdrawStudy: - return NavigationScreenValues(screenName: "Leave Study", navigationLink: "/leave-study", fullScreen: true) + return NavigationScreenValues(screenName: "Leave Study", navigationLink: .leaveStudy, fullScreen: true) case .withdrawStudyConfirm: - return NavigationScreenValues(screenName: "Confirm to leave the study", navigationLink: "/confirm-leave-study", fullScreen: true) + return NavigationScreenValues(screenName: "Confirm to leave the study", navigationLink: .leaveStudyConfirm, fullScreen: true) case .limeSurvey: - return NavigationScreenValues(screenName: "LimeSurvey", navigationLink: "/lime-survey-observation", parameters: [.observationId, .notificaitonId, .scheduleId], fullScreen: true) + return NavigationScreenValues(screenName: "LimeSurvey", navigationLink: .limesurvey, parameters: [.observationId, .notificationId, .scheduleId], fullScreen: true) case .observationErrors: - return NavigationScreenValues(screenName: "Observation Errors", navigationLink: "/observation-errors") + return NavigationScreenValues(screenName: "Observation Errors", navigationLink: .observationErrors) + case .garminConnect: + return NavigationScreenValues(screenName: "Garmin Connect", navigationLink: .garminConnect, parameters: [], fullScreen: true) } } - static let PARAM_OBSERVATION_ID = "observationId" - static let PARAM_NOTIFICATION_ID = "notificationId" } extension NavigationScreen { - func localize(useTable table: String, withComment comment: String) -> String { - return values.screenName.localize(withComment: comment, useTable: table) + static func match(from url: URL) -> (screen: NavigationScreen, params: [NavigationRouteParameter: String])? { + let path = url.path + let normalizedURLPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + + // Find a matching screen by comparing normalized paths + guard + let screen = NavigationScreen.allCases.first(where: { screen in + let link = screen.values.navigationLink + let route = link.route + let normalizedLink = route.hasPrefix("/") ? String(route.dropFirst()) : route + return normalizedURLPath == normalizedLink + }) + else { + return nil + } + + // Parse query parameters and keep only the ones declared by the screen + var resultParams: [NavigationRouteParameter: String] = [:] + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + for item in components.queryItems ?? [] { + if let value = item.value, let param = NavigationRouteParameter.companion.fromKey(key: item.name), screen.values.parameters.contains(param) { + resultParams[param] = value + } + } + } + + return (screen, resultParams) + } + static func match(from path: String) -> (screen: NavigationScreen, params: [NavigationRouteParameter: String])? { + let normalizedURLPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + + // Find a matching screen by comparing normalized paths + guard + let screen = NavigationScreen.allCases.first(where: { screen in + let link = screen.values.navigationLink + let route = link.route + let normalizedLink = route.hasPrefix("/") ? String(route.dropFirst()) : route + return normalizedURLPath == normalizedLink + }) + else { + return nil + } + + return (screen, [:]) + } + + func localize() -> String { + let localizedKey = String.LocalizationValue(stringLiteral: values.screenName) + return String(localized: localizedKey) } - func generateURL(withParameters params: [NavigationParameter: String]) -> URL? { + func generateURL(withParameters params: [NavigationRouteParameter: String]) -> URL? { var components = URLComponents() - components.path = values.navigationLink + components.path = values.navigationLink.route var queryItems: [URLQueryItem] = [] for parameter in values.parameters { if let value = params[parameter] { - queryItems.append(URLQueryItem(name: parameter.rawValue, value: value)) + queryItems.append(URLQueryItem(name: parameter.key, value: value)) } } diff --git a/iosApp/iosApp/Extensions/ObservationExtension.swift b/iosApp/iosApp/Extensions/ObservationExtension.swift index c743ec411..132ade65b 100644 --- a/iosApp/iosApp/Extensions/ObservationExtension.swift +++ b/iosApp/iosApp/Extensions/ObservationExtension.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // diff --git a/iosApp/iosApp/Extensions/SetExtension.swift b/iosApp/iosApp/Extensions/SetExtension.swift index d3b0141bb..f197dde82 100644 --- a/iosApp/iosApp/Extensions/SetExtension.swift +++ b/iosApp/iosApp/Extensions/SetExtension.swift @@ -2,7 +2,7 @@ import Foundation import shared extension Set where Element == String { - func anyNameIn(items: Set) -> Bool { + func anyNameIn(items: Set) -> Bool { contains { name in items.contains { item in item.deviceName?.contains(name) ?? false @@ -11,8 +11,8 @@ extension Set where Element == String { } } -extension Set where Element == BluetoothDevice { - func deviceWithNameIn(nameSet: Set) -> [BluetoothDevice] { +extension Set where Element == BluetoothDeviceEntity { + func deviceWithNameIn(nameSet: Set) -> [BluetoothDeviceEntity] { filter { device in nameSet.contains { device.deviceName?.contains($0) ?? false } } diff --git a/iosApp/iosApp/Extensions/StringExtension.swift b/iosApp/iosApp/Extensions/StringExtension.swift index 4ab7f702c..6972fe3f1 100644 --- a/iosApp/iosApp/Extensions/StringExtension.swift +++ b/iosApp/iosApp/Extensions/StringExtension.swift @@ -18,18 +18,6 @@ import Foundation import shared extension String { - func localize(withComment comment: String, useTable table: String? = nil) -> String { - let result = NSLocalizedString(self, tableName: table, comment: comment) - if result.isEmpty { - return self - } - return result - } - - static func localize(forKey key: String, withComment comment: String, inTable table: String? = nil) -> String { - return key.localize(withComment: comment, useTable: table) - } - func toMD5() -> String { let digest = Insecure.MD5.hash(data: data(using: .utf8) ?? Data()) return Data(digest).base64EncodedString() @@ -40,16 +28,16 @@ extension String { let regex = try! NSRegularExpression(pattern: RegexData.companion.url, options: []) let range = NSRange(location: 0, length: utf16.count) var markdownString = self - + let matches = regex.matches(in: self, options: [], range: range).reversed() - + for match in matches { guard let range = Range(match.range, in: self) else { continue } let url = String(self[range]) let markdown = "[\(url)](\(url))" markdownString = markdownString.replacingCharacters(in: range, with: markdown) } - + return markdownString } return self diff --git a/iosApp/iosApp/Extensions/TimeIntervalExtension.swift b/iosApp/iosApp/Extensions/TimeIntervalExtension.swift index 77115456b..c3078e4bb 100644 --- a/iosApp/iosApp/Extensions/TimeIntervalExtension.swift +++ b/iosApp/iosApp/Extensions/TimeIntervalExtension.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // diff --git a/iosApp/iosApp/Extensions/UISegmentedControlExtension.swift b/iosApp/iosApp/Extensions/UISegmentedControlExtension.swift index 6a1851f2c..af5a7a34b 100644 --- a/iosApp/iosApp/Extensions/UISegmentedControlExtension.swift +++ b/iosApp/iosApp/Extensions/UISegmentedControlExtension.swift @@ -7,16 +7,16 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import SwiftUI extension UISegmentedControl { - override open func didMoveToSuperview() { - super.didMoveToSuperview() - self.setContentHuggingPriority(.defaultLow, for: .vertical) - } + override open func didMoveToSuperview() { + super.didMoveToSuperview() + setContentHuggingPriority(.defaultLow, for: .vertical) + } } diff --git a/iosApp/iosApp/Extensions/ViewModifierExtension.swift b/iosApp/iosApp/Extensions/ViewModifierExtension.swift deleted file mode 100644 index 14773c5df..000000000 --- a/iosApp/iosApp/Extensions/ViewModifierExtension.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ViewModifierExtension.swift -// iosApp -// -// Created by Jan Cortiel on 15.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - -struct AccentModifier: ViewModifier { - var color: Color - func body(content: Content) -> some View { - if #available(iOS 16, *) { - content.tint(color) - } else { - content.accentColor(color) - } - } -} - -struct TabViewModifier: ViewModifier { - var color: Color - func body(content: Content) -> some View { - if #available(iOS 16, *) { - content.toolbarBackground(color, for: .tabBar) - } else { - content.onAppear { - UITabBar.appearance().barTintColor = UIColor(color) - } - } - } -} - -extension View { - @ViewBuilder - func accent(color: Color) -> some View { - self.modifier(AccentModifier(color: color)) - } - - @ViewBuilder - func tabBarColor(color: Color) -> some View { - self.modifier(TabViewModifier(color: color)) - } -} diff --git a/iosApp/iosApp/GoogleService-Info.plist b/iosApp/iosApp/GoogleService-Info.plist deleted file mode 100644 index 5f6d3a961..000000000 --- a/iosApp/iosApp/GoogleService-Info.plist +++ /dev/null @@ -1,34 +0,0 @@ - - - - - CLIENT_ID - 867714323835-lhi1ql8stdvepo3ih565lkat08h17a5u.apps.googleusercontent.com - REVERSED_CLIENT_ID - com.googleusercontent.apps.867714323835-lhi1ql8stdvepo3ih565lkat08h17a5u - API_KEY - AIzaSyCDiCWYRXnlXQEzIYv0AZONq9rdRwKqAQk - GCM_SENDER_ID - 867714323835 - PLIST_VERSION - 1 - BUNDLE_ID - io.redlink.more.ios - PROJECT_ID - more-adad0 - STORAGE_BUCKET - more-adad0.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:867714323835:ios:f694ac24fcb229ee5019de - - \ No newline at end of file diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 4a0878f00..5b0910403 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,7 +4,9 @@ BGTaskSchedulerPermittedIdentifiers - io.redlink.more.app.multiplatform.data-upload + $(PRODUCT_BUNDLE_IDENTIFIER).data-upload + $(PRODUCT_BUNDLE_IDENTIFIER).dailyRefresh + $(PRODUCT_BUNDLE_IDENTIFIER).observation-reminder-refresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -19,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.14 + 1.0.0 CFBundleURLTypes @@ -31,12 +33,12 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes - more-app + umm-app CFBundleVersion - 4.0.14 + 1.0.0 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption @@ -44,21 +46,37 @@ LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription - $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices + $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health + devices + NSBluetoothPeripheralUsageDescription - $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices + $(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health + devices + NSCameraUsageDescription - $(PRODUCT_NAME) needs your camera to scan QR-Code + $(PRODUCT_NAME) needs your camera to scan QR-Code NSLocationAlwaysAndWhenInUseUsageDescription - $(PRODUCT_NAME) needs to access you location to track your position for certain studies + $(PRODUCT_NAME) needs to access you location to track your position for certain + studies + NSLocationAlwaysUsageDescription - $(PRODUCT_NAME) always needs access to you location to track your position for certain studies + $(PRODUCT_NAME) always needs access to you location to track your position for + certain studies + NSLocationUsageDescription - $(PRODUCT_NAME) needs to access you location to track your position for certain studies + $(PRODUCT_NAME) needs to access you location to track your position for certain + studies + NSLocationWhenInUseUsageDescription - $(PRODUCT_NAME) needs access to your location to track your position for certain studies + $(PRODUCT_NAME) needs access to your location to track your position for certain + studies + NSMotionUsageDescription - $(PRODUCT_NAME) needs access to your motion data to record your movement data in the background + $(PRODUCT_NAME) needs access to your motion data to record your movement data in the + background + + NSUserTrackingUsageDescription + $(PRODUCT_NAME) wants to track your app usage for your study UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -93,5 +111,10 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + CFBundleLocalizations + + en + de + diff --git a/iosApp/iosApp/InfoPlist.xcstrings b/iosApp/iosApp/InfoPlist.xcstrings new file mode 100644 index 000000000..fd63a8a9e --- /dev/null +++ b/iosApp/iosApp/InfoPlist.xcstrings @@ -0,0 +1,171 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More" + } + } + } + }, + "NSBluetoothAlwaysUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf Bluetooth, um unterstützte Gesundheitsgeräte zu finden und zu verbinden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices" + } + } + } + }, + "NSBluetoothPeripheralUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf Bluetooth, um unterstützte Gesundheitsgeräte zu finden und zu verbinden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs access to Bluetooth to find and connect to supported health devices" + } + } + } + }, + "NSCameraUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deine Kamera, um QR-Codes zu scannen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs your camera to scan QR-Code" + } + } + } + }, + "NSLocationAlwaysAndWhenInUseUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs to access you location to track your position for certain studies" + } + } + } + }, + "NSLocationAlwaysUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt jederzeit Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) always needs access to you location to track your position for certain studies" + } + } + } + }, + "NSLocationUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs to access you location to track your position for certain studies" + } + } + } + }, + "NSLocationWhenInUseUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt während der Nutzung Zugriff auf deinen Standort, um für bestimmte Studien deine Position zu erfassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs access to your location to track your position for certain studies" + } + } + } + }, + "NSMotionUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) benötigt Zugriff auf deine Bewegungsdaten, um deine Aktivität auch im Hintergrund aufzuzeichnen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) needs access to your motion data to record your movement data in the background" + } + } + } + }, + "NSUserTrackingUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) möchte deine App-Nutzung für deine Studie erfassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME) wants to track your app usage for your study" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift index 4b72ecd25..6f4f62fa0 100644 --- a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift @@ -15,8 +15,8 @@ import CoreMotion import Foundation -import shared import UIKit +import shared class AccelerometerBackgroundObservation: Observation_ { private var recordForDurationInSec: Double = 60 * 10 @@ -25,12 +25,11 @@ class AccelerometerBackgroundObservation: Observation_ { private var timer: Timer? private let semaphore = Semaphore() - private let observationRepository: ObservationRepository = { - ObservationRepository() - }() + private let observationRepository: ObservationRepository - init(sensorPermissions: Set) { - super.init(observationType: AccelerometerType(sensorPermissions: sensorPermissions)) + init(repos: MainRepository, sensorPermissions: Set) { + observationRepository = repos.observation + super.init(repos: repos, observationType: AccelerometerType(sensorPermissions: sensorPermissions)) } override func start() -> Bool { @@ -72,9 +71,10 @@ class AccelerometerBackgroundObservation: Observation_ { } override func applyObservationConfig(settings: [String: Any]) { - if var start = settings[Observation_.Companion().CONFIG_TASK_START] as? Int64, - let end = settings[Observation_.Companion().CONFIG_TASK_STOP] as? Int64, - Date(timeIntervalSince1970: TimeInterval(end)) > Date() { + if var start = settings[Observation_.companion.CONFIG_TASK_START] as? Int64, + let end = settings[Observation_.companion.CONFIG_TASK_STOP] as? Int64, + Date(timeIntervalSince1970: TimeInterval(end)) > Date() + { let startDate = Date(timeIntervalSince1970: TimeInterval(start)) let endDate = Date(timeIntervalSince1970: TimeInterval(end)) if startDate < Date() { diff --git a/iosApp/iosApp/Observations/AccelerometerObservation.swift b/iosApp/iosApp/Observations/AccelerometerObservation.swift index 135c01411..956fa2350 100644 --- a/iosApp/iosApp/Observations/AccelerometerObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerObservation.swift @@ -7,46 +7,46 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import Foundation import CoreMotion +import Foundation import shared class AccelerometerObservation: Observation_ { private let motion = CMMotionManager() - private var accelerometerFrequency = 1.0/60.0 - - private var timer: Timer? = nil - - init(sensorPermission: Set) { - super.init(observationType: AccelerometerType(sensorPermissions: sensorPermission)) + private var accelerometerFrequency = 1.0 / 60.0 + + private var timer: Timer? + + init(repos: MainRepository, sensorPermission: Set) { + super.init(repos: repos, observationType: AccelerometerType(sensorPermissions: sensorPermission)) } - + override func start() -> Bool { if motion.isAccelerometerAvailable { self.timer = setTimer() guard let timer else { return false } - self.motion.startAccelerometerUpdates() + motion.startAccelerometerUpdates() RunLoop.main.add(timer, forMode: .default) - + return true } return false } - + override func stop(onCompletion: @escaping () -> Void) { timer?.invalidate() motion.stopAccelerometerUpdates() onCompletion() } - + override func observerErrors() -> Set { var errors: Set = [] if !motion.isAccelerometerAvailable { @@ -54,16 +54,16 @@ class AccelerometerObservation: Observation_ { } return errors } - - override func applyObservationConfig(settings: Dictionary){ - + + override func applyObservationConfig(settings: Dictionary) { } - + private func setTimer() -> Timer { - Timer(fire: Date(), interval: accelerometerFrequency, repeats: true, block: { timer in + Timer(fire: Date(), interval: accelerometerFrequency, repeats: true, block: { _ in if let data = self.motion.accelerometerData { let dict = ["x": data.acceleration.x, "y": data.acceleration.y, "z": data.acceleration.z] - self.storeData(data: dict, timestamp: -1){} + self.storeData(data: dict, timestamp: -1) { + } } }) } diff --git a/iosApp/iosApp/Observations/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index 3c135f1ab..92943281e 100644 --- a/iosApp/iosApp/Observations/GPSObservation.swift +++ b/iosApp/iosApp/Observations/GPSObservation.swift @@ -15,66 +15,97 @@ import CoreLocation import Foundation -import shared import UIKit +import shared class GPSObservation: Observation_ { - private let manager: CLLocationManager = CLLocationManager() + private var manager: CLLocationManager? public var currentLocation = CLLocation() - private var running = false - init(sensorPermissions: Set) { - super.init(observationType: GPSType(sensorPermissions: sensorPermissions)) - manager.delegate = self - manager.desiredAccuracy = kCLLocationAccuracyBest + init(repos: MainRepository, sensorPermissions: Set) { + super.init(repos: repos, observationType: GPSType(sensorPermissions: sensorPermissions)) + Task { @MainActor [weak self] in + self?.manager = CLLocationManager() + self?.manager?.delegate = self + + self?.manager?.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + self?.manager?.activityType = .fitness + } } + override func start() -> Bool { - if observerAccessible() { - manager.allowsBackgroundLocationUpdates = true - manager.showsBackgroundLocationIndicator = true - manager.startUpdatingLocation() - return true + Task { @MainActor [weak self] in + if let manager = self?.manager { + manager.allowsBackgroundLocationUpdates = true + manager.showsBackgroundLocationIndicator = true + manager.startUpdatingLocation() + Napier.d("Started GPS location updates") + } } - return false + + return true } override func stop(onCompletion: @escaping () -> Void) { - manager.stopUpdatingLocation() - running = false + Napier.d("Stopping GPS location updates") + Task { @MainActor [weak self] in + self?.manager?.stopUpdatingLocation() + } onCompletion() } override func observerErrors() -> Set { var errors: Set = [] - if manager.authorizationStatus == .notDetermined { - errors.insert("Permission request pending until observation is about to start") - manager.requestWhenInUseAuthorization() - } else if manager.authorizationStatus != .authorizedWhenInUse - && manager.authorizationStatus != .authorizedAlways { - errors.insert("Permission not granted to access location of the device") - PermissionManager.openSensorPermissionDialog() - } else if !CLLocationManager.locationServicesEnabled() { + if !CLLocationManager.locationServicesEnabled() { errors.insert("Location Services not enabled") } return errors } - override func applyObservationConfig(settings: Dictionary) {} + override func applyObservationConfig(settings: [String: Any]) { + } + } extension GPSObservation: CLLocationManagerDelegate { - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + func handleLocations(_ locations: [CLLocation]) { + self.storeLocations(locations) + } + + func handleAuthorizationChange(_ manager: CLLocationManager) { + if manager.authorizationStatus == .restricted || manager.authorizationStatus == .denied || manager.accuracyAuthorization != .fullAccuracy { + super.stopAndSetState(state: .paused, scheduleId: nil) + } + } + + @objc func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + self.storeLocations(locations) + } + + @objc func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Napier.e("Location update error \(error)") + } + + @objc func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + self.handleAuthorizationChange(manager) + } + + @objc func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + self.handleAuthorizationChange(manager) + } + + private func storeLocations(_ locations: [CLLocation]) { let data = locations.compactMap { location in - let dict = ["longitude": location.coordinate.longitude, "latitude": location.coordinate.latitude, "altitude": location.altitude] + let dict = [ + "longitude": location.coordinate.longitude, + "latitude": location.coordinate.latitude, + "altitude": location.altitude, + ] return ObservationBulkModel(data: dict, timestamp: Int64(location.timestamp.timeIntervalSince1970)) } storeData(data: data) {} } + - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - if manager.authorizationStatus == .restricted || manager.authorizationStatus == .denied || manager.accuracyAuthorization != .fullAccuracy { - super.stopAndSetState(state: .active, scheduleId: nil) - } - } } diff --git a/iosApp/iosApp/Observations/IOSDataRecorder.swift b/iosApp/iosApp/Observations/IOSDataRecorder.swift index b0eefaa19..61fb4610c 100644 --- a/iosApp/iosApp/Observations/IOSDataRecorder.swift +++ b/iosApp/iosApp/Observations/IOSDataRecorder.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,7 +18,7 @@ import shared class IOSDataRecorder: DataRecorder { private var runningSchedules: Set = Set() - + func start(scheduleId: String) { if !runningSchedules.contains(scheduleId) { Task { @MainActor in @@ -32,9 +32,12 @@ class IOSDataRecorder: DataRecorder { } } } - + func startMultiple(scheduleIds: Set) { - scheduleIds.filter{!runningSchedules.contains($0)}.forEach { id in + scheduleIds.filter { + !runningSchedules.contains($0) + } + .forEach { id in Task { @MainActor in do { if (try await AppDelegate.shared.observationManager.start(scheduleId: id)).boolValue { @@ -46,22 +49,22 @@ class IOSDataRecorder: DataRecorder { } } } - + func pause(scheduleId: String) { AppDelegate.shared.observationManager.pause(scheduleId: scheduleId) runningSchedules.remove(scheduleId) } - + func stop(scheduleId: String) { AppDelegate.shared.observationManager.stop(scheduleId: scheduleId) runningSchedules.remove(scheduleId) } - + func stopAll() { AppDelegate.shared.observationManager.stopAll() runningSchedules.removeAll() } - + func restartAll() { Task { @MainActor in do { @@ -71,11 +74,17 @@ class IOSDataRecorder: DataRecorder { } } } - + func updateTaskStates() { - AppDelegate.shared.observationManager.updateTaskStates() + Task { + do { + try await AppDelegate.shared.observationManager.updateTaskStates() + } catch { + print("Cannot update task states: \(error)") + } + } } - + func activateScheduleUpdate() { AppDelegate.shared.observationManager.activateScheduleUpdate() } diff --git a/iosApp/iosApp/Observations/IOSObservationFactory.swift b/iosApp/iosApp/Observations/IOSObservationFactory.swift index b17de2b8d..948ae53c5 100644 --- a/iosApp/iosApp/Observations/IOSObservationFactory.swift +++ b/iosApp/iosApp/Observations/IOSObservationFactory.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,10 +17,22 @@ import Foundation import shared class IOSObservationFactory: ObservationFactory { - override init(dataManager: ObservationDataManager) { - super.init(dataManager: dataManager) - observations.add(GPSObservation(sensorPermissions: ["gpsAlways"])) - observations.add(AccelerometerBackgroundObservation(sensorPermissions: ["cmsensorrecorder"])) - observations.add(PolarVerityHeartRateObservation(sensorPermissions: ["bluetoothAlways"])) + init(repository: MainRepository, dataManager: ObservationDataManager, userDefaults: SharedStorageRepository) { + super.init(repository: repository, sharedStorageRepository: userDefaults, dataManager: dataManager, scope: Scope.shared) + registerObservation { + GPSObservation(repos: repository, sensorPermissions: ["gpsAlways"]) + } + + registerObservation { + AccelerometerBackgroundObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"]) + } + + registerObservation { + PolarVerityHeartRateObservation(repos: repository, sensorPermissions: ["bluetoothAlways"]) + } + } + + override func observationPostConstruct(observation: Observation_) { + observation.setPermissionObserver(observer: IOSObservationPermissionObserver()) } } diff --git a/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift new file mode 100644 index 000000000..0cdf6a6d1 --- /dev/null +++ b/iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift @@ -0,0 +1,159 @@ +// +// IOSObservationPermissionObserver.swift +// iosApp +// +// Created by Junie on 10.03.26. +// + +import AppTrackingTransparency +import CoreBluetooth +import CoreLocation +import CoreMotion +import Foundation +import shared + +class IOSObservationPermissionObserver: NSObject, ObservationPermissionObserver { + private let locationManager = CLLocationManager() + private var centralManager: CBCentralManager? + private var motionActivityManager: CMMotionActivityManager? + + func permissionState(observationType: ObservationType) -> PermissionApprovalState { + if observationType is GPSType { + let lm = CLLocationManager() + let status = lm.authorizationStatus + Napier.i("GPS authorization: \(status)") + return if status == .notDetermined { + .notSet + } else if (status == .authorizedAlways || status == .authorizedWhenInUse) && lm.accuracyAuthorization == .fullAccuracy { + .granted + } else { + .declined + } + } + if observationType is AccelerometerType { + let status = CMSensorRecorder.authorizationStatus() + Napier.i("CMSensorRecorder authorization: \(status)") + return if status == .notDetermined { + .notSet + } else if status == .authorized { + .granted + } else { + .declined + } + } + if observationType is PolarVerityHeartRateType { + let status = CBManager.authorization + Napier.i("CBManager authorization: \(status)") + return if status == .notDetermined { + .notSet + } else if status == .allowedAlways { + .granted + } else { + .declined + } + } + if observationType is AppUsageObservationType { + let status = ATTrackingManager.trackingAuthorizationStatus + Napier.i("ATTrackingManager authorization: \(status)") + if status == .notDetermined { + return .notSet + } else if status == .authorized { + Napier.event(.appTrackingAccepted) + return .granted + } else { + Napier.event(.appTrackingDeclined) + return .declined + } + } + return .granted + } + + func requestPermission(observationType: ObservationType) { + AppDelegate.shared.observationFactory.startRequestingPermissions() + if observationType is GPSType { + locationManager.delegate = self + let status = locationManager.authorizationStatus + if status == .notDetermined { + // Request both When In Use and Always (if available in Info.plist) + locationManager.requestWhenInUseAuthorization() + locationManager.requestAlwaysAuthorization() + } else if status == .denied || status == .restricted || locationManager.accuracyAuthorization != .fullAccuracy { + PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } + return + } + + if observationType is AccelerometerType { + let status = CMSensorRecorder.authorizationStatus() + if status == .notDetermined { + // Trigger the motion permission prompt by starting activity updates briefly + let mam = CMMotionActivityManager() + motionActivityManager = mam + mam.startActivityUpdates(to: OperationQueue.main) { [weak self] _ in + self?.motionActivityManager?.stopActivityUpdates() + self?.motionActivityManager = nil + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } + } else if status == .denied || status == .restricted { + PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } + return + } + + if observationType is PolarVerityHeartRateType { + let status = CBManager.authorization + if status == .notDetermined { + // Creating a CBCentralManager triggers the Bluetooth permission prompt + centralManager = CBCentralManager(delegate: self, queue: nil) + } else if status == .denied || status == .restricted { + PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } + return + } + + if observationType is AppUsageObservationType { + let status = ATTrackingManager.trackingAuthorizationStatus + if status == .notDetermined { + ATTrackingManager.requestTrackingAuthorization { _ in + // Observation flow will re-check via permissionState + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } + } else if status == .denied || status == .restricted { + PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } else { + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } + return + } + + // Default fallback for other observation types + PermissionManager.openSensorPermissionDialog() + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } +} + +extension IOSObservationPermissionObserver: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + // No-op: Observation flow will re-check via permissionState + locationManager.delegate = nil + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } +} + +extension IOSObservationPermissionObserver: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + // Release once we have a state update; prompt has been shown (if needed) + centralManager = nil + AppDelegate.shared.observationFactory.stopRequestingPermissions() + } +} diff --git a/iosApp/iosApp/Observations/ObservationActionDelegate.swift b/iosApp/iosApp/Observations/ObservationActionDelegate.swift index 8bcb9198d..b2c85421b 100644 --- a/iosApp/iosApp/Observations/ObservationActionDelegate.swift +++ b/iosApp/iosApp/Observations/ObservationActionDelegate.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // diff --git a/iosApp/iosApp/Observations/ObservationDataCollector.swift b/iosApp/iosApp/Observations/ObservationDataCollector.swift index 4953584ee..c8ed5c983 100644 --- a/iosApp/iosApp/Observations/ObservationDataCollector.swift +++ b/iosApp/iosApp/Observations/ObservationDataCollector.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,14 +17,21 @@ import Foundation import shared class ObservationDataCollector { - func collectData(dataCollected completion: @escaping (Bool) -> Void) { print("Collect undone observations") - AppDelegate.shared.updateTaskStates() - AppDelegate.shared.observationManager.collectAllData {success in - AppDelegate.shared.observationDataManager.saveAndSend() - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { timer in - completion(success.boolValue) + Task { + do { + try await AppDelegate.shared.observationManager.updateTaskStates() + await MainActor.run { + AppDelegate.shared.observationManager.collectAllData { success in + AppDelegate.shared.observationDataManager.saveAndSend() + Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in + completion(success.boolValue) + } + } + } + } catch { + print("Cannot update task states: \(error)") } } } diff --git a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift index 509f6651e..a1663aaad 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -13,46 +13,62 @@ // https://commonsclause.com/). // +import Combine import CoreBluetooth import Foundation +import KMPNativeCoroutinesCombine import PolarBleSdk import RxSwift import shared import UIKit class PolarVerityHeartRateObservation: Observation_ { - static var hrReady = false - - static func setHRFeature(state: Bool) { - if state { - if !hrReady { - AppDelegate.shared.observationManager.startObservationType(type: PolarVerityHeartRateType(sensorPermissions: []).observationType) - } - } else { - Observation_.pauseObservation(PolarVerityHeartRateType(sensorPermissions: [])) - } - hrReady = state - } - private let deviceIdentificer: Set = ["Polar"] private let polarConnector = AppDelegate.polarConnector - private var connectedDevices: [BluetoothDevice] = [] + private var connectedDevices: [BluetoothDeviceEntity] = [] private var hrObservation: Disposable? - private let deviceManager = BluetoothDeviceManager.shared + private let bleManager = BluetoothStateManagement.shared + + private var deviceListener: AnyCancellable? - private var deviceListener: Ktor_ioCloseable? - private let errorStringTable = "Errors" - init(sensorPermissions: Set) { - super.init(observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) + private let polarController: PolarController + + private var cancellables = Set() + private static let notificationBackoffInterval: TimeInterval = 60 + private static var lastCannotStartNotificationDate: Date? + + init(repos: MainRepository, sensorPermissions: Set) { + polarController = PolarController(repos: repos) + super.init(repos: repos, observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) + + createPublisher(for: polarController.hrFeatureChange) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { pair in + if let studyActive = pair.first?.boolValue, studyActive { + if let hrReady = pair.second?.boolValue, hrReady { + print("HR Ready: \(hrReady)") + Task { + do { + try await AppDelegate.shared.observationManager.updateTaskStates() + } catch { + print("Cannot start polar observation: \(error)") + } + } + } else { + AppDelegate.shared.observationManager.pauseObservationType(type: self.observationType.observationType) + } + } + } + .store(in: &cancellables) } override func start() -> Bool { if observerAccessible() { - let acceptableDevices = deviceManager.connectedDevicesAsValue().deviceWithNameIn(nameSet: deviceIdentificer) + let acceptableDevices = bleManager.connectedDevicesValue.deviceWithNameIn(nameSet: deviceIdentificer) if !acceptableDevices.isEmpty, let firstAddres = acceptableDevices[0].address { listenToDeviceConnection() hrObservation = polarConnector.polarApi.startHrStreaming(firstAddres).subscribe(onNext: { [weak self] data in @@ -63,37 +79,39 @@ class PolarVerityHeartRateObservation: Observation_ { }, onError: { [weak self] error in print(error) if let self { - showObservationErrorNotification(notificationBody: "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!".localize(withComment: "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!", useTable: errorStringTable), fallbackTitle: "Observation Error".localize(withComment: "Observation Error", useTable: errorStringTable)) + showCannotStartNotificationWithBackoff(title: "Observation Error", message: "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!") Observation_.pauseObservation(self.observationType) } }) return true } } - showObservationErrorNotification(notificationBody: "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!".localize(withComment: "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!", useTable: errorStringTable), fallbackTitle: "Observation Error".localize(withComment: "Observation Error", useTable: errorStringTable)) + showCannotStartNotificationWithBackoff(title: "Observation Error", message: "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!") return false } override func stop(onCompletion: @escaping () -> Void) { hrObservation?.dispose() - deviceListener?.close() + deviceListener?.cancel() onCompletion() } override func observerErrors() -> Set { var errors: Set = [] - let state = AppDelegate.shared.bluetoothController.bluetoothPower.value as? BluetoothState if CBManager.authorization != .allowedAlways { errors.insert("Access to Bluetooth not granted") PermissionManager.openSensorPermissionDialog() + PolarStates.shared.hrFeatureReady(ready: false) } - if state == nil || state == BluetoothState.off { + if !bleManager.bluetoothActiveValue { errors.insert("Bluetooth is not enabled") + PolarStates.shared.hrFeatureReady(ready: false) } if !AppDelegate.shared.bluetoothController.observerDeviceAccessible(bleDevices: deviceIdentificer) { + PolarStates.shared.hrFeatureReady(ready: false) errors.insert("No polar device connected") errors.insert(Observation_.companion.ERROR_DEVICE_NOT_CONNECTED) - } else if !PolarVerityHeartRateObservation.hrReady { + } else if !PolarStates.shared.hrFeatureReadyValue { errors.insert("Heart-rate measurement feature unavailable") } return errors @@ -111,12 +129,25 @@ class PolarVerityHeartRateObservation: Observation_ { observerAccessible() } - private func listenToDeviceConnection() { - deviceListener = deviceManager.connectedDevicesAsClosure { [weak self] devices in - if let self, !self.deviceIdentificer.anyNameIn(items: devices) { - PolarVerityHeartRateObservation.setHRFeature(state: false) - self.deviceListener?.close() - } + private func showCannotStartNotificationWithBackoff(title: String, message: String) { + let now = Date() + if let last = PolarVerityHeartRateObservation.lastCannotStartNotificationDate, + now.timeIntervalSince(last) < PolarVerityHeartRateObservation.notificationBackoffInterval { + return } + PolarVerityHeartRateObservation.lastCannotStartNotificationDate = now + showObservationErrorNotification(notificationBody: message, fallbackTitle: title) + } + + private func listenToDeviceConnection() { + deviceListener = createPublisher(for: bleManager.connectedDevices) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] devices in + if let self, !self.deviceIdentificer.anyNameIn(items: devices) { + PolarStates.shared.hrFeatureReady(ready: false) + self.deviceListener?.cancel() + } + }) } } diff --git a/iosApp/iosApp/Observations/iOSObservationDataManager.swift b/iosApp/iosApp/Observations/iOSObservationDataManager.swift index d9e140ab6..8043bdc8d 100644 --- a/iosApp/iosApp/Observations/iOSObservationDataManager.swift +++ b/iosApp/iosApp/Observations/iOSObservationDataManager.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import Foundation import shared class iOSObservationDataManager: ObservationDataManager { - override func sendData(onCompletion: @escaping (KotlinBoolean) -> Void) { + override func sendData(immediately: Bool, onCompletion: @escaping (KotlinBoolean) -> Void) { AppDelegate.dataUploadManager.uploadData { onCompletion(KotlinBoolean(bool: $0)) } } } diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings new file mode 100644 index 000000000..cb4e97cbe --- /dev/null +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -0,0 +1,2739 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@: %@" : { + "comment" : "A small text element displaying the current app version. The argument is the string “App Version”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@: %2$@" + } + } + } + }, + "%lld" : { + "comment" : "A text label showing the number of errors in an observation. The argument is the number of errors.", + "isCommentAutoGenerated" : true + }, + "Abort" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + } + } + }, + "acc-mobile-observation" : { + "comment" : "NotificationView.strings\n iosApp\n\n Created by Isabella Aigner on 12.04.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschleuunigungsmesser" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accelerometer" + } + } + } + }, + "Accelerometer Recording is not available" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschleunigungssensoraufzeichnung ist nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accelerometer Recording is not available" + } + } + } + }, + "Accelerometer Sensor not available" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschleunigungssensor nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accelerometer Sensor not available" + } + } + } + }, + "accept_button" : { + "comment" : "ConsentView.strings\n iosApp\n\n Created by Jan Cortiel on 09.02.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akzeptieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accept" + } + } + } + }, + "Access to Bluetooth not granted" : { + "comment" : "Errors.strings\n iosApp\n\n Created by Jan Cortiel on 22.05.24.\n Copyright © 2024 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff auf Bluetooth nicht gewährt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access to Bluetooth not granted" + } + } + } + }, + "active_for" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiv für" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active for" + } + } + } + }, + "Alert" : { + "comment" : "The title of an alert.", + "isCommentAutoGenerated" : true + }, + "All" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Nachrichten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All Notifications" + } + } + } + }, + "All Items" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Daten" + } + } + } + }, + "All types" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Typen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All types" + } + } + } + }, + "Answer" : { + "comment" : "SimpleQuestionObservation.strings\n iosApp\n\n Created by Isabella Aigner on 20.04.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antwort" + } + } + } + }, + "answer_submitted" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihre Antwort auf die Frage wurde erfolgreich übermittelt!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your answer to the question has been successfully submitted!" + } + } + } + }, + "App Version" : { + "comment" : "Default.strings\n iosApp\n\n Created by Jan Cortiel on 06.02.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Version" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Version" + } + } + } + }, + "app-usage-observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Nutzung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App usage" + } + } + } + }, + "back_to_settings" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiterhin teilnehmen!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I want to continue participating!" + } + } + } + }, + "Bluetooth disabled! Please enable to use!" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth deaktiviert! Bitte aktivieren zum Verbinden!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth disabled! Please enable to use!" + } + } + } + }, + "Bluetooth is not enabled" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth ist nicht aktiviert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is not enabled" + } + } + } + }, + "camera_to_scan_qr_code" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnen Sie die Kamera um den QR code zu scannen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open camera to scan a QR Code" + } + } + } + }, + "Cancel" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + } + } + }, + "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beobachtung kann nicht gestartet werden! Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind!" + } + } + } + }, + "close" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + } + } + }, + "Close" : { + "comment" : "A button label that closes a view.", + "isCommentAutoGenerated" : true + }, + "Collected Datapoints" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesammelte Datenpunkte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collected Datapoints" + } + } + } + }, + "Connected devices" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbundene Geräte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected devices" + } + } + } + }, + "consent_error_body" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es gab einen System Fehler. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie Ihren Studien-Administrator!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not connect to the server. Please try again later or contact your study-administrator!" + } + } + } + }, + "consent_error_title" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Fehler" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System error" + } + } + } + }, + "continue_study" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiterhin teilnehmen!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to participate" + } + } + } + }, + "Could not receive the url" : { + + }, + "Dashboard" : { + "comment" : "Navigation.strings\n iosApp\n\n Created by Jan Cortiel on 15.03.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Übersicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dashboard" + } + } + } + }, + "Dashboard Filter" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dashboard Filter" + } + } + } + }, + "Data is loading..." : { + "comment" : "A placeholder text that appears while the data for the LimeSurvey is loading.", + "isCommentAutoGenerated" : true + }, + "data_capture_running" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufzeichnung läuft" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data Capture is running" + } + } + } + }, + "decline_button" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ablehnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Decline" + } + } + } + }, + "Devices" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geräte" + } + } + } + }, + "Disconnect" : { + "comment" : "A button label that disconnects from a Bluetooth device.", + "isCommentAutoGenerated" : true + }, + "Discovered devices" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entdeckte Geräte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discovered devices" + } + } + } + }, + "Done" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fertig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "enter_study_endpoint" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearbeite den Studienendpunkt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Study Endpoint" + } + } + } + }, + "enter_study_endpoint_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL der Studie eingeben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Study URL" + } + } + } + }, + "enter_token" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Token eingeben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Token" + } + } + } + }, + "ENTIRE_TIME" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamter Zeitraum" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entire time" + } + } + } + }, + "Error" : { + "comment" : "A button label that indicates there are observation errors. The number in front of \"Error\" is replaced by the actual number of errors.", + "isCommentAutoGenerated" : true + }, + "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler beim Fortsetzen der Beobachtung! Es gab ein Verbindungsproblem mit einem Bluetooth-Sensor. Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!" + } + } + } + }, + "errors" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "errors" + } + } + } + }, + "External Device Setup" : { + "comment" : "BluetoothConnection.strings\n iosApp\n\n Created by Jan Cortiel on 26.04.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Externe Geräteeinstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "External Device Setup" + } + } + } + }, + "filter_acitvated" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter aktiviert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter activated" + } + } + } + }, + "first_message" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn Sie die Studie verlassen, können Sie später nicht mehr teilnehmen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you withdraw from the study, you will not be able to re-enter at a later date." + } + } + } + }, + "gps-mobile-observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS" + } + } + } + }, + "Heart-rate measurement feature unavailable" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Funktion zur Messung der Herzfrequenz nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heart-rate measurement feature unavailable" + } + } + } + }, + "Important" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wichtig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Important" + } + } + } + }, + "info_contact_title" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontaktdaten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contact" + } + } + } + }, + "info_disclaimer" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenden Sie sich an uns, wenn Sie auf Probleme stoßen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feel free to contact us, when you encounter problems." + } + } + } + }, + "Information" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Information" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Information" + } + } + } + }, + "Leave Study" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studie verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leave Study" + } + } + } + }, + "leave_confirmation_message" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn Sie die Studie verlassen, können Sie später nicht mehr teilnehmen. Ihre Studienteilnahme wird beendet und alle bisher aufgezeichneten Daten werden von Ihrem Mobiltelefon gelöscht!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you leave this study, you may not re-enter. Your participation will be cancelled and your data will be deleted from your mobile phone!" + } + } + } + }, + "leave_study" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Studie verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leave Study" + } + } + } + }, + "LimeSurvey" : { + "comment" : "LimeSurvey.strings\n iosApp\n\n Created by Jan Cortiel on 15.05.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LimeSurvey" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LimeSurvey" + } + } + } + }, + "Location Services not enabled" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ortungsdienste sind nicht aktiviert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location Services not enabled" + } + } + } + }, + "login_button" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login" + } + } + } + }, + "login_model_invalid_body" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte kontrollieren Sie Ihren Zugangs-Token und die Studien-URL und versuchen Sie es erneut. Ansonsten wenden Sie sich bitte an Ihren Studien-Administrator!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access-token or study-endpoint invalid. Please check your input and try again. Else contact your study-administrator!" + } + } + } + }, + "login_welcome_title" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Willkommen bei More" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to More" + } + } + } + }, + "modules" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modules" + } + } + } + }, + "multiple-choice-question-observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehrfachauswahl" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiple Choice" + } + } + } + }, + "No devices connected" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Geräte angeschlossen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No devices connected" + } + } + } + }, + "No devices found nearby" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Geräte in der Nähe gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No devices found nearby" + } + } + } + }, + "No polar device connected" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Polar-Gerät verbunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No polar device connected" + } + } + } + }, + "No running tasks currently" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Derzeit gibt es keine laufenden Aufzeichnungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No running tasks currently" + } + } + } + }, + "No tasks completed by now" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch keine Aufgaben erledigt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tasks completed by now" + } + } + } + }, + "No tasks to show" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine zu zeigenden Aufgaben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tasks to show" + } + } + } + }, + "no_filter_activated" : { + "comment" : "NotificationView.strings\n iosApp\n\n Created by Isabella Aigner on 12.04.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es wurde kein Filter wurde aktiviert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No filter activated" + } + } + } + }, + "no_internet_message" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte verbinden Sie das Gerät mit dem Internet und versuchen es erneut!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please connect to the internet and try again" + } + } + } + }, + "no_internet_title" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Internetverbindung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No internet connection" + } + } + } + }, + "Notification Filter" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nachrichten Filter" + } + } + } + }, + "Notification Permissions Not Granted" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungsberechtigungen nicht erteilt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification Permissions Not Granted" + } + } + } + }, + "Notifications" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nachrichten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications" + } + } + } + }, + "obs_modules" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observation Modules" + } + } + } + }, + "Observation Details" : { + "comment" : "ObservationDetails.strings\n iosApp\n\n Created by Isabella Aigner on 19.04.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufzeichnungsdetails" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observation Details" + } + } + } + }, + "Observation Errors" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufzeichnungsfehler" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observation Errors" + } + } + } + }, + "Observation Filter" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observation Filter" + } + } + } + }, + "observations" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufzeichnungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observations" + } + } + } + }, + "Ok" : { + "comment" : "The text of the \"Ok\" button in an alert.", + "isCommentAutoGenerated" : true + }, + "ONE_MONTH" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 Monat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 Month" + } + } + } + }, + "ONE_WEEK" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 Woche" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 Week" + } + } + } + }, + "open_filter_settings" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnen Sie die Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Filter Settings" + } + } + } + }, + "open_settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen öffnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open settings" + } + } + } + }, + "or" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "oder" + } + } + } + }, + "Participant" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilnehmer" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Participant" + } + } + } + }, + "Participant Information" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informationen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Participant Information" + } + } + } + }, + "participant_info" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informationen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Participant information" + } + } + } + }, + "participation_key_entry" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte geben Sie den Registrierungstoken ein" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Registration Token" + } + } + } + }, + "participation_key_entry_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Token eingeben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Token" + } + } + } + }, + "Past Observations" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vergangene Aufzeichnungen" + } + } + } + }, + "pause_observation" : { + "comment" : "A label for a button that pauses an observation.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aufzeichnung pausieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause Observation" + } + } + } + }, + "Permission not granted to access location of the device" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff auf den Standort des Geräts nicht genehmigt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permission not granted to access location of the device" + } + } + } + }, + "Permission not granted to access Sensor recording service" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff auf Sensoraufzeichnungsdienst nicht genehmigt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permission not granted to access Sensor recording service" + } + } + } + }, + "Permission request pending until observation is about to start" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genehmigungsanfrage ausstehend, bis die Beobachtung beginnt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permission request pending until observation is about to start" + } + } + } + }, + "permission_needed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamerazugriff erforderlich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permission needed" + } + } + } + }, + "permissions_denied" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff verweigert!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permissions denied!" + } + } + } + }, + "polar-verity-observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Polar Verity" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Polar Verity" + } + } + } + }, + "Proceed to Settings" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu den Einstellungen gehen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proceed to Settings" + } + } + } + }, + "Proceed Without Granting Permissions" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohne Berechtigungen fortfahren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proceed Without Granting Permissions" + } + } + } + }, + "provide_camera_access" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Um den QR Code zu scannen benötigen wir Zugriff zu deiner Kamera." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera permissions needed to scan QR code." + } + } + } + }, + "Question Observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simple Frage" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Question" + } + } + } + }, + "question-observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frage" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Question" + } + } + } + }, + "questionnaire-observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fragebogen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questionnaire" + } + } + } + }, + "Read Less" : { + "comment" : "ExpandableText.strings\n iosApp\n\n Created by Isabella Aigner on 23.03.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weniger" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read less" + } + } + } + }, + "Read More" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read more" + } + } + } + }, + "really_message" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchten Sie wirklich die Studie verlassen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you really want to withdraw?" + } + } + } + }, + "refresh_study_config" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studienkonfiguration neu laden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh Study Configuration" + } + } + } + }, + "Reload study" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studie neu laden" + } + } + } + }, + "Required Permissions Were Not Granted" : { + "comment" : "AlertDialog.strings\n iosApp\n\n Created by Jan Cortiel on 30.01.24.\n Copyright © 2024 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erforderliche Berechtigungen wurden nicht erteilt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required Permissions Were Not Granted" + } + } + } + }, + "reset_filter" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset filter" + } + } + } + }, + "return_to_dashboard" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurück zur Übersicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Return to Dashboard" + } + } + } + }, + "Running Observations" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laufende Aufzeichnungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running Observations" + } + } + } + }, + "Scan QR Code" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR-Code scannen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan QR Code" + } + } + } + }, + "scan_qr_code" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR Code Scannen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan QR Code" + } + } + } + }, + "scan_start_automatic" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR Code wird automatisch gescannt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR Code will be scanned automatically." + } + } + } + }, + "schedule" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitplan" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule" + } + } + } + }, + "schedule_string" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitplan" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule" + } + } + } + }, + "Searching for devices" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche nach Geräten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Searching for devices" + } + } + } + }, + "Select Filter" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle einen Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Filter" + } + } + } + }, + "Select Time" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle eine Zeit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Time" + } + } + } + }, + "Select Type" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle einen Typ" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Type" + } + } + } + }, + "select_answer" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte beantworten Sie die Frage" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please select an answer" + } + } + } + }, + "Settings" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "settings_text" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sie haben alle notwendigen Zustimmungen erteilt. Sie können die Zustimmmungen zurückziehen, indem Sie die gesamte Studie verlassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All necessary permissions have been accepted. Permissions can be viewed here. If you want to withdraw permissions, you can only do so by withdrawing from the study." + } + } + } + }, + "settings_title" : { + "comment" : "SettingsView.strings\n iosApp\n\n Created by Julia Mayrhauser on 08.03.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "Some tasks in this study need certain bluetooth devices to be completed and only activate, once a certain device is connected. Please make sure to turn on and connect these devices" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einige Aufgaben in dieser Studie erfordern bestimmte Bluetooth-Geräte, die nur aktiviert werden, wenn ein bestimmtes Gerät angeschlossen ist. Bitte stellen Sie sicher, dass Sie diese Geräte einschalten und verbinden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Some tasks in this study need certain bluetooth devices to be completed and only activate, once a certain device is connected. Please make sure to turn on and connect these devices" + } + } + } + }, + "Start LimeSurvey" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start LimeSurvey" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start LimeSurvey" + } + } + } + }, + "start_observation" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufzeichnung starten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Data Capture" + } + } + } + }, + "start_questionnaire" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starte den Fragebogen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Questionnaire" + } + } + } + }, + "stop_observation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufzeichnung stoppen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop Data Capture" + } + } + } + }, + "Study currently paused" : { + "comment" : "Title of a view that informs the user that a study is currently paused", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studie pausiert" + } + } + } + }, + "Study Details" : { + "comment" : "Info.strings\n iosApp\n\n Created by Isabella Aigner on 20.04.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studiendetails" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Study Details" + } + } + } + }, + "Study loading…" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studie lädt…" + } + } + } + }, + "study_consent" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zustimmung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consent" + } + } + } + }, + "study_currently_inactive" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Studie ist derzeit nicht aktiv" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Study currently inactive" + } + } + } + }, + "study_duration" : { + "comment" : "StudyDetailsView.strings\n iosApp\n\n Created by Daniil Barkov on 22.03.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studiendauer:" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Study duration:" + } + } + } + }, + "study_endpoint_headling" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Studienendpunkt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Study Endpoint" + } + } + } + }, + "study_loading_error_message" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es gab ein Problem beim Laden Ihrer Studie.\\nBitte versuchen Sie es später erneut oder kontaktieren Sie Ihren Studien Administrator" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There was an issue loading your study.\\nPlease try again later or contact your study administrator" + } + } + } + }, + "study_loading_error_title" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler beim Laden der Studie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error loading Study" + } + } + } + }, + "study_update_title" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Studie wird gerade aktualisiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The study is currently updating" + } + } + } + }, + "study_updating_message" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte warten Sie bis der Prozess abgeschlossen wurde, schließen Sie nicht die Applikation oder trennen Ihr Gerät vom Internet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please wait until this processed has finished, do not exit the app or disconnect the device from the internet" + } + } + } + }, + "submit" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einreichen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Submit" + } + } + } + }, + "sure_message" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sind Sie sicher, dass sie Ihre Teilnahme an der Studie beenden wollen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to withdraw your participation?" + } + } + } + }, + "System Error! Please try again later or contact your Study Administrator!" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Error! Bitte versuchen Sie es später oder kontaktieren Sie Ihren Studien-Administrator!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Error! Please try again later or contact your Study Administrator!" + } + } + } + }, + "Task Detail" : { + "comment" : "TaskDetail.strings\n iosApp\n\n Created by Isabella Aigner on 22.03.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufgabendetails" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Task Detail" + } + } + } + }, + "Task Details" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufgabendetails" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Task Detail" + } + } + } + }, + "tasks_completed" : { + "comment" : "DashboardView.strings\n iosApp\n\n Created by Julia Mayrhauser on 03.03.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamtfortschritt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overall Progress" + } + } + } + }, + "thank_you" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vielen Dank!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thank You!" + } + } + } + }, + "thank_you_participation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vielen Dank für Ihre Teilnahme!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thank You for your participation!" + } + } + } + }, + "There are currently no notficiations to show" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Derzeit gibt es keine Nachrichten zum Anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are currently no notficiations to list" + } + } + } + }, + "This study is currently paused by the Study Operator and will be resumed shortly" : { + "comment" : "A title that describes that the study is currently paused", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Studie wurde vom Studien-Leiter derzeit pausiert und wird in Kürze wieder fortgesetzt" + } + } + } + }, + "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Studie benötigt eine oder mehrere Sensorenberechtigungen, um ordnungsgemäß zu funktionieren. Sie haben die Möglichkeit, die Sensorenberechtigungen abzulehnen; sollten Sie sich jedoch dafür entscheiden, funktionieren die Anwendung und die Studie möglicherweise nicht vollständig oder wie erwartet. Möchten Sie zu den Einstellungen navigieren und der Anwendung den Zugriff auf diese notwendigen Berechtigungen gestatten?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?" + } + } + } + }, + "timeframe" : { + "comment" : "ScheduleListView.strings\n iosApp\n\n Created by Julia Mayrhauser on 07.03.23.\n Copyright © 2023 Redlink GmbH. All rights reserved.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitrahmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeframe" + } + } + } + }, + "Timeframe" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitrahmen:" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeframe: " + } + } + } + }, + "to_settings" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchten Sie erneut versuchen die Settings zu senden? Wenn das Problem bestehen bleibt kontaktieren Sie bitte ihr Forschungsinstitut." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you want to retry sending your settings? If the problem persists, please contact your study institute." + } + } + } + }, + "TODAY_AND_TOMORROW" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute und Morgen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today and Tomorrow" + } + } + } + }, + "Token or Endpoint invalid" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler im Token oder in der URL" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Token or Endpoint invalid" + } + } + } + }, + "token_error" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Fehler ist beim Token aufgetreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Token Error" + } + } + } + }, + "type" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + } + } + }, + "type_plural" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Types" + } + } + } + }, + "Unread" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ungelesen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unread" + } + } + } + }, + "update_error" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Fehler beim Update der Studie ist aufgetreten!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error updating study!" + } + } + } + }, + "We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wir bitten um Erlaubnis, Ihnen Push-Benachrichtigungen zu senden. Dies hilft, den aktuellen Status der Studie jederzeit aufrechtzuerhalten und dient als Erinnerung an Ihre Aufgaben." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks." + } + } + } + }, + "withdraw" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Studie verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Withdraw from the study" + } + } + } + }, + "withdraw_study" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Studie verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Withdraw from the study" + } + } + } + }, + "withdraw_swipe" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ziehen um die die Studie zu verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe to withdraw" + } + } + } + }, + "You can connect to and disconnect from devices at any time: Info > Devices" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sie können sich jederzeit mit Geräten verbinden und die Verbindung zu ihnen trennen: Info > Geräte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can connect to and disconnect from devices at any time: Info > Devices" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/iosApp/iosApp/Resources/Strings/NotificationFilter.strings b/iosApp/iosApp/Resources/Strings/NotificationFilter.strings deleted file mode 100644 index f8493e875..000000000 --- a/iosApp/iosApp/Resources/Strings/NotificationFilter.strings +++ /dev/null @@ -1,17 +0,0 @@ -/* - NotificationFilter.strings - iosApp - - Created by Mikolaj Luzak on 25.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Select Filter" = "Select Filter"; - -"ALL" = "All Notifications"; -"UNREAD" = "Unread"; -"IMPORTANT" = "Important"; - -"no_filter_activated" = "No Filter activated"; -"filter" = "Filter"; -"filter_plural" = "Filters"; diff --git a/iosApp/iosApp/Resources/Strings/ObservationTypes.strings b/iosApp/iosApp/Resources/Strings/ObservationTypes.strings deleted file mode 100644 index 0811c9024..000000000 --- a/iosApp/iosApp/Resources/Strings/ObservationTypes.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* - ObservationTypes.strings - iosApp - - Created by Isabella Aigner on 05.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"All Items" = "All Items"; -"gps-mobile-observation" = "GPS Mobile Observation"; -"acc-mobile-observation" = "ACC Mobile Observation"; -"polar-verity-observation" = "Polar Verity Observation"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/AlertDialog.strings b/iosApp/iosApp/Resources/Strings/de.lproj/AlertDialog.strings deleted file mode 100644 index 321159c94..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/AlertDialog.strings +++ /dev/null @@ -1,14 +0,0 @@ -/* - AlertDialog.strings - iosApp - - Created by Jan Cortiel on 30.01.24. - Copyright © 2024 Redlink GmbH. All rights reserved. -*/ -"Required Permissions Were Not Granted" = "Erforderliche Berechtigungen wurden nicht erteilt"; -"This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?" = "Diese Studie benötigt eine oder mehrere Sensorenberechtigungen, um ordnungsgemäß zu funktionieren. Sie haben die Möglichkeit, die Sensorenberechtigungen abzulehnen; sollten Sie sich jedoch dafür entscheiden, funktionieren die Anwendung und die Studie möglicherweise nicht vollständig oder wie erwartet. Möchten Sie zu den Einstellungen navigieren und der Anwendung den Zugriff auf diese notwendigen Berechtigungen gestatten?"; -"Proceed to Settings" = "Zu den Einstellungen gehen"; -"Proceed Without Granting Permissions" = "Ohne Berechtigungen fortfahren"; - -"Notification Permissions Not Granted" = "Benachrichtigungsberechtigungen nicht erteilt"; -"We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks." = "Wir bitten um Erlaubnis, Ihnen Push-Benachrichtigungen zu senden. Dies hilft, den aktuellen Status der Studie jederzeit aufrechtzuerhalten und dient als Erinnerung an Ihre Aufgaben."; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/BluetoothConnection.strings b/iosApp/iosApp/Resources/Strings/de.lproj/BluetoothConnection.strings deleted file mode 100644 index 6669eed29..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/BluetoothConnection.strings +++ /dev/null @@ -1,22 +0,0 @@ -/* - BluetoothConnection.strings - iosApp - - Created by Jan Cortiel on 26.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"External Device Setup" = "Externe Geräteeinstellungen"; - -"Connected devices" = "Verbundene Geräte"; -"Discovered devices" = "Entdeckte Geräte"; -"Searching for devices" = "Suche nach Geräten"; - - -"No devices connected" = "Keine Geräte angeschlossen"; -"No devices found nearby" = "Keine Geräte in der Nähe gefunden"; - -"Bluetooth disabled! Please enable to use!" = "Bluetooth deaktiviert! Bitte aktivieren zum Verbinden!"; - -"Some tasks in this study need certain bluetooth devices to be completed and only activate, once a certain device is connected. Please make sure to turn on and connect these devices" = "Einige Aufgaben in dieser Studie erfordern bestimmte Bluetooth-Geräte, die nur aktiviert werden, wenn ein bestimmtes Gerät angeschlossen ist. Bitte stellen Sie sicher, dass Sie diese Geräte einschalten und verbinden"; -"You can connect to and disconnect from devices at any time: Info > Devices" = "Sie können sich jederzeit mit Geräten verbinden und die Verbindung zu ihnen trennen: Info > Geräte"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/ConsentView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/ConsentView.strings deleted file mode 100644 index 42c536086..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/ConsentView.strings +++ /dev/null @@ -1,11 +0,0 @@ -/* - ConsentView.strings - iosApp - - Created by Jan Cortiel on 09.02.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"accept_button" = "Akzeptieren"; -"decline_button" = "Ablehnen"; -"to_settings" = "Möchten Sie erneut versuchen die Settings zu senden? Wenn das Problem bestehen bleibt kontaktieren Sie bitte ihr Forschungsinstitut."; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/DashboardFilter.strings b/iosApp/iosApp/Resources/Strings/de.lproj/DashboardFilter.strings deleted file mode 100644 index e73fd0c63..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/DashboardFilter.strings +++ /dev/null @@ -1,31 +0,0 @@ -/* - NotificationView.strings - iosApp - - Created by Isabella Aigner on 12.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"acc-mobile-observation" = "Beschleuunigungsmesser"; -"gps-mobile-observation" = "GPS"; -"polar-verity-observation" = "Polar Verity"; -"question-observation" = "Frage"; -"questionnaire-observation" = "Fragebogen"; - -"no_filter_activated" = "Es wurde kein Filter wurde aktiviert."; -"filter_acitvated" = "Filter aktiviert"; -"reset_filter" = "Filter zurücksetzen"; - -"All Items" = "Alle Daten"; -"All types" = "Alle Typen"; - -"ENTIRE_TIME" = "Gesamter Zeitraum"; -"ONE_MONTH" = "1 Monat"; -"ONE_WEEK" = "1 Woche"; -"TODAY_AND_TOMORROW" = "Heute und Morgen"; - -"Select Time" = "Wähle eine Zeit"; -"Select Type" = "Wähle einen Typ"; - -"type" = "Typ"; -"type_plural" = "Typen"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/DashboardView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/DashboardView.strings deleted file mode 100644 index 76cf371fe..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/DashboardView.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* - DashboardView.strings - iosApp - - Created by Julia Mayrhauser on 03.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"tasks_completed" = "Gesamtfortschritt"; -"study_currently_inactive" = "Die Studie ist derzeit nicht aktiv"; -"open_filter_settings" = "Öffnen Sie die Filter"; -"schedule" = "Zeitplan"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/Default.strings b/iosApp/iosApp/Resources/Strings/de.lproj/Default.strings deleted file mode 100644 index 9af9b9b88..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/Default.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* - Default.strings - iosApp - - Created by Jan Cortiel on 06.02.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ -"App Version" = "App Version"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings b/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings deleted file mode 100644 index 1bacae34c..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings +++ /dev/null @@ -1,22 +0,0 @@ -/* - Errors.strings - iosApp - - Created by Jan Cortiel on 22.05.24. - Copyright © 2024 Redlink GmbH. All rights reserved. -*/ - -"Access to Bluetooth not granted" = "Zugriff auf Bluetooth nicht gewährt"; -"Bluetooth is not enabled" = "Bluetooth ist nicht aktiviert"; -"No polar device connected" = "Kein Polar-Gerät verbunden"; -"Permission request pending until observation is about to start" = "Genehmigungsanfrage ausstehend, bis die Beobachtung beginnt"; -"Accelerometer Sensor not available" = "Beschleunigungssensor nicht verfügbar"; -"Permission not granted to access Sensor recording service" = "Zugriff auf Sensoraufzeichnungsdienst nicht genehmigt"; -"Accelerometer Recording is not available" = "Beschleunigungssensoraufzeichnung ist nicht verfügbar"; -"Location Services not enabled" = "Ortungsdienste sind nicht aktiviert"; -"Permission not granted to access location of the device" = "Zugriff auf den Standort des Geräts nicht genehmigt"; -"errors" = "Fehler"; -"Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!" = "Beobachtung kann nicht gestartet werden! Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind!"; -"Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!" = "Fehler beim Fortsetzen der Beobachtung! Es gab ein Verbindungsproblem mit einem Bluetooth-Sensor. Bitte stellen Sie sicher, dass Bluetooth aktiviert ist und alle notwendigen Geräte verbunden sind!"; -"Observation Error" = "Aufzeichnungfehler"; -"Heart-rate measurement feature unavailable" = "Funktion zur Messung der Herzfrequenz nicht verfügbar"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/ExpandableText.strings b/iosApp/iosApp/Resources/Strings/de.lproj/ExpandableText.strings deleted file mode 100644 index b5257873b..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/ExpandableText.strings +++ /dev/null @@ -1,10 +0,0 @@ -/* - ExpandableText.strings - iosApp - - Created by Isabella Aigner on 23.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Read Less" = "Weniger"; -"Read More" = "Mehr"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/Info.strings b/iosApp/iosApp/Resources/Strings/de.lproj/Info.strings deleted file mode 100644 index 17915c262..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/Info.strings +++ /dev/null @@ -1,18 +0,0 @@ -/* - Info.strings - iosApp - - Created by Isabella Aigner on 20.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Study Details" = "Studiendetails"; -"Running Observations" = "Laufende Aufzeichnungen"; -"Past Observations" = "Vergangene Aufzeichnungen"; -"Devices" = "Geräte"; -"Settings" = "Einstellungen"; -"Leave Study" = "Studie verlassen"; - -"info_disclaimer" = "Wenden Sie sich an uns, wenn Sie auf Probleme stoßen."; -"info_contact_title" = "Kontaktdaten"; -"Participant" = "Teilnehmer"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/LimeSurvey.strings b/iosApp/iosApp/Resources/Strings/de.lproj/LimeSurvey.strings deleted file mode 100644 index 9e7ea9dad..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/LimeSurvey.strings +++ /dev/null @@ -1,11 +0,0 @@ -/* - LimeSurvey.strings - iosApp - - Created by Jan Cortiel on 15.05.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"LimeSurvey" = "LimeSurvey"; -"Cancel" = "Abbrechen"; -"Done" = "Fertig"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings deleted file mode 100644 index 96a6e9f35..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings +++ /dev/null @@ -1,25 +0,0 @@ -/* - LoginViewLocalizable.strings - iosApp - - Created by Jan Cortiel on 06.02.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"login_welcome_title" = "Willkommen bei More"; - -"or" = "oder"; -"login_button" = "Login"; - -"participation_key_entry" = "Bitte geben Sie den Registrierungstoken ein"; - -"study_endpoint_headling" = "Studienendpunkt"; -"enter_study_endpoint" = "Bearbeite den Studienendpunkt"; - -"enter_token" = "Token eingeben"; -"token_error" = "Ein Fehler ist beim Token aufgetreten"; - -"enter_study_url" = "URL der Studie eigeben"; - -"scan_qr_code" = "Scannen Sie bitte Ihren QR Code"; -"camera_to_scan_qr_code" = "Öffnen Sie die Kamera um den QR code zu scannen"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings b/iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings deleted file mode 100644 index 76efe1ef8..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings +++ /dev/null @@ -1,24 +0,0 @@ -/* - Navigation.strings - iosApp - - Created by Jan Cortiel on 15.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Dashboard" = "Übersicht"; -"Dashboard Filter" = "Filter"; -"Notifications" = "Nachrichten"; -"Information" = "Information"; -"Settings" = "Einstellungen"; -"Task Details" = "Aufgabendetails"; -"Observation Details" = "Aufzeichnungsdetails"; -"Observation Filter" = "Filter"; -"Question Observation" = "Simple Frage"; -"Study Details" = "Studiendetails"; -"Running Observations" = "Laufende Aufzeichnungen"; -"Past Observations" = "Vergangene Aufzeichnungen"; -"Devices" = "Geräte"; -"Observation Errors" = "Aufzeichnungsfehler"; - -"Scan QR Code" = "QR-Code scannen"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/NotificationView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/NotificationView.strings deleted file mode 100644 index 6762b22b7..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/NotificationView.strings +++ /dev/null @@ -1,5 +0,0 @@ -"no_filter_activated" = "Es wurde kein Filter wurde aktiviert"; -"There are currently no notficiations to show" = "Derzeit gibt es keine Nachrichten zum Anzeigen"; -"All" = "Alle Nachrichten"; -"Unread" = "Nicht gelesene"; -"Important" = "Nur wichtige"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/ObservationDetails.strings b/iosApp/iosApp/Resources/Strings/de.lproj/ObservationDetails.strings deleted file mode 100644 index 446c501fe..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/ObservationDetails.strings +++ /dev/null @@ -1,10 +0,0 @@ -/* - ObservationDetails.strings - iosApp - - Created by Isabella Aigner on 19.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Observation Details" = "Aufzeichnungsdetails"; -"Participant Information" = "Informationen"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/ScheduleListView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/ScheduleListView.strings deleted file mode 100644 index 91c70185d..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/ScheduleListView.strings +++ /dev/null @@ -1,22 +0,0 @@ -/* - ScheduleListView.strings - iosApp - - Created by Julia Mayrhauser on 07.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"timeframe" = "Zeitrahmen"; -"active_for" = "Aktiv für"; - -"start_questionnaire" = "Starte den Fragebogen"; -"start_observation" = "Aufzeichnung starten"; -"pause_observation" = "Aufzeichnung pausieren"; -"stop_observation" = "Aufzeichnung stoppen"; -"Start LimeSurvey" = "Start LimeSurvey"; - -"data_capture_running" = "Aufzeichnung läuft"; - -"No running tasks currently" = "Derzeit gibt es keine laufenden Aufzeichnungen"; -"No tasks completed by now" = "Noch keine Aufgaben erledigt"; -"No tasks to show" = "Keine zu zeigenden Aufgaben"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/SettingsView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/SettingsView.strings deleted file mode 100644 index 406b910cf..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/SettingsView.strings +++ /dev/null @@ -1,28 +0,0 @@ -/* - SettingsView.strings - iosApp - - Created by Julia Mayrhauser on 08.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"settings_title" = "Einstellungen"; - -"settings_text" = "Sie haben alle notwendigen Zustimmungen erteilt. Sie können die Zustimmmungen zurückziehen, indem Sie die gesamte Studie verlassen."; -"refresh_study_config" = "Studienkonfiguration neu laden"; - -"withdraw" = "Die Studie verlassen"; -"leave_study" = "Die Studie verlassen"; -"continue_study" = "Weiterhin teilnehmen!"; -"withdraw_study" = "Die Studie verlassen"; -"withdraw_swipe" = "Ziehen um die die Studie zu verlassen"; - -"first_message" = "Wenn Sie die Studie verlassen, können Sie später nicht mehr teilnehmen."; -"second_message" = "Wenn Sie die Studie verlassen, können Sie später nicht mehr teilnehmen. Ihre Studienteilnahme wird beendet und alle bisher aufgezeichneten Daten werden von Ihrem Mobiltelefon gelöscht."; - -"really_message" = "Möchten Sie wirklich die Studie verlassen?"; -"sure_message" = "Sind Sie sicher, dass sie Ihre Teilnahme an der Studie beenden wollen?"; -"back_to_settings" = "Weiterhin teilnehmen!"; - -"study_consent" = "Zustimmung"; -"update_error" = "Ein Fehler beim Update der Studie ist aufgetreten!"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/SimpleQuestionObservation.strings b/iosApp/iosApp/Resources/Strings/de.lproj/SimpleQuestionObservation.strings deleted file mode 100644 index 2c78336ea..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/SimpleQuestionObservation.strings +++ /dev/null @@ -1,19 +0,0 @@ -/* - SimpleQuestionObservation.strings - iosApp - - Created by Isabella Aigner on 20.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Answer" = "Antwort"; -"select_answer" = "Bitte beantworten Sie die Frage"; - -"submit" = "Einreichen"; -"close" = "Schließen"; -"return_to_dashboard" = "Zurück zur Übersicht"; - -"answer_submitted" = "Ihre Antwort auf die Frage wurde erfolgreich übermittelt!"; - -"thank_you" = "Vielen Dank!"; -"thank_you_participation" = "Vielen Dank für Ihre Teilnahme!"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/StudyDetailsView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/StudyDetailsView.strings deleted file mode 100644 index 7a24a79d9..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/StudyDetailsView.strings +++ /dev/null @@ -1,18 +0,0 @@ -/* - StudyDetailsView.strings - iosApp - - Created by Daniil Barkov on 22.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"study_duration" = "Studiendauer:"; -"participant_info" = "Informationen"; -"modules_string" = "Module"; -"obs_modules" = "Module"; -"tasks_completed" = "Gesamtfortschritt"; - -"close_view" = "Schließen"; -"schedule_string" = "Zeitplan"; -"open_settings" = "Gehe zu den Einstellungen"; -"no_filter_activated" = "Kein Filter aktiviert"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/StudyStates.strings b/iosApp/iosApp/Resources/Strings/de.lproj/StudyStates.strings deleted file mode 100644 index c3d4d743c..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/StudyStates.strings +++ /dev/null @@ -1,16 +0,0 @@ -/* - StudyStates.strings - iosApp - - Created by Jan Cortiel on 25.07.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"The study configuration is currently updating" = "Die Studienkonfiguration wird gerade aktualisiert!"; -"Study currently paused" = "Die Studie ist derzeit pausiert"; -"This study is currently paused by the Study Operator and will be resumed shortly" = "Die Studie ist vom Studienleiter derzeit pausiert und wird in Kürze fortgesetzt"; -"Please wait until this process is finished" = "Bitte warten Sie kurz, bis die Aktualisierung beendet wurde"; -"Study was completed" = "Diese Studie wurde beendet"; -"Thank you for your participation" = "Vielen Dank für Ihre Teilnahme"; -"Message by the Study Operator" = "Nachricht von Ihrem Studienleiter"; -"Leave Study" = "Studie verlassen"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/TaskDetail.strings b/iosApp/iosApp/Resources/Strings/de.lproj/TaskDetail.strings deleted file mode 100644 index cf8c35216..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/TaskDetail.strings +++ /dev/null @@ -1,18 +0,0 @@ -/* - TaskDetail.strings - iosApp - - Created by Isabella Aigner on 22.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Task Detail" = "Aufgabendetails"; -"Participant Information" = "Informationen"; - -"Abort" = "Abbrechen"; -"Timeframe" = "Zeitrahmen:"; - -"Collected Datapoints" = "Gesammelte Datenpunkte"; -"Pause Observation" = "Aufzeichnung pausieren"; - - diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/AlertDialog.strings b/iosApp/iosApp/Resources/Strings/en.lproj/AlertDialog.strings deleted file mode 100644 index 9a69d2880..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/AlertDialog.strings +++ /dev/null @@ -1,14 +0,0 @@ -/* - AlertDialog.strings - iosApp - - Created by Jan Cortiel on 30.01.24. - Copyright © 2024 Redlink GmbH. All rights reserved. -*/ -"Required Permissions Were Not Granted" = "Required Permissions Were Not Granted"; -"This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?" = "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?"; -"Proceed to Settings" = "Proceed to Settings"; -"Proceed Without Granting Permissions" = "Proceed Without Granting Permissions"; - -"Notification Permissions Not Granted" = "Notification Permissions Not Granted"; -"We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks." = "We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks."; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/BluetoothConnection.strings b/iosApp/iosApp/Resources/Strings/en.lproj/BluetoothConnection.strings deleted file mode 100644 index 2f96fe344..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/BluetoothConnection.strings +++ /dev/null @@ -1,22 +0,0 @@ -/* - BluetoothConnection.strings - iosApp - - Created by Jan Cortiel on 26.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"External Device Setup" = "External Device Setup"; - -"Connected devices" = "Connected devices"; -"Discovered devices" = "Discovered devices"; -"Searching for devices" = "Searching for devices"; - -"No devices connected" = "No devices connected"; -"No devices found nearby" = "No devices found nearby"; - -"Bluetooth disabled! Please enable to use!" = "Bluetooth disabled! Please enable to use!"; - -"Some tasks in this study need certain bluetooth devices to be completed and only activate, once a certain device is connected. Please make sure to turn on and connect these devices" = "Some tasks in this study need certain bluetooth devices to be completed and only activate, once a certain device is connected. Please make sure to turn on and connect these devices"; -"You can connect to and disconnect from devices at any time: Info > Devices" = "You can connect to and disconnect from devices at any time: Info > Devices"; - diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/ConsentView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/ConsentView.strings deleted file mode 100644 index 6e1fda8e7..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/ConsentView.strings +++ /dev/null @@ -1,11 +0,0 @@ -/* - ConsentView.strings - iosApp - - Created by Jan Cortiel on 09.02.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"accept_button" = "Accept"; -"decline_button" = "Decline"; -"to_settings" = "Do you want to retry sending your settings? If the problem persists, please contact your study institute."; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/DashboardFilter.strings b/iosApp/iosApp/Resources/Strings/en.lproj/DashboardFilter.strings deleted file mode 100644 index f7586d9d7..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/DashboardFilter.strings +++ /dev/null @@ -1,31 +0,0 @@ -/* - NotificationView.strings - iosApp - - Created by Isabella Aigner on 12.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"acc-mobile-observation" = "Accelerometer"; -"gps-mobile-observation" = "GPS"; -"polar-verity-observation" = "Polar Verity"; -"question-observation" = "Question"; -"questionnaire-observation" = "Questionnaire"; - -"no_filter_activated" = "No Filter activated"; -"filter_acitvated" = "Filter activated"; -"reset_filter" = "Reset filter"; - -"All Items" = "All Items"; -"All types" = "All types"; - -"ENTIRE_TIME" = "Entire time"; -"ONE_MONTH" = "1 Month"; -"ONE_WEEK" = "1 Week"; -"TODAY_AND_TOMORROW" = "Today and Tomorrow"; - -"Select Time" = "Select Time"; -"Select Type" = "Select Type"; - -"type" = "Type"; -"type_plural" = "Types"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/DashboardView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/DashboardView.strings deleted file mode 100644 index 023d8c712..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/DashboardView.strings +++ /dev/null @@ -1,16 +0,0 @@ -/* - DashboardView.strings - iosApp - - Created by Julia Mayrhauser on 03.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"tasks_completed" = "Overall Progress"; -"study_currently_inactive" = "Study currently inactive"; -"open_filter_settings" = "Open Filter Settings"; -"schedule" = "Schedule"; - -"modules" = "Modules"; -"observations" = "Aufzeichnungen"; -"past_tasks" = "Past Tasks"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/Default.strings b/iosApp/iosApp/Resources/Strings/en.lproj/Default.strings deleted file mode 100644 index 9af9b9b88..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/Default.strings +++ /dev/null @@ -1,8 +0,0 @@ -/* - Default.strings - iosApp - - Created by Jan Cortiel on 06.02.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ -"App Version" = "App Version"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings b/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings deleted file mode 100644 index 5c3a87aca..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings +++ /dev/null @@ -1,22 +0,0 @@ -/* - Errors.strings - iosApp - - Created by Jan Cortiel on 22.05.24. - Copyright © 2024 Redlink GmbH. All rights reserved. -*/ - -"Access to Bluetooth not granted" = "Access to Bluetooth not granted"; -"Bluetooth is not enabled" = "Bluetooth is not enabled"; -"No polar device connected" = "No polar device connected"; -"Permission request pending until observation is about to start" = "Permission request pending until observation is about to start"; -"Accelerometer Sensor not available" = "Accelerometer Sensor not available"; -"Permission not granted to access Sensor recording service" = "Permission not granted to access Sensor recording service"; -"Accelerometer Recording is not available" = "Accelerometer Recording is not available"; -"Location Services not enabled" = "Location Services not enabled"; -"Permission not granted to access location of the device" = "Permission not granted to access location of the device"; -"errors" = "errors"; -"Cannot start Observation! Please make sure to enable bluetooth and connect all necessary devices!" = "Cannot start Observation! Please make sure to enable bluetooth and connect all necessary devices!"; -"Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!" = "Error continuing Observation! There was a connection issue to a bluetooth sensor. Please make sure to enable bluetooth and connect all necessary devices!"; -"Observation Error" = "Observation Error"; -"Heart-rate measurement feature unavailable" = "Heart-rate measurement feature unavailable"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/ExpandableText.strings b/iosApp/iosApp/Resources/Strings/en.lproj/ExpandableText.strings deleted file mode 100644 index ada4be57d..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/ExpandableText.strings +++ /dev/null @@ -1,10 +0,0 @@ -/* - ExpandableText.strings - iosApp - - Created by Isabella Aigner on 23.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Read Less" = "Read less"; -"Read More" = "Read more"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/Info.strings b/iosApp/iosApp/Resources/Strings/en.lproj/Info.strings deleted file mode 100644 index 31a4012b9..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/Info.strings +++ /dev/null @@ -1,18 +0,0 @@ -/* - Info.strings - iosApp - - Created by Isabella Aigner on 20.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Study Details" = "Study Details"; -"Running Observations" = "Running Observations"; -"Past Observations" = "Past Observations"; -"Devices" = "Devices"; -"Settings" = "Settings"; -"Leave Study" = "Leave Study"; - -"info_disclaimer" = "Feel free to contact us, when you encounter problems."; -"info_contact_title" = "Contact"; -"Participant" = "Participant"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/LimeSurvey.strings b/iosApp/iosApp/Resources/Strings/en.lproj/LimeSurvey.strings deleted file mode 100644 index 456f3461d..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/LimeSurvey.strings +++ /dev/null @@ -1,11 +0,0 @@ -/* - LimeSurvey.strings - iosApp - - Created by Jan Cortiel on 15.05.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"LimeSurvey" = "LimeSurvey"; -"Cancel" = "Cancel"; -"Done" = "Done"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings deleted file mode 100644 index 26af8bb2b..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings +++ /dev/null @@ -1,27 +0,0 @@ -/* - LoginViewLocalizable.strings - iosApp - - Created by Jan Cortiel on 06.02.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"login_welcome_title" = "Welcome to More"; - -"or" = "or"; -"login_button" = "Login"; - -"participation_key_entry" = "Enter Registration Token"; - -"study_endpoint_headling" = "Study Endpoint"; -"enter_study_endpoint" = "Enter Study Endpoint"; - -"enter_token" = "Enter Token"; -"token_error" = "Token Error"; - -"enter_study_url" = "Enter study URL"; - -"scan_qr_code" = "Scan QR Code"; -"camera_to_scan_qr_code" = "Open camera to scan a QR Code"; - - diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings b/iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings deleted file mode 100644 index 8b8bbc0cd..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings +++ /dev/null @@ -1,25 +0,0 @@ -/* - Navigation.strings - iosApp - - Created by Jan Cortiel on 15.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Dashboard" = "Dashboard"; -"Dashboard Filter" = "Dashboard Filter"; -"Notifications" = "Notifications"; -"Information" = "Information"; -"Settings" = "Settings"; -"Task Details" = "Task Detail"; -"Observation Details" = "Observation Details"; -"Observation Filter" = "Observation Filter"; -"Question Observation" = "Question"; -"Study Details" = "Study Details"; -"Running Observations" = "Running Observations"; -"Past Observations" = "Past Observations"; -"Devices" = "Devices"; -"Observation Errors" = "Observation Errors"; - -"Scan QR Code" = "Scan QR Code"; - diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/NotificationView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/NotificationView.strings deleted file mode 100644 index 6e6c350e1..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/NotificationView.strings +++ /dev/null @@ -1,13 +0,0 @@ -/* - NotificationView.strings - iosApp - - Created by Isabella Aigner on 12.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"no_filter_activated" = "No filter activated"; -"There are currently no notficiations to show" = "There are currently no notficiations to list"; -"All" = "All Notifications"; -"Unread" = "Not read"; -"Important" = "Only Important"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/ObservationDetails.strings b/iosApp/iosApp/Resources/Strings/en.lproj/ObservationDetails.strings deleted file mode 100644 index 9ea6e118f..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/ObservationDetails.strings +++ /dev/null @@ -1,10 +0,0 @@ -/* - ObservationDetails.strings - iosApp - - Created by Isabella Aigner on 19.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Observation Details" = "Observation Details"; -"Participant Information" = "Participant Information"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/ScheduleListView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/ScheduleListView.strings deleted file mode 100644 index 529927996..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/ScheduleListView.strings +++ /dev/null @@ -1,22 +0,0 @@ -/* - ScheduleListView.strings - iosApp - - Created by Julia Mayrhauser on 07.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"timeframe" = "Timeframe"; -"active_for" = "Active for"; - -"start_questionnaire" = "Start Questionnaire"; -"start_observation" = "Start Data Capture"; -"pause_observation" = "Pause Data Capture"; -"stop_observation" = "Stop Data Capture"; -"Start LimeSurvey" = "Start LimeSurvey"; - -"data_capture_running" = "Data Capture is running"; - -"No tasks to show" = "No tasks to show"; -"No tasks completed by now" = "No tasks completed by now"; -"No running tasks currently" = "No running tasks currently"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/SettingsView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/SettingsView.strings deleted file mode 100644 index 2dafaa1f8..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/SettingsView.strings +++ /dev/null @@ -1,28 +0,0 @@ -/* - SettingsView.strings - iosApp - - Created by Julia Mayrhauser on 08.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"settings_title" = "Settings"; - -"settings_text" = "All necessary permissions have been accepted. Permissions can be viewed here. If you want to withdraw permissions, you can only do so by withdrawing from the study."; -"refresh_study_config" = "Refresh Study Configuration"; - -"leave_study" = "Leave Study"; -"continue_study" = "Continue to participate"; -"withdraw" = "Withdraw from the study"; -"withdraw_study" = "Withdraw from the study"; -"withdraw_swipe" = "Swipe to withdraw"; - -"first_message" = "If you withdraw from the study, you will not be able to re-enter at a later date."; -"second_message" = "If you leave this study, you may not re-enter. Your participation will be cancelled and your data will be deleted from your mobile phone!"; - -"really_message" = "Do you really want to withdraw?"; -"sure_message" = "Are you sure you want to withdraw your participation?"; -"back_to_settings" = "I want to continue participating!"; - -"study_consent" = "Consent"; -"update_error" = "Error updating study!"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/SimpleQuestionObservation.strings b/iosApp/iosApp/Resources/Strings/en.lproj/SimpleQuestionObservation.strings deleted file mode 100644 index 28aa2d6de..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/SimpleQuestionObservation.strings +++ /dev/null @@ -1,19 +0,0 @@ -/* - SimpleQuestionObservation.strings - iosApp - - Created by Isabella Aigner on 20.04.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Answer" = "Answer"; -"select_answer" = "Please select an answer"; - -"submit" = "Submit"; -"close" = "Close"; -"return_to_dashboard" = "Return to Dashboard"; - -"answer_submitted" = "Your answer to the question has been successfully submitted!"; - -"thank_you" = "Thank You!"; -"thank_you_participation" = "Thank You for your participation!"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/StudyDetailsView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/StudyDetailsView.strings deleted file mode 100644 index 87c50ec93..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/StudyDetailsView.strings +++ /dev/null @@ -1,18 +0,0 @@ -/* - StudyDetailsView.strings - iosApp - - Created by Daniil Barkov on 22.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"study_duration" = "Study duration:"; -"participant_info" = "Participant information"; -"modules_string" = "Modules"; -"obs_modules" = "Observation Modules"; -"tasks_completed" = "Overall Progress"; - -"close_view" = "Close"; -"schedule_string" = "Schedule"; -"open_settings" = "Go to settings"; -"no_filter_activated" = "No Filter activated"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/StudyStates.strings b/iosApp/iosApp/Resources/Strings/en.lproj/StudyStates.strings deleted file mode 100644 index a1eb15820..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/StudyStates.strings +++ /dev/null @@ -1,17 +0,0 @@ -/* - StudyStates.strings - iosApp - - Created by Jan Cortiel on 25.07.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"The study configuration is currently updating" = "The study configuration is currently updating"; -"Study currently paused" = "Study currently paused"; -"This study is currently paused by the Study Operator and will be resumed shortly" = "This study is currently paused by the Study Operator and will be resumed shortly"; -"Please wait until this process is finished" = "Please wait until this process is finished"; -"Study was completed" = "Study was completed"; -"Thank you for your participation" = "Thank you for your participation"; -"Message by the Study Operator" = "Message by the Study Operator"; -"Leave Study" = "Leave Study"; - diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/TaskDetail.strings b/iosApp/iosApp/Resources/Strings/en.lproj/TaskDetail.strings deleted file mode 100644 index 5f03ce285..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/TaskDetail.strings +++ /dev/null @@ -1,16 +0,0 @@ -/* - TaskDetail.strings - iosApp - - Created by Isabella Aigner on 22.03.23. - Copyright © 2023 Redlink GmbH. All rights reserved. -*/ - -"Task Detail" = "Task Detail"; -"Participant Information" = "Participant Information"; - -"Abort" = "Abort"; -"Timeframe" = "Timeframe: "; - -"Collected Datapoints" = "Collected Datapoints"; -"Pause Observation" = "Pause Data Capture"; diff --git a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift index bfed049ce..6bd9052a7 100644 --- a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,93 +18,85 @@ import Foundation import PolarBleSdk import RxSwift import shared -import RealmSwift protocol BLEConnectorDelegate { func bleHasPower() } -typealias BluetoothDeviceList = [BluetoothDevice: CBPeripheral] +typealias BluetoothDeviceList = [BluetoothDeviceEntity: CBPeripheral] class IOSBluetoothConnector: NSObject, BluetoothConnector { + private let bleManager = BluetoothStateManagement.shared let specificBluetoothConnectors: KotlinMutableDictionary = KotlinMutableDictionary() - + + private var peripherals: Set = [] + private lazy var centralManager: CBCentralManager = { CBCentralManager(delegate: self, queue: nil) }() - private var discoveredDevices: BluetoothDeviceList = [:] - private var connectedDevices: BluetoothDeviceList = [:] - var scanning = false - var bluetoothState: BluetoothState = .off - + var observer: KotlinMutableSet = KotlinMutableSet() var delegate: BLEConnectorDelegate? - + private var scanningWithUnknownBLEState = false - + override init() { super.init() specificBluetoothConnectors.allValues - .forEach{ ($0 as? BluetoothConnector)?.observer.add(self) } + .forEach { + ($0 as? BluetoothConnector)?.observer.add(self) + } } - + func addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) { specificBluetoothConnectors[key] = connector } - func connect(device: BluetoothDevice) -> KotlinError? { + func connect(device: BluetoothDeviceEntity) -> KotlinError? { print("Connecting to device: \(device)") let (hasConnected, error) = connectToSpecificDevice(device: device) - if (hasConnected) { + if hasConnected { guard let error else { return nil } print(error) return error } - if let cbPeripheral = discoveredDevices[device] { + if let cbPeripheral = peripherals.first(where: { $0.identifier.uuidString == device.deviceId }) { centralManager.connect(cbPeripheral) return nil } else { return KotlinError(message: "Could not find device") } } - - func disconnect(device: BluetoothDevice) { - if let cbPeripheral = discoveredDevices[device] { + + func disconnect(device: BluetoothDeviceEntity) { + if let cbPeripheral = peripherals.first(where: { $0.identifier.uuidString == device.deviceId }) { centralManager.cancelPeripheralConnection(cbPeripheral) } } - + func addObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { - self.observer.add(bluetoothConnectorObserver) - if self.observer.count > 0 { - replayStates() - } + observer.add(bluetoothConnectorObserver) } - + func removeObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { - self.observer.remove(bluetoothConnectorObserver) - if self.observer.count == 0 { + observer.remove(bluetoothConnectorObserver) + if observer.count == 0 { stopScanning() } } - + func updateObserver(action: @escaping (BluetoothConnectorObserver) -> Void) { - observer.forEach{ + observer.forEach { if let observer = $0 as? BluetoothConnectorObserver { action(observer) } } } - - func replayStates() { - isScanning(boolean: scanning) - } - - + func scan() { - if !scanning { + if !bleManager.scanningValue { switch centralManager.state { case .unknown: print("Bluetooth state unknown") @@ -119,88 +111,87 @@ class IOSBluetoothConnector: NSObject, BluetoothConnector { print("Bluetooth state powered off") case .poweredOn: print("Bluetooth state powered on") - scanning = true + bleManager.isScanning(scan: true) centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) @unknown default: print("Bluetooth state unknown default") } } } - + func stopScanning() { - scanning = false centralManager.stopScan() + bleManager.isScanning(scan: false) } - - func isConnectingToDevice(bluetoothDevice: BluetoothDevice) { + + func isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.isConnectingToDevice(bluetoothDevice: bluetoothDevice) } } - - func didConnectToDevice(bluetoothDevice: BluetoothDevice) { + + func didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didConnectToDevice(bluetoothDevice: bluetoothDevice) } } - - func didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { + + func didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didDisconnectFromDevice(bluetoothDevice: bluetoothDevice) } } - - func didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { + + func didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didFailToConnectToDevice(bluetoothDevice: bluetoothDevice) } } - - func didDiscoverDevice(device: BluetoothDevice) { + + func didDiscoverDevice(device: BluetoothDeviceEntity) { updateObserver { $0.didDiscoverDevice(device: device) } } - - func removeDiscoveredDevice(device: BluetoothDevice) { + + func removeDiscoveredDevice(device: BluetoothDeviceEntity) { updateObserver { $0.removeDiscoveredDevice(device: device) } } - + func onBluetoothStateChange(bluetoothState: BluetoothState) { - self.bluetoothState = bluetoothState - updateObserver{ $0.onBluetoothStateChange(bluetoothState: bluetoothState)} + bleManager.setBluetoothState(active: bluetoothState == BluetoothState.on) + if bluetoothState == BluetoothState.off { + peripherals.removeAll() + } } - - private func connectToSpecificDevice(device: BluetoothDevice) -> (Bool, KotlinError?) { - if let connector = specificBluetoothConnectors - .first(where: {device.deviceName?.lowercased().contains(($0.key as? String)?.lowercased() ?? "") ?? false})?.value as? BluetoothConnector { + + private func connectToSpecificDevice(device: BluetoothDeviceEntity) -> (Bool, KotlinError?) { + if let connector = + specificBluetoothConnectors + .first(where: { device.deviceName?.lowercased().contains(($0.key as? String)?.lowercased() ?? "") ?? false })?.value as? BluetoothConnector + { return (true, connector.connect(device: device)) } return (false, nil) } - - private func disconnectFromSpecificDevice(device: BluetoothDevice) -> Bool { - if let connector = specificBluetoothConnectors - .first(where: {device.deviceName?.lowercased().contains(($0.key as? String)?.lowercased() ?? "") ?? false})?.value as? BluetoothConnector { + + private func disconnectFromSpecificDevice(device: BluetoothDeviceEntity) -> Bool { + if let connector = + specificBluetoothConnectors + .first(where: { device.deviceName?.lowercased().contains(($0.key as? String)?.lowercased() ?? "") ?? false })?.value as? BluetoothConnector + { connector.disconnect(device: device) return true } return false } - - func isScanning(boolean: Bool) { - scanning = boolean - updateObserver { - $0.isScanning(boolean: boolean) - } - } - + func close() { stopScanning() } - + deinit { stopScanning() } @@ -210,60 +201,63 @@ extension IOSBluetoothConnector: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { print("Connected to \(peripheral.description)") let device = peripheral.toBluetoothDevice() - self.connectedDevices[device] = peripheral - self.didConnectToDevice(bluetoothDevice: device) + peripherals.insert(peripheral) + didConnectToDevice(bluetoothDevice: device) } - + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral) { print("Disconnected from \(peripheral.identifier)") let device = peripheral.toBluetoothDevice() - self.connectedDevices.removeValue(forKey: device) - self.didConnectToDevice(bluetoothDevice: device) + bleManager.removeConnectedDeviceIds(deviceIds: [peripheral.identifier.uuidString]) + didDisconnectFromDevice(bluetoothDevice: device) } - + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral) { print("Did fail to connect to device: \(peripheral.identifier)") - self.connectedDevices.removeValue(forKey: peripheral.toBluetoothDevice()) - self.didFailToConnectToDevice(bluetoothDevice: peripheral.toBluetoothDevice()) + bleManager.removeConnectingDeviceIds(deviceIds: [peripheral.identifier.uuidString]) + didFailToConnectToDevice(bluetoothDevice: peripheral.toBluetoothDevice()) } - + func centralManagerDidUpdateState(_ central: CBCentralManager) { print("Manager state is powered on: \(central.state == .poweredOn)") if central.state == .poweredOn { delegate?.bleHasPower() if scanningWithUnknownBLEState { - self.scan() + scan() } } if central.state != .unknown { scanningWithUnknownBLEState = false } } - + + func resetAll() { + peripherals.removeAll() + } + internal func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { if peripheral.name != nil { let device = peripheral.toBluetoothDevice() if peripheral.state == .connected { - connectedDevices[device] = peripheral - self.didConnectToDevice(bluetoothDevice: device) + peripherals.insert(peripheral) + didConnectToDevice(bluetoothDevice: device) } else { - discoveredDevices[device] = peripheral - self.didDiscoverDevice(device: device) + peripherals.insert(peripheral) + didDiscoverDevice(device: device) } } } } extension IOSBluetoothConnector: CBPeripheralDelegate { - } extension CBPeripheral { - func toBluetoothDevice() -> BluetoothDevice { - BluetoothDevice.Companion().create( - deviceId: self.identifier.uuidString, - deviceName: self.name ?? "Unknown", - address: self.identifier.uuidString + func toBluetoothDevice() -> BluetoothDeviceEntity { + BluetoothDeviceEntity.companion.create( + deviceId: identifier.uuidString, + deviceName: name ?? "Unknown", + address: identifier.uuidString ) } } diff --git a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift index 9689f1555..27b8c14f5 100644 --- a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -21,88 +21,106 @@ import shared import UIKit class PolarConnector: NSObject, BluetoothConnector { - let deviceManager = BluetoothDeviceManager.shared + private let bleManager = BluetoothStateManagement.shared var specificBluetoothConnectors: KotlinMutableDictionary = KotlinMutableDictionary() - var bluetoothState: BluetoothState = .on var delegate: BLEConnectorDelegate? private var scanningWithUnknownBLEState = false - private var devicesSubscription: Disposable? = nil - - lazy var polarApi: PolarBleApi = { [weak self] in - var api = PolarBleApiDefaultImpl - .polarImplementation(DispatchQueue.main, - features: [ - .feature_hr, - .feature_battery_info, - .feature_device_info, - .feature_polar_offline_recording, - .feature_polar_online_streaming, - .feature_polar_sdk_mode, - .feature_polar_device_time_setup, - ]) - - if let self { - api.observer = self - api.polarFilter(true) - api.deviceInfoObserver = self - api.deviceFeaturesObserver = self - api.powerStateObserver = self - } - - return api - }() + private var devicesSubscription: Disposable? + + private(set) var polarApi: PolarBleApi + + override init() { + polarApi = PolarBleApiDefaultImpl.polarImplementation( + DispatchQueue.main, + features: [ + .feature_hr, + .feature_battery_info, + .feature_device_info, + .feature_polar_offline_recording, + .feature_polar_online_streaming, + .feature_polar_sdk_mode, + .feature_polar_device_time_setup, + ] + ) + + super.init() + + polarApi.observer = self + polarApi.polarFilter(true) + polarApi.deviceInfoObserver = self + polarApi.deviceFeaturesObserver = self + polarApi.powerStateObserver = self + +// Enable for Debug Logging +// polarApi.logger = self + + bleManager.setBluetoothState(active: polarApi.isBlePowered) + } var observer: KotlinMutableSet = KotlinMutableSet() - var scanning = false { - didSet { - isScanning(boolean: scanning) - } - } - func addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) { specificBluetoothConnectors[key] = connector } - func connect(device: BluetoothDevice) -> KotlinError? { - if let deviceId = device.deviceId { + func connect(device: BluetoothDeviceEntity) -> KotlinError? { + let performConnect = { () -> KotlinError? in do { - try polarApi.connectToDevice(deviceId) + self.stopScanning() + try self.polarApi.connectToDevice(device.deviceId) return nil } catch { print(error) return KotlinError(message: error.localizedDescription) } } - return KotlinError(message: "No valid device ID") + + if Thread.isMainThread { + return performConnect() + } else { + var result: KotlinError? + DispatchQueue.main.sync { + result = performConnect() + } + return result + } } - func disconnect(device: BluetoothDevice) { - if let deviceId = device.deviceId { + func disconnect(device: BluetoothDeviceEntity) { + let performConnect = { () in do { - try polarApi.disconnectFromDevice(deviceId) + self.stopScanning() + try self.polarApi.disconnectFromDevice(device.deviceId) } catch { print(error) } } + + if Thread.isMainThread { + performConnect() + } else { + Task { @MainActor in + performConnect() + } + } } func scan() { if CBManager.authorization == .restricted || CBManager.authorization == .denied { PermissionManager.openSensorPermissionDialog() - } else if !scanning && self.observer.count > 0 && bluetoothState == BluetoothState.on { + } else if !bleManager.scanningValue && observer.count > 0 && bleManager.bluetoothActiveValue && bleManager.devicesCurrentlyConnectingValue.isEmpty { print("Polar: Starting the scan...") - DispatchQueue.main.async { [weak self] in + bleManager.isScanning(scan: true) + Task { @MainActor [weak self] in if let self { - self.scanning = true self.devicesSubscription = self.polarApi.searchForDevice().subscribe(onNext: { device in - self.didDiscoverDevice(device: BluetoothDevice.fromPolarDevice(polarInfo: device)) + self.didDiscoverDevice(device: BluetoothDeviceEntity.fromPolarDevice(polarInfo: device)) }, onError: { error in print(error) - self.scanning = false + BluetoothStateManagement.shared.isScanning(scan: false) }, onDisposed: { - self.scanning = false + BluetoothStateManagement.shared.isScanning(scan: false) }) } } @@ -110,82 +128,66 @@ class PolarConnector: NSObject, BluetoothConnector { } func stopScanning() { - DispatchQueue.main.async { [weak self] in - if let self, self.scanning { + Task { @MainActor [weak self] in + if let self, BluetoothStateManagement.shared.scanningValue { print("Polar: Stopping the scan and cleaning up...") self.devicesSubscription?.dispose() - self.polarApi.cleanup() - self.scanning = false + self.devicesSubscription = nil + + BluetoothStateManagement.shared.isScanning(scan: false) } } } func close() { - } - func isConnectingToDevice(bluetoothDevice: BluetoothDevice) { + func isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.isConnectingToDevice(bluetoothDevice: bluetoothDevice) } } - func didConnectToDevice(bluetoothDevice: BluetoothDevice) { + func didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didConnectToDevice(bluetoothDevice: bluetoothDevice) } } - func didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { + func didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didDisconnectFromDevice(bluetoothDevice: bluetoothDevice) } + if bleManager.connectedDevicesValue.map({ $0.deviceName?.lowercased().contains("polar") }).isEmpty { + PolarStates.shared.hrFeatureReady(ready: false) + } } - func didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { + func didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didFailToConnectToDevice(bluetoothDevice: bluetoothDevice) } } - func removeDiscoveredDevice(device: BluetoothDevice) { + func removeDiscoveredDevice(device: BluetoothDeviceEntity) { updateObserver { $0.removeDiscoveredDevice(device: device) } } - func didDiscoverDevice(device: BluetoothDevice) { + func didDiscoverDevice(device: BluetoothDeviceEntity) { updateObserver { $0.didDiscoverDevice(device: device) } } - func isScanning(boolean: Bool) { - if boolean != scanning { - scanning = boolean - } - updateObserver { - $0.isScanning(boolean: boolean) - } - } - - func onBluetoothStateChange(bluetoothState: BluetoothState) { - self.bluetoothState = bluetoothState - updateObserver { - $0.onBluetoothStateChange(bluetoothState: bluetoothState) - } - } - func addObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { - self.observer.add(bluetoothConnectorObserver) - if self.observer.count > 0 { - replayStates() - } + observer.add(bluetoothConnectorObserver) } func removeObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { - self.observer.remove(bluetoothConnectorObserver) - if self.observer.count == 0 { + observer.remove(bluetoothConnectorObserver) + if observer.count == 0 { stopScanning() } } @@ -198,81 +200,58 @@ class PolarConnector: NSObject, BluetoothConnector { } } - func replayStates() { - print("Polar Connector: Replaying states...") - onBluetoothStateChange(bluetoothState: self.bluetoothState) - isScanning(boolean: scanning) + func resetAll() { + polarApi.cleanup() } - } extension PolarConnector: PolarBleApiObserver { func deviceDisconnected(_ identifier: PolarBleSdk.PolarDeviceInfo, pairingError: Bool) { print("Polar disconnected: \(identifier.name). Had paring error: \(pairingError)") - self.didDisconnectFromDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) + didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } func deviceConnecting(_ identifier: PolarBleSdk.PolarDeviceInfo) { print("Polar connecting: \(identifier.name)") - self.isConnectingToDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) + isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } func deviceConnected(_ identifier: PolarDeviceInfo) { print("Polar connected: \(identifier.name)") - self.didConnectToDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) + didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } } extension PolarConnector: PolarBleApiPowerStateObserver { func blePowerOn() { print("Polar power on") - self.onBluetoothStateChange(bluetoothState: .on) - Task { [weak self] in - self?.scan() - try await Task.sleep(nanoseconds: 1_000_000_000) - self?.stopScanning() - } + bleManager.setBluetoothState(active: true) } func blePowerOff() { print("Polar power off") - self.onBluetoothStateChange(bluetoothState: .off) - deviceManager.foreachConnectedDevice { [weak self] device in - self?.didDisconnectFromDevice(bluetoothDevice: device) - } - deviceManager.foreachDiscoveredDevice { [weak self] device in - self?.removeDiscoveredDevice(device: device) - } - - stopScanning() + bleManager.setBluetoothState(active: false) } } extension PolarConnector: PolarBleApiDeviceFeaturesObserver { - // Deprecated - func hrFeatureReady(_ identifier: String) { - print("HR ready!") - } - - // Deprecated - func ftpFeatureReady(_ identifier: String) { - print("FTP Feature ready!") - } - - // Deprecated - func streamingFeaturesReady(_ identifier: String, streamingFeatures: Set) { - print("Stream Features ready!") - } - func bleSdkFeatureReady(_ identifier: String, feature: PolarBleSdk.PolarBleSdkFeature) { if feature == .feature_hr { - print("HR ready") - PolarVerityHeartRateObservation.setHRFeature(state: true) + print("Polar HR Feature ready!") + PolarStates.shared.hrFeatureReady(ready: true) } } } extension PolarConnector: PolarBleApiDeviceInfoObserver { + func batteryChargingStatusReceived(_ identifier: String, chargingStatus: PolarBleSdk.BleBasClient.ChargeState) { + print("Battery charging status received by \(identifier): \(chargingStatus)") + } + + func disInformationReceivedWithKeysAsStrings(_ identifier: String, key: String, value: String) { + print("DisinformationReceivedWithKeysAsString by \(identifier): \(key); \(value)") + } + func batteryLevelReceived(_ identifier: String, batteryLevel: UInt) { print("Battery level for \(identifier): \(batteryLevel)") } @@ -280,7 +259,6 @@ extension PolarConnector: PolarBleApiDeviceInfoObserver { func disInformationReceived(_ identifier: String, uuid: CBUUID, value: String) { print("Disinformation received by \(identifier): \(uuid); \(value)") } - } extension PolarConnector: PolarBleApiLogger { diff --git a/iosApp/iosApp/Services/CameraPermissionManager.swift b/iosApp/iosApp/Services/CameraPermissionManager.swift new file mode 100644 index 000000000..944a88e9c --- /dev/null +++ b/iosApp/iosApp/Services/CameraPermissionManager.swift @@ -0,0 +1,13 @@ +// +// Untitled.swift +// iosApp +// +// Created by Isabella Aigner on 04.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +enum CameraPermissionStatus: String { + case idle = "Not Determined" + case approved = "Access Granted" + case denied = "Access Denied" +} diff --git a/iosApp/iosApp/Services/DataUploadManager.swift b/iosApp/iosApp/Services/DataUploadManager.swift index 5b0a9f74a..25425cd65 100644 --- a/iosApp/iosApp/Services/DataUploadManager.swift +++ b/iosApp/iosApp/Services/DataUploadManager.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -19,42 +19,48 @@ import shared class DataUploadManager { private let semaphore = Semaphore() private var currentlyUploading = false - + func uploadData(completion: @escaping (Bool) -> Void) { if !currentlyUploading { currentlyUploading = true - DispatchQueue.global(qos: .background).async { [weak self] in + + Task(priority: .background) { [weak self] in print("Fetching Data Bulk...") - let observationDataRepository = ObservationDataRepository() - observationDataRepository.allAsBulk { dataBulk in - if let dataBulk, !dataBulk.dataPoints.isEmpty { + let observationDataRepository = await ObservationDataRepositoryImpl(appDatabase: AppDelegate.database) + do { + if let dataBulk = try await observationDataRepository.allAsBulk(), !dataBulk.dataPoints.isEmpty { print("Sending data to backend...") - AppDelegate.shared.networkService.iosSendData(data: dataBulk) { pair in - if let error = pair.second { - print("Error: \(error)") - self?.currentlyUploading = false - completion(false) - } else if let self, let idSet = pair.first as? Set { - print("Sent data! Deleting local data...") - observationDataRepository.deleteAllWithId(idSet: idSet) - print("Deleted data!") - self.currentlyUploading = false - completion(true) - } else { - print("Error!") - self?.currentlyUploading = false - completion(false) - } + let pair = try await AppDelegate.shared.networkService.sendData(data: dataBulk) + if let error = pair.second { + print("Error: \(error)") + self?.currentlyUploading = false + completion(false) + } else if let self, let idSet = pair.first as? Set { + print("Sent data! Deleting local data...") + try await observationDataRepository.deleteAllWithId(idSet: idSet) + print("Deleted data!") + self.currentlyUploading = false + completion(true) + } else { + print("Error!") + self?.currentlyUploading = false + completion(false) } } else { print("No data to send!") self?.currentlyUploading = false completion(true) } + + } catch { + print("Error: \(error)") + self?.currentlyUploading = false + completion(false) } } } } + func close() { print("Closed!") } diff --git a/iosApp/iosApp/Services/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift index 1614aa352..7eacfa05c 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -7,17 +7,16 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import FirebaseMessaging import Foundation import shared -import Realm import UserNotifications -import FirebaseMessaging class FCMService: NSObject { func register() { @@ -36,29 +35,50 @@ extension FCMService: MessagingDelegate { } extension FCMService: UNUserNotificationCenterDelegate { - @MainActor func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let content = notification.request.content let data = content.userInfo.notNilStringDictionary() if let msgId = data[NotificationManager.companion.MSG_ID] { - AppDelegate.shared.notificationManager.storeAndHandleNotification(shared: AppDelegate.shared, key: msgId, title: content.title, body: content.body, priority: 1, read: false, data: data, displayNotification: false) + AppDelegate.shared.notificationManager.storeAndHandleNotification(key: msgId, title: content.title, body: content.body, priority: 1, read: false, completed: false, data: data, displayNotification: false) + } + do { + try await AppDelegate.shared.observationService.scheduleObservationReminder() + } catch { + Napier.e("Error while updating observation reminder: \(error)") } - return [.sound,.badge, .banner] + do { + try await AppDelegate.shared.observationService.scheduleObservationReminder() + } catch { + Napier.e("Error while updating observation reminder: \(error)") + } + return [.sound, .badge, .banner] } - + @MainActor func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - let data = response.notification.request.content.userInfo.notNilStringDictionary() + let content = response.notification.request.content + let data = content.userInfo.notNilStringDictionary() let msgId = data[NotificationManager.companion.MSG_ID] ?? response.notification.request.identifier - if let deepLinkString = data[NotificationManager.companion.DEEP_LINK] { - if let deepLink = URL(string: deepLinkString) { - AppDelegate.navigationScreenHandler.openWithDeepLink(url: deepLink, notificationId: msgId) + + // Use the shared manager to store and handle the notification interaction, including deep link modification. + AppDelegate.shared.notificationManager.storeAndHandleNotificationInteraction( + key: msgId, + title: content.title, + body: content.body, + priority: 1, + read: true, + completed: false, + data: data, + ) { (actionHandler, deepLinkData) in + if let deepLinkData { + switch actionHandler { + case NotificationActionHandler.deeplink: + AppDelegate.navigationScreenHandler.openRoute(to: deepLinkData) + default: + break + } } - } else { - AppDelegate.navigationScreenHandler.clearViews() - AppDelegate.navigationScreenHandler.tagState = 1 - AppDelegate.shared.notificationManager.markNotificationAsRead(notificationId: msgId) } } } @@ -66,7 +86,7 @@ extension FCMService: UNUserNotificationCenterDelegate { extension Dictionary where Key == AnyHashable { func notNilStringDictionary() -> [String: String] { var data = [String: String]() - + for (key, value) in self { if let value = value as? String { data[String(describing: key)] = value @@ -75,3 +95,4 @@ extension Dictionary where Key == AnyHashable { return data } } + diff --git a/iosApp/iosApp/Services/LocalPushNotificationService.swift b/iosApp/iosApp/Services/LocalPushNotificationService.swift index 031ac81f2..58e9d4307 100644 --- a/iosApp/iosApp/Services/LocalPushNotificationService.swift +++ b/iosApp/iosApp/Services/LocalPushNotificationService.swift @@ -21,6 +21,7 @@ import UserNotifications class LocalPushNotifications: LocalNotificationListener { private static let notificationCountKey = "notification_count" + func clearNotifications() { UNUserNotificationCenter.current().removeAllDeliveredNotifications() } @@ -40,7 +41,7 @@ class LocalPushNotifications: LocalNotificationListener { } Messaging.messaging().token { token, error in if let error { - print("Error fetching FCM registration token: \(error)") + Napier.e("Error fetching FCM registration token: \(error)") } else if let token { onCompletion(token) } @@ -52,45 +53,110 @@ class LocalPushNotifications: LocalNotificationListener { func deleteFCMToken() { Messaging.messaging().deleteToken { error in if let error = error { - print("Erro rdeleting FCM registration token: \(error)") + Napier.e("Erro rdeleting FCM registration token: \(error)") } } } - func displayNotification(notification: NotificationSchema) { + func displayNotification(notification: NotificationEntity, badgeCount: Int32) { if let title = notification.title, let body = notification.notificationBody { - requestLocalNotification(identifier: notification.notificationId, title: title, subtitle: body) + let content = UNMutableNotificationContent() + content.title = String(localized: .init(title), bundle: .main) + content.subtitle = NotificationTextLocalization.shared.localizeToStringDesc(raw: body)?.localized() ?? String(localized: .init(body), bundle: .main) + + if let deepLink = notification.deepLink { + content.userInfo[NotificationManager.companion.DEEP_LINK] = deepLink + } + + content.sound = .default + if let scheduledDate = notification.timestamp?.toInt64().toDate(), Date.now < scheduledDate { + content.badge = NSNumber(value: 1) + + requestLocalNotification(identifier: notification.notificationId, content: content, on: scheduledDate) + } else { + requestLocalNotification(identifier: notification.notificationId, content: content) + } } } - - func updateBadgeCount(count: Int32) { - AppDelegate.appGroupUserDefaults?.set(Int(count), forKey: LocalPushNotifications.notificationCountKey) - if #available(iOS 16.0, *) { - UNUserNotificationCenter.current().setBadgeCount(Int(count)) { error in - print(error ?? "Error setting badge count") + + func clearScheduledNotifications(notifications: [NotificationEntity]) { + let center = UNUserNotificationCenter.current() + let identifiers = notifications.map { $0.notificationId } + + if identifiers.isEmpty { + center.getPendingNotificationRequests { requests in + let allIDs = requests.map { $0.identifier } + if !allIDs.isEmpty { + center.removePendingNotificationRequests(withIdentifiers: allIDs) + } + center.removeAllDeliveredNotifications() + + center.getPendingNotificationRequests { remaining in + if remaining.isEmpty { + Napier.i("All pending notifications cleared") + } else { + Napier.w("Pending notifications still present after clear: \(remaining.map { $0.identifier })") + } + } } } else { - UIApplication.shared.applicationIconBadgeNumber = Int(count) + center.removePendingNotificationRequests(withIdentifiers: identifiers) + center.removeDeliveredNotifications(withIdentifiers: identifiers) + + center.getPendingNotificationRequests { remaining in + let stillPending = remaining.map { $0.identifier }.filter { identifiers.contains($0) } + if stillPending.isEmpty { + Napier.i("Cleared scheduled notifications: \(identifiers)") + } else { + Napier.w("Some notifications still pending after clear: \(stillPending)") + } + } + } + } + + + func updateBadgeCount(count: Int32) { + setAppGroupNotificiationCount(Int(count)) + UNUserNotificationCenter.current().setBadgeCount(Int(count)) { error in + Napier.e(error?.localizedDescription ?? "Error setting badge count") } } + + - private func requestLocalNotification(identifier: String, title: String, subtitle: String, timeInterval: TimeInterval = 0, repeates: Bool = false) { - let content = UNMutableNotificationContent() - content.title = title - content.subtitle = subtitle - content.sound = .default + private func requestLocalNotification(identifier: String, content: UNMutableNotificationContent, timeInterval: TimeInterval = 0, repeats: Bool = false) { let adjustedTimeInterval = max(timeInterval, 1) - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: adjustedTimeInterval, repeats: repeates) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: adjustedTimeInterval, repeats: repeats) let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in if let error = error { - print("Error adding notification: \(error)") + Napier.e("Error adding notification\(identifier): \(error)") } else { - print("Local Notification requested!") + Napier.i("Local Notification \(identifier) requested!") } } } + + private func requestLocalNotification(identifier: String, content: UNMutableNotificationContent, on date: Date, repeats: Bool = false) { + let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: repeats) + + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + Napier.e("Error adding scheduled notification \(identifier): \(error)") + } else { + Napier.i("Scheduled Local Notification \(identifier) for \(date)") + } + } + } + + private func setAppGroupNotificiationCount(_ count: Int) { + AppDelegate.appGroupUserDefaults?.set(count, forKey: LocalPushNotifications.notificationCountKey) + } } + diff --git a/iosApp/iosApp/Services/PermissionManager.swift b/iosApp/iosApp/Services/PermissionManager.swift index 93ffde37f..8224b984a 100644 --- a/iosApp/iosApp/Services/PermissionManager.swift +++ b/iosApp/iosApp/Services/PermissionManager.swift @@ -14,13 +14,14 @@ // import AVFoundation +import AppTrackingTransparency import CoreBluetooth import CoreLocation import CoreMotion import Foundation -import shared import UIKit import UserNotifications +import shared enum PermissionStatus { case accepted, declined, requesting, non @@ -82,14 +83,17 @@ class PermissionManager: NSObject, ObservableObject { if notificationStatus == .accepted { AppDelegate.registerForNotifications() } else { - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "Notification Permissions Not Granted", message: "We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks.", positiveTitle: "Proceed to Settings", negativeTitle: "Proceed Without Granting Permissions", onPositive: { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - AlertController.shared.closeAlertDialog() - }, onNegative: { - AlertController.shared.closeAlertDialog() - })) + AlertController.shared.openAlertDialog( + model: AlertDialogModel.companion.fromStrings( + title: "Notification Permissions Not Granted", + message: "We request permission to send you push notifications. This assists in maintaining the study's current status at all times and serves as a reminder for your tasks.", + confirmLabel: "Proceed to Settings", + cancelLabel: "Proceed Without Granting Permissions", + onConfirm: { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + })) } requestPermission() } @@ -104,6 +108,14 @@ class PermissionManager: NSObject, ObservableObject { } } + private var appTrackingStatus: PermissionStatus = .non { + didSet { + if appTrackingStatus.userResponded() { + requestPermission() + } + } + } + private var bluetoothStatus: PermissionStatus = .non { didSet { if bluetoothStatus.userResponded() { @@ -116,7 +128,6 @@ class PermissionManager: NSObject, ObservableObject { override init() { super.init() - setPermissionValues(observationPermissions: AppDelegate.shared.observationFactory.studySensorPermissions()) } private func requestGpsAuthorization(always: Bool = true) { @@ -125,7 +136,8 @@ class PermissionManager: NSObject, ObservableObject { locationManager.requestAlwaysAuthorization() gpsStatus = .requesting } else if locationManager.authorizationStatus == CLAuthorizationStatus.denied - || locationManager.authorizationStatus == CLAuthorizationStatus.restricted || locationManager.accuracyAuthorization != .fullAccuracy { + || locationManager.authorizationStatus == CLAuthorizationStatus.restricted || locationManager.accuracyAuthorization != .fullAccuracy + { gpsStatus = .declined } else { gpsStatus = .accepted @@ -212,11 +224,36 @@ class PermissionManager: NSObject, ObservableObject { } private func requestPermissionCamera() { - AVCaptureDevice.requestAccess(for: .video, completionHandler: { accessGranted in - DispatchQueue.main.async { - self.cameraPermissionGranted = accessGranted + AVCaptureDevice.requestAccess( + for: .video, + completionHandler: { accessGranted in + DispatchQueue.main.async { + self.cameraPermissionGranted = accessGranted + } + }) + } + + func requestAppTrackingAuthorization() { + let status = ATTrackingManager.trackingAuthorizationStatus + if status == .notDetermined { + ATTrackingManager.requestTrackingAuthorization { [weak self] newStatus in + Task { @MainActor in + if newStatus == .authorized { + Napier.event(.appTrackingAccepted) + self?.appTrackingStatus = .accepted + } else { + Napier.event(.appTrackingDeclined) + self?.appTrackingStatus = .declined + } + } } - }) + } else if status == .authorized { + Napier.event(.appTrackingAccepted) + appTrackingStatus = .accepted + } else { + Napier.event(.appTrackingDeclined) + appTrackingStatus = .declined + } } func anyNeededPermissionDeclined() -> Bool { @@ -228,12 +265,14 @@ class PermissionManager: NSObject, ObservableObject { cameraStatus = observationPermissions.contains("camera") ? .requesting : .non cmSensorStatus = observationPermissions.contains("cmsensorrecorder") ? .requesting : .non bluetoothStatus = observationPermissions.contains("bluetoothAlways") ? .requesting : .non + appTrackingStatus = observationPermissions.contains("appTracking") ? .requesting : .non } func requestPermission(permissionRequest: Bool = false) { print("Requesting Permissions") if permissionRequest { permissionsRequested = true + setPermissionValues(observationPermissions: AppDelegate.shared.observationFactory.studySensorPermissions()) } if permissionsRequested, let observer { if notificationStatus == .requesting && !notificationStatus.userResponded() { @@ -244,6 +283,8 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = checkBluetoothAuthorization() } else if cmSensorStatus == .requesting && !cmSensorStatus.userResponded() { requestCMSensorRecorder() + } else if appTrackingStatus == .requesting && !appTrackingStatus.userResponded() { + requestAppTrackingAuthorization() } else { print("Continuing") observer.accepted() @@ -257,6 +298,7 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = bluetoothStatus.resetStatus() cmSensorStatus = cmSensorStatus.resetStatus() cameraStatus = cameraStatus.resetStatus() + appTrackingStatus = appTrackingStatus.resetStatus() permissionsRequested = false } @@ -266,6 +308,7 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = .non cmSensorStatus = .non cameraStatus = .non + appTrackingStatus = .non permissionsRequested = false } } @@ -278,23 +321,28 @@ extension PermissionManager: CLLocationManagerDelegate { gpsStatus = .accepted } } - + private static var permissionAlertOpenedThisSession = false + static func resetPermissionAlertFlag() { + permissionAlertOpenedThisSession = false + } + static func openSensorPermissionDialog() { if permissionAlertOpenedThisSession { return } - + permissionAlertOpenedThisSession = true - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "Required Permissions Were Not Granted", message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", positiveTitle: "Proceed to Settings", negativeTitle: "Proceed Without Granting Permissions", onPositive: { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - AlertController.shared.closeAlertDialog() - }, onNegative: { - AlertController.shared.closeAlertDialog() - })) + AlertController.shared.openAlertDialog( + model: AlertDialogModel.companion.fromStrings( + title: "Required Permissions Were Not Granted", message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", confirmLabel: "Proceed to Settings", cancelLabel: "Proceed Without Granting Permissions", + onConfirm: { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + }, onDecline: nil)) + } } diff --git a/iosApp/iosApp/Services/Semaphore.swift b/iosApp/iosApp/Services/Semaphore.swift index 80321ebe6..f8a9a5afd 100644 --- a/iosApp/iosApp/Services/Semaphore.swift +++ b/iosApp/iosApp/Services/Semaphore.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import Foundation actor Semaphore { private var active = false - + func tryLock() -> Bool { if !active { active = true @@ -25,7 +25,7 @@ actor Semaphore { } return false } - + func unlock() { active = false } diff --git a/iosApp/iosApp/Services/TaskScheduleService.swift b/iosApp/iosApp/Services/TaskScheduleService.swift deleted file mode 100644 index 67c56bffe..000000000 --- a/iosApp/iosApp/Services/TaskScheduleService.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// TaskScheduleService.swift -// More -// -// Created by Jan Cortiel on 17.04.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import Foundation -import shared - -class TaskScheduleService { - private let scheduleRepository = ScheduleRepository() - private var timer: Timer? - - func startUpdateTimer() { - timer?.invalidate() - update() - } - - private func scheduleNextUpdate() { - Task { @MainActor in - if let nextSchedule = try await self.scheduleRepository.getNextSchedule()?.int64Value { - let now = Int64(Date().timeIntervalSince1970) - if now < nextSchedule { - timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(nextSchedule - now), repeats: false, block: { [weak self] timer in - if let self { - self.update() - } else { - timer.invalidate() - } - }) - } else { - stopUpdates() - } - } else { - stopUpdates() - } - } - } - - private func update() { - AppDelegate.shared.updateTaskStates() - self.scheduleNextUpdate() - } - - private func stopUpdates() { - timer?.invalidate() - timer = nil - } - - deinit { - stopUpdates() - } -} diff --git a/iosApp/iosApp/Style/MoreAnimation.swift b/iosApp/iosApp/Style/MoreAnimation.swift index 45aafe305..285bdc6e8 100644 --- a/iosApp/iosApp/Style/MoreAnimation.swift +++ b/iosApp/iosApp/Style/MoreAnimation.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -16,18 +16,16 @@ import SwiftUI extension Animation { - static let more = MoreAnimation() - + struct MoreAnimation { let foldingAnimation = Animation.easeInOut(duration: .more.medium) } } extension Double { - static let more = MoreAnimationSpeed() - + struct MoreAnimationSpeed { let slow = 0.5 let medium = 0.3 diff --git a/iosApp/iosApp/Style/MoreBorder.swift b/iosApp/iosApp/Style/MoreBorder.swift index 2858d1d43..fc0451677 100644 --- a/iosApp/iosApp/Style/MoreBorder.swift +++ b/iosApp/iosApp/Style/MoreBorder.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import Foundation extension CGFloat { static let moreBorder = MoreBorder() - + struct MoreBorder { let cornerRadius: CGFloat = 4 let lineWidth: CGFloat = 2 diff --git a/iosApp/iosApp/Style/MoreColor.swift b/iosApp/iosApp/Style/MoreColor.swift index 7f0155897..85725a63c 100644 --- a/iosApp/iosApp/Style/MoreColor.swift +++ b/iosApp/iosApp/Style/MoreColor.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,31 +17,31 @@ import SwiftUI extension Color { static let more = Color.MoreColor() - + struct MoreColor { let primaryDark = Color("PrimaryDark") let primary = Color("Primary") let primaryMedium = Color("PrimaryMedium") let primaryLight200 = Color("PrimaryLight200") let primaryLight = Color("PrimaryLight") - + let secondary = Color("Secondary") let secondaryMedium = Color("SecondaryMedium") let secondaryLight = Color("SecondaryLight") - + let textDefault = Color("Secondary") let textInactive = Color("SecondaryMedium") - + let important = Color("Important") let importantMedium = Color("ImportantMedium") let importantLight = Color("ImportantLight") - + let approved = Color("Approved") let approvedMedium = Color("ApprovedMedium") let approvedLight = Color("ApprovedLight") - + let white = Color("White") - + // special elements let divider = Color("PrimaryLight") let mainBackground = Color("SecondaryLight") diff --git a/iosApp/iosApp/Style/MoreContainer.swift b/iosApp/iosApp/Style/MoreContainer.swift index dba4aeaef..3e29aeab1 100644 --- a/iosApp/iosApp/Style/MoreContainer.swift +++ b/iosApp/iosApp/Style/MoreContainer.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,19 +17,19 @@ import SwiftUI extension EdgeInsets { static let moreContainerEdgeInsets = MoreContainerStyle() - + struct MoreContainerStyle { let vertical = EdgeInsets(top: .moreContainerPadding.verticalPadding, leading: 0, bottom: .moreContainerPadding.verticalPadding, trailing: 0) let top = EdgeInsets(top: .moreContainerPadding.verticalPadding, leading: 0, bottom: 0, trailing: 0) let bottom = EdgeInsets(top: 0, leading: 0, bottom: .moreContainerPadding.verticalPadding, trailing: 0) - + let loginVertical = EdgeInsets(top: .moreContainerPadding.verticalLoginPadding, leading: 0, bottom: .moreContainerPadding.verticalLoginPadding, trailing: 0) } } extension CGFloat { static let moreContainerPadding = MoreContainerPadding() - + struct MoreContainerPadding { let verticalPadding: CGFloat = 24 let verticalLoginPadding: CGFloat = 50 diff --git a/iosApp/iosApp/Style/MoreFont.swift b/iosApp/iosApp/Style/MoreFont.swift index 3d5e1667f..161d20fb7 100644 --- a/iosApp/iosApp/Style/MoreFont.swift +++ b/iosApp/iosApp/Style/MoreFont.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,7 +18,7 @@ import SwiftUI extension Font { static let more = Font.More() - + struct More { let title = Font.title let title2 = Font.title2 diff --git a/iosApp/iosApp/Style/MoreFontWeight.swift b/iosApp/iosApp/Style/MoreFontWeight.swift index b3204258f..9ab04b484 100644 --- a/iosApp/iosApp/Style/MoreFontWeight.swift +++ b/iosApp/iosApp/Style/MoreFontWeight.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,7 +18,7 @@ import SwiftUI extension Font.Weight { static let more = Font.Weight.More() - + struct More { let title = Font.Weight.bold let error = Font.Weight.semibold diff --git a/iosApp/iosApp/Style/MoreFrame.swift b/iosApp/iosApp/Style/MoreFrame.swift index 48ef89632..96f4be1c4 100644 --- a/iosApp/iosApp/Style/MoreFrame.swift +++ b/iosApp/iosApp/Style/MoreFrame.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import Foundation extension CGFloat { static let moreFrameStyle = MoreFrameStyle() - + struct MoreFrameStyle { let minWidth: CGFloat = 300 let buttonMinWidth: CGFloat = 100 diff --git a/iosApp/iosApp/Style/MoreImage.swift b/iosApp/iosApp/Style/MoreImage.swift index dbcc0ad48..66924b2d0 100644 --- a/iosApp/iosApp/Style/MoreImage.swift +++ b/iosApp/iosApp/Style/MoreImage.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import SwiftUI extension Image { static let more = MoreImage() - + struct MoreImage { let toggleFoldView = Image(systemName: "chevron.down") } diff --git a/iosApp/iosApp/Style/MoreListStyleEdgeInsets.swift b/iosApp/iosApp/Style/MoreListStyleEdgeInsets.swift index d7fd10b1f..d98ae6448 100644 --- a/iosApp/iosApp/Style/MoreListStyleEdgeInsets.swift +++ b/iosApp/iosApp/Style/MoreListStyleEdgeInsets.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import SwiftUI extension EdgeInsets { static let moreListStyleEdgeInsets = MoreListStyleEdgeInsets() - + struct MoreListStyleEdgeInsets { let listItem = EdgeInsets(top: .moreListStylePadding.listItemVertical, leading: 0, bottom: .moreListStylePadding.listItemVertical, trailing: 0) } @@ -25,7 +25,7 @@ extension EdgeInsets { extension CGFloat { static let moreListStylePadding = MoreListStylePadding() - + struct MoreListStylePadding { let listItemVertical: CGFloat = 16 } diff --git a/iosApp/iosApp/Style/MoreTextFieldStyle.swift b/iosApp/iosApp/Style/MoreTextFieldStyle.swift index f40a3835c..219f46079 100644 --- a/iosApp/iosApp/Style/MoreTextFieldStyle.swift +++ b/iosApp/iosApp/Style/MoreTextFieldStyle.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,9 +18,8 @@ import SwiftUI extension CGFloat { static let moreTextFieldPadding = MoreTextFieldStyle() - + struct MoreTextFieldStyle { let textFieldInnerPadding: CGFloat = 12 - } } diff --git a/iosApp/iosApp/Style/MoreTextStyle.swift b/iosApp/iosApp/Style/MoreTextStyle.swift index 2fa845fff..f2e5573d8 100644 --- a/iosApp/iosApp/Style/MoreTextStyle.swift +++ b/iosApp/iosApp/Style/MoreTextStyle.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import SwiftUI extension Font { static let moreFont = MoreFontStyle() - + struct MoreFontStyle { let inactiveText = Font.caption } diff --git a/iosApp/iosApp/Utils/Napier.swift b/iosApp/iosApp/Utils/Napier.swift new file mode 100644 index 000000000..cae37a7f7 --- /dev/null +++ b/iosApp/iosApp/Utils/Napier.swift @@ -0,0 +1,56 @@ +// +// Napier.swift +// BlendedCare +// +// Created by Jan Cortiel on 29.01.26. +// Copyright © 2026 Redlink GmbH. All rights reserved. +// + +import Foundation +import shared + +enum Napier { + static func i( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { + let tag = "\(file)#\(function):\(line)" + KMMLogger.shared.i(tag: tag, message: message) + } + + static func d( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { + let tag = "\(file)#\(function):\(line)" + KMMLogger.shared.d(tag: tag, message: message) + } + + static func w( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { + let tag = "\(file)#\(function):\(line)" + KMMLogger.shared.w(tag: tag, message: message) + } + + static func e( + _ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line + ) { + let tag = "\(file)#\(function):\(line)" + KMMLogger.shared.e(tag: tag, message: message) + } + + static func event(_ event: LogEvent, message: String? = nil) { + KMMLogger.shared.event(event: event, message: message) + } +} diff --git a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift index 8255bf6bf..da6dffaff 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift @@ -7,21 +7,20 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct BluetoothConnectionView: View { - @StateObject var viewModel: BluetoothConnectionViewModel + @StateObject private var viewModel = BluetoothConnectionViewModel() @Binding var viewOpen: Bool var showAsSeparateView: Bool = false - private let bluetoothStrings = "BluetoothConnection" var body: some View { VStack { if showAsSeparateView { @@ -41,25 +40,24 @@ struct BluetoothConnectionView: View { } ScrollView { LazyVStack(alignment: .leading) { - Title(titleText: "External Device Setup".localize(withComment: "External Device Setup Screen", useTable: bluetoothStrings)) - BasicText(text: "\("Some tasks in this study need certain bluetooth devices to be completed and only activate, once a certain device is connected. Please make sure to turn on and connect these devices".localize(withComment: "Bluetooth necessity description", useTable: bluetoothStrings)):", color: Color.more.secondary) + Title(titleText: "External Device Setup") + BasicText(text: "\(String(localized: "Some tasks in this study need certain bluetooth devices to be completed and only activate, once a certain device is connected. Please make sure to turn on and connect these devices")):", color: Color.more.secondary) .padding(.vertical, 8) - + ForEach(viewModel.neededDevices, id: \.self) { device in SectionHeading(sectionTitle: "- \(device)") } .padding(.bottom, 8) - + if showAsSeparateView { - BasicText(text: "You can connect to and disconnect from devices at any time: Info > Devices".localize(withComment: "Connection tutorial", useTable: bluetoothStrings), color: Color.more.secondary) + BasicText(text: "You can connect to and disconnect from devices at any time: Info > Devices", color: Color.more.secondary) .padding(.top, 8) } - - if viewModel.bluetoothPower == .on { - Section(header: SectionHeading(sectionTitle: "Connected devices".localize(withComment: "Connected device section", useTable: bluetoothStrings))) { + if viewModel.bluetoothPower { + Section(header: SectionHeading(sectionTitle: "Connected devices")) { if viewModel.connectedDevices.isEmpty { - EmptyListView(text: "\(String.localize(forKey: "No devices connected", withComment: "No devices connected", inTable: bluetoothStrings))!") + EmptyListView(text: "\("No devices connected")!") } else { ForEach(viewModel.connectedDevices, id: \.self.address) { device in if let deviceName = device.deviceName { @@ -89,10 +87,10 @@ struct BluetoothConnectionView: View { } } } - + Section(header: SectionHeading(sectionTitle: "Discovered devices")) { if viewModel.discoveredDevices.isEmpty { - EmptyListView(text: "\(String.localize(forKey: "No devices found nearby", withComment: "No devices found nearby", inTable: bluetoothStrings))!") + EmptyListView(text: "\("No devices found nearby")!") } else { ForEach(viewModel.discoveredDevices, id: \.address) { device in if let deviceName = device.deviceName { @@ -102,6 +100,7 @@ struct BluetoothConnectionView: View { if let address = device.address, viewModel.connectingDevices.contains(address) { Spacer() ProgressView() + .tint(.more.primary) } } Divider() @@ -117,13 +116,14 @@ struct BluetoothConnectionView: View { if viewModel.bluetoothIsScanning { HStack { ProgressView() + .tint(.more.primary) .padding(.trailing, 4) - BasicText(text: "\(String.localize(forKey: "Searching for devices", withComment: "Searching for new devices", inTable: bluetoothStrings))...") + BasicText(text: "\("Searching for devices")...") } } } } else { - BasicText(text: "Bluetooth disabled! Please enable to use!".localize(withComment: "Bluetooth disabled! Please enable to use!", useTable: bluetoothStrings)) + BasicText(text: "Bluetooth disabled! Please enable to use!") } } .onAppear { @@ -139,6 +139,8 @@ struct BluetoothConnectionView: View { struct BluetoothConnectionView_Previews: PreviewProvider { static var previews: some View { - BluetoothConnectionView(viewModel: BluetoothConnectionViewModel(), viewOpen: .constant(false)) + MoreMainBackgroundView(contentPadding: 8) { + BluetoothConnectionView(viewOpen: .constant(false)) + } } } diff --git a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift index 016ab4c58..24a1f8088 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift @@ -13,79 +13,102 @@ // https://commonsclause.com/). // +import Combine +import Dispatch import Foundation +import KMPNativeCoroutinesCombine import shared class BluetoothConnectionViewModel: ObservableObject { private let coreViewModel: CoreBluetoothViewModel = CoreBluetoothViewModel(observationFactory: AppDelegate.shared.observationFactory, coreBluetooth: AppDelegate.shared.bluetoothController) - private let deviceManager = BluetoothDeviceManager.shared + private let bleManager = BluetoothStateManagement.shared - @Published var discoveredDevices: [BluetoothDevice] = [] - @Published var connectedDevices: [BluetoothDevice] = [] + @Published var discoveredDevices: [BluetoothDeviceEntity] = [] + @Published var connectedDevices: [BluetoothDeviceEntity] = [] @Published var connectingDevices: [String] = [] @Published var bluetoothIsScanning = false @Published var neededDevices: [String] = [] - @Published var bluetoothPower: BluetoothState = .off + @Published var bluetoothPower = false + + private var cancellables = Set() init() { - deviceManager.connectedDevicesAsClosure { [weak self] deviceSet in - if let self { - DispatchQueue.main.async { - self.connectedDevices = Array(deviceSet) - .filter { $0.deviceName != nil && !($0.deviceName?.isEmpty ?? true) } - .sorted(by: { d1, d2 in - if let name1 = d1.deviceName, let name2 = d2.deviceName { - return name1 < name2 - } else { - return false - } - }) + createPublisher(for: bleManager.connectedDevices) + .map { devices in + Array(devices) + .compactMap { device -> BluetoothDeviceEntity? in + guard let name = device.deviceName, !name.isEmpty else { + return nil } + return device + } + .sorted { + ($0.deviceName ?? "") < ($1.deviceName ?? "") } } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] devices in + self?.connectedDevices = devices + } + .store(in: &cancellables) - deviceManager.devicesCurrentlyConnectingAsClosure { [weak self] devices in - if let self { - DispatchQueue.main.async { - self.connectingDevices = devices.compactMap { $0.address } - } + createPublisher(for: bleManager.devicesCurrentlyConnecting) + .map { devices in + devices.compactMap { + $0.address } } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] addresses in + self?.connectingDevices = addresses + } + .store(in: &cancellables) - deviceManager.discoveredDevicesAsClosure { [weak self] deviceSet in - if let self { - DispatchQueue.main.async { - self.discoveredDevices = Array(deviceSet) - .filter { $0.deviceName != nil && !($0.deviceName?.isEmpty ?? true) } - .sorted(by: { d1, d2 in - if let name1 = d1.deviceName, let name2 = d2.deviceName { - let name1ContainsKeyword = self.neededDevices.contains(where: name1.contains) - let name2ContainsKeyword = self.neededDevices.contains(where: name2.contains) - if name1ContainsKeyword && !name2ContainsKeyword { - return true - } else if !name1ContainsKeyword && name2ContainsKeyword { - return false - } else { - return name1 < name2 - } - } else { - return false - } - }) + createPublisher(for: bleManager.discoveredDevices) + .map { [weak self] devices -> [BluetoothDeviceEntity] in + let needed = Set(self?.neededDevices ?? []) + let filtered = devices.compactMap { device -> (BluetoothDeviceEntity, String, Bool)? in + guard let name = device.deviceName, !name.isEmpty else { + return nil } + let matchesNeeded = needed.isEmpty ? false : needed.contains(where: { name.contains($0) }) + return (device, name, matchesNeeded) } + let sorted = filtered.sorted { lhs, rhs in + if lhs.2 != rhs.2 { + return lhs.2 && !rhs.2 + } + return lhs.1 < rhs.1 + } + return sorted.map { + $0.0 + } + } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] devices in + self?.discoveredDevices = devices } + .store(in: &cancellables) - coreViewModel.coreBluetooth.isScanningAsClosure { [weak self] kBool in - self?.bluetoothIsScanning = kBool.boolValue + createPublisher(for: bleManager.scanning) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] scanning in + self?.bluetoothIsScanning = scanning.boolValue } + .store(in: &cancellables) - coreViewModel.coreBluetooth.bluetoothStateAsClosure { [weak self] bluetoothState in - self?.bluetoothPower = bluetoothState + createPublisher(for: bleManager.bluetoothActive) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] power in + self?.bluetoothPower = power.boolValue } + .store(in: &cancellables) } func viewDidAppear() { @@ -98,11 +121,17 @@ class BluetoothConnectionViewModel: ObservableObject { ViewManager.shared.showBLEView(state: false) } - func connectToDevice(device: BluetoothDevice) { - coreViewModel.connectToDevice(device: device) + func connectToDevice(device: BluetoothDeviceEntity) { + Task { + do { + try await coreViewModel.connectToDevice(device: device) + } catch { + print("Cannot connect to bluetooth device: \(device.deviceName ?? "Unknown"): \(error)") + } + } } - func disconnectFromDevice(device: BluetoothDevice) { + func disconnectFromDevice(device: BluetoothDeviceEntity) { coreViewModel.disconnectFromDevice(device: device) } } diff --git a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift index 4e0de3511..a0a9776c1 100644 --- a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift +++ b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift @@ -7,18 +7,17 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct CompletedSchedules: View { @StateObject var scheduleViewModel: ScheduleViewModel - private let navigationStrings = "Navigation" @State var tasksCompleted: Double = 0 @State var totalTasks: Double = 0 var body: some View { @@ -26,6 +25,12 @@ struct CompletedSchedules: View { ScheduleListHeader(scheduleViewModel: scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) ScheduleView(viewModel: scheduleViewModel) } - .customNavigationTitle(with: NavigationScreen.pastObservations.localize(useTable: navigationStrings, withComment: "Completed Schedules title"),displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.pastObservations.localize(), displayMode: .inline) + .onAppear { + scheduleViewModel.coreModel.viewDidAppear() + } + .onDisappear { + scheduleViewModel.coreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Components/AccordionItem.swift b/iosApp/iosApp/Views/Components/AccordionItem.swift index 80e7c6013..506741460 100644 --- a/iosApp/iosApp/Views/Components/AccordionItem.swift +++ b/iosApp/iosApp/Views/Components/AccordionItem.swift @@ -7,20 +7,20 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct AccordionItem: View { let title: String var info: String @State var isOpen: Bool = false - + var body: some View { HStack { VStack { diff --git a/iosApp/iosApp/Views/Components/AppVersion.swift b/iosApp/iosApp/Views/Components/AppVersion.swift index af6580c2b..84d055416 100644 --- a/iosApp/iosApp/Views/Components/AppVersion.swift +++ b/iosApp/iosApp/Views/Components/AppVersion.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,7 +17,7 @@ import SwiftUI struct AppVersion: View { var body: some View { - Text("\("App Version".localize(withComment: "App Version")): \(Bundle.main.appBuild)") + Text("\("App Version"): \(Bundle.main.appBuild)") .font(.system(size: 10, weight: .medium)) .foregroundColor(.more.primary) .padding(.vertical, 10) diff --git a/iosApp/iosApp/Views/Components/BasicNavLinkButton.swift b/iosApp/iosApp/Views/Components/BasicNavLinkButton.swift index 519ea9bac..ec7629ed3 100644 --- a/iosApp/iosApp/Views/Components/BasicNavLinkButton.swift +++ b/iosApp/iosApp/Views/Components/BasicNavLinkButton.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -20,7 +20,7 @@ struct BasicNavLinkButton: View { var destination: () -> Destination var label: () -> Label - + var body: some View { VStack { NavigationLink { diff --git a/iosApp/iosApp/Views/Components/BasicText.swift b/iosApp/iosApp/Views/Components/BasicText.swift index 3e2aa2fc6..123c8eb11 100644 --- a/iosApp/iosApp/Views/Components/BasicText.swift +++ b/iosApp/iosApp/Views/Components/BasicText.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -22,7 +22,7 @@ struct BasicText: View { var lineLimit: Int? = nil var textAlign: TextAlignment = .leading var body: some View { - Text(.init(text)) + Text(LocalizedStringKey(text)) .foregroundColor(color) .multilineTextAlignment(textAlign) .fixedSize(horizontal: false, vertical: true) diff --git a/iosApp/iosApp/Views/Components/CheckboxField.swift b/iosApp/iosApp/Views/Components/CheckboxField.swift new file mode 100644 index 000000000..ca2df6d3c --- /dev/null +++ b/iosApp/iosApp/Views/Components/CheckboxField.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct CheckboxField: View { + let id: String + let label: String + let isSelected: Bool + let callback: (String) -> Void + + init( + id: String, + label: String, + isSelected: Bool = false, + callback: @escaping (String) -> Void + ) { + self.id = id + self.label = label + self.isSelected = isSelected + self.callback = callback + } + + var body: some View { + Button(action: { + withAnimation(.none) { + self.callback(self.id) + } + }) { + HStack(alignment: .center) { + Image(systemName: self.isSelected ? "checkmark.square.fill" : "square") + .foregroundColor(.more.primary) + .animation(nil, value: isSelected) + BasicText(text: label, color: .more.secondary) + Spacer() + }.foregroundColor(.more.primaryLight) + } + .foregroundColor(.more.white) + .padding(.bottom, 7) + .buttonStyle(.plain) + .contentShape(Rectangle()) + .transaction { $0.animation = nil } + } +} + +struct CheckboxField_Previews: PreviewProvider { + static var previews: some View { + CheckboxField(id: "Test", label: "Test", isSelected: false, callback: { selected in + print("Toggled item is \(selected)") + }) + } +} diff --git a/iosApp/iosApp/Views/Components/CircleActivityIndicator.swift b/iosApp/iosApp/Views/Components/CircleActivityIndicator.swift index 1fea8c617..335761007 100644 --- a/iosApp/iosApp/Views/Components/CircleActivityIndicator.swift +++ b/iosApp/iosApp/Views/Components/CircleActivityIndicator.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -19,28 +19,28 @@ struct CircleActivityIndicator: View { @State private var isCircleRotating = true @State private var animateStart = false @State private var animateEnd = true - + var body: some View { ZStack { - Circle() - .stroke(lineWidth: 5) - .fill(Color.init(red: 0.96, green: 0.96, blue: 0.96)) - .frame(width: 64, height: 64) - - Circle() - .trim(from: animateStart ? 1/3 : 1/9, to: animateEnd ? 2/5 : 1) - .stroke(lineWidth: 5) - .rotationEffect(.degrees(isCircleRotating ? 0 : 360)) - .frame(width: 64, height: 64) - .foregroundColor(.more.approved) - .onAppear() { - withAnimation(Animation - .linear(duration: 1) - .repeatForever(autoreverses: false)) { - self.isCircleRotating.toggle() - } + Circle() + .stroke(lineWidth: 5) + .fill(Color(red: 0.96, green: 0.96, blue: 0.96)) + .frame(width: 64, height: 64) + + Circle() + .trim(from: animateStart ? 1 / 3 : 1 / 9, to: animateEnd ? 2 / 5 : 1) + .stroke(lineWidth: 5) + .rotationEffect(.degrees(isCircleRotating ? 0 : 360)) + .frame(width: 64, height: 64) + .foregroundColor(.more.approved) + .onAppear { + withAnimation(Animation + .linear(duration: 1) + .repeatForever(autoreverses: false)) { + self.isCircleRotating.toggle() } } + } } } diff --git a/iosApp/iosApp/Views/Components/ConsentList.swift b/iosApp/iosApp/Views/Components/ConsentList.swift index 0a0940621..a90815b72 100644 --- a/iosApp/iosApp/Views/Components/ConsentList.swift +++ b/iosApp/iosApp/Views/Components/ConsentList.swift @@ -7,18 +7,18 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct ConsentList: View { var permissionModel: PermissionModel - + var body: some View { ScrollView { ForEach(permissionModel.consentInfo, id: \.self) { consentModel in diff --git a/iosApp/iosApp/Views/Components/ConsentListHeader.swift b/iosApp/iosApp/Views/Components/ConsentListHeader.swift index 0387f0efe..c717eb72c 100644 --- a/iosApp/iosApp/Views/Components/ConsentListHeader.swift +++ b/iosApp/iosApp/Views/Components/ConsentListHeader.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // diff --git a/iosApp/iosApp/Views/Components/ConsentListItem.swift b/iosApp/iosApp/Views/Components/ConsentListItem.swift index 79d553741..7c5fe3a55 100644 --- a/iosApp/iosApp/Views/Components/ConsentListItem.swift +++ b/iosApp/iosApp/Views/Components/ConsentListItem.swift @@ -7,33 +7,32 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct ConsentListItem: View { let consentInfo: PermissionConsentModel @State var isOpen: Bool = false let hasCheckbox: Bool = false @State var hasPreview: Bool = false - + var body: some View { HStack { if hasCheckbox { VStack { Image(systemName: "checkmark.circle.fill") .foregroundColor(.more.primary) - } } VStack(alignment: .leading) { ConsentListHeader(title: consentInfo.title, hasCheck: .constant(!hasCheckbox), isOpen: $isOpen) - Divider() + Divider() Group { if isOpen { BasicText(text: consentInfo.info) diff --git a/iosApp/iosApp/Views/Components/DatapointsCollection.swift b/iosApp/iosApp/Views/Components/DatapointsCollection.swift index 5490ade8c..4163c07aa 100644 --- a/iosApp/iosApp/Views/Components/DatapointsCollection.swift +++ b/iosApp/iosApp/Views/Components/DatapointsCollection.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,14 +18,13 @@ import SwiftUI struct DatapointsCollection: View { @Binding var datapoints: Int64 var running: Bool - private let stringTable = "TaskDetail" var body: some View { VStack { if running { CircleActivityIndicator() } - Title2(titleText: String.localize(forKey: "Collected Datapoints", withComment: "Shows collected Datapoints beneath", inTable: stringTable)) - + Title2(titleText: "Collected Datapoints") + Text(String(datapoints)) .font(.more.title2) .foregroundColor(.more.secondary) diff --git a/iosApp/iosApp/Views/Components/DetailsTitle.swift b/iosApp/iosApp/Views/Components/DetailsTitle.swift index e54794f35..4daf4c6a7 100644 --- a/iosApp/iosApp/Views/Components/DetailsTitle.swift +++ b/iosApp/iosApp/Views/Components/DetailsTitle.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -21,7 +21,7 @@ struct DetailsTitle: View { var font: Font = Font.body var weight: Font.Weight = Font.Weight.semibold var body: some View { - Text(text) + Text(LocalizedStringKey(text)) .foregroundColor(color) .font(font) .fontWeight(weight) diff --git a/iosApp/iosApp/Views/Components/EmptyListView.swift b/iosApp/iosApp/Views/Components/EmptyListView.swift index d7dc549ec..553011233 100644 --- a/iosApp/iosApp/Views/Components/EmptyListView.swift +++ b/iosApp/iosApp/Views/Components/EmptyListView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // diff --git a/iosApp/iosApp/Views/Components/ErrorLogin.swift b/iosApp/iosApp/Views/Components/ErrorLogin.swift deleted file mode 100644 index c64b4820f..000000000 --- a/iosApp/iosApp/Views/Components/ErrorLogin.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ErrorLogin.swift -// iosApp -// -// Created by Isabella Aigner on 28.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - -struct ErrorLogin: View { - @EnvironmentObject var model: LoginViewModel - - @Binding var stringTable: String - @Binding var disabled: Bool - - var body: some View { - VStack { - if !model.error.isEmpty { - ErrorText(message: model.error) - .padding(.bottom, 5) - } - - VStack(alignment: .center) { - if model.isLoading { - ProgressView() - .progressViewStyle(.circular) - } - LoginButton(stringTable: .constant(stringTable), disabled: $disabled) - .environmentObject(model) - } - } - .frame(minHeight: 75) - } -} diff --git a/iosApp/iosApp/Views/Components/ErrorText.swift b/iosApp/iosApp/Views/Components/ErrorText.swift index b4c9bcfa6..ded92e970 100644 --- a/iosApp/iosApp/Views/Components/ErrorText.swift +++ b/iosApp/iosApp/Views/Components/ErrorText.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,10 +18,9 @@ import SwiftUI struct ErrorText: View { var message: String var body: some View { - Text(message) + Text(LocalizedStringKey(message)) .foregroundColor(.more.important) .fontWeight(.more.error) - } } diff --git a/iosApp/iosApp/Views/Components/ExitButton.swift b/iosApp/iosApp/Views/Components/ExitButton.swift new file mode 100644 index 000000000..f80d4aa29 --- /dev/null +++ b/iosApp/iosApp/Views/Components/ExitButton.swift @@ -0,0 +1,40 @@ +// +// ExitButton.swift +// BlendedCare +// +// Created by Jan Cortiel on 27.01.26. +// Copyright © 2026 Redlink GmbH. All rights reserved. +// + +import SwiftUI +import shared + +struct ExitButton: View { + @EnvironmentObject private var navigationModalState: NavigationModalState + var body: some View { + MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { + withdraw() + } label: { + Text("withdraw") + } + } + + private func withdraw() { + AlertController.shared.openAlertDialog(model: AlertDialogModel.companion.fromStrings( + title: "sure_message", + message: "leave_confirmation_message", + confirmLabel: "withdraw", + cancelLabel: "continue_study", + onConfirm: { + AppDelegate.shared.exitStudy { + Task { @MainActor in + navigationModalState.clearViews() + } + } + })) + } +} + +#Preview { + ExitButton() +} diff --git a/iosApp/iosApp/Views/Components/ExpandableContent.swift b/iosApp/iosApp/Views/Components/ExpandableContent.swift index d0d311ad3..7c3c6232e 100644 --- a/iosApp/iosApp/Views/Components/ExpandableContent.swift +++ b/iosApp/iosApp/Views/Components/ExpandableContent.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -19,24 +19,38 @@ struct ExpandableContent: View { @State var content: () -> Content @State var title: () -> String @State private var expanded: Bool = false - + var body: some View { VStack(alignment: .leading) { - HStack() { + HStack { SectionHeading(sectionTitle: title()) Spacer() UIToggleFoldViewButton(isOpen: $expanded) } - - Divider().padding(.bottom) - - VStack { - self.content() - }.padding(0) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: !expanded ? 0 : .none) - .clipped() - .animation(.easeOut) - .transition(.slide) + + Divider() + + if expanded { + VStack { + self.content() + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity.combined(with: .scale)) + .padding(.top, 8) + } + } + .padding(.bottom) + .animation(.easeOut(duration: 0.3), value: expanded) + } +} + +struct ExpandableContent_Preview: PreviewProvider { + static var previews: some View { + ExpandableContent(content: { + Text("Hello, World!") + }) { + "Hello" } } } diff --git a/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift b/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift index fed133cf3..52274021d 100644 --- a/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift +++ b/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -19,25 +19,39 @@ struct ExpandableContentWithLink: View { @State var content: () -> Content @State var title: () -> String @Binding var expanded: Bool - + var body: some View { VStack(alignment: .leading) { - HStack() { + HStack { SectionHeading(sectionTitle: title()) Spacer() UIToggleFoldViewButton(isOpen: $expanded) } - + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + expanded.toggle() + } + } + Divider().padding(.bottom) - - VStack { - self.content() - }.padding(0) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: !expanded ? 0 : .none) - .clipped() - .animation(.easeOut) - .transition(.slide) + + if expanded { + VStack { + self.content() + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity.combined(with: .scale)) + .padding(.top, 8) + } } + .animation(.easeOut(duration: 0.3), value: expanded) } } +struct ExpandableContentWithLink_Previews: PreviewProvider { + static var previews: some View { + ExpandableContentWithLink(content: { Text("Hello, World!") }, title: { "Hello" }, expanded: .constant(false)) + } +} diff --git a/iosApp/iosApp/Views/Components/ExpandableInput.swift b/iosApp/iosApp/Views/Components/ExpandableInput.swift index f070e384d..6e1bde76f 100644 --- a/iosApp/iosApp/Views/Components/ExpandableInput.swift +++ b/iosApp/iosApp/Views/Components/ExpandableInput.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -47,9 +47,9 @@ struct ExpandableInput: View { if expanded { if isSmTextfield { - MoreTextFieldSmBottom(titleKey: .constant(inputPlaceholder), inputText: $input, capitalization: capitalization, autoCorrectDisabled: true, textType: textType) + MoreTextFieldSmBottom(titleKey: $inputPlaceholder, inputText: $input, capitalization: capitalization, autoCorrectDisabled: true, textType: textType) } else { - MoreTextField(titleKey: .constant(inputPlaceholder), inputText: $input, capitalization: capitalization, autoCorrectDisabled: true, textType: textType) + MoreTextField(titleKey: $inputPlaceholder, inputText: $input, capitalization: capitalization, autoCorrectDisabled: true, textType: textType) } } } diff --git a/iosApp/iosApp/Views/Components/ExpandableText.swift b/iosApp/iosApp/Views/Components/ExpandableText.swift index 9caa0dad4..ca5f4be9c 100644 --- a/iosApp/iosApp/Views/Components/ExpandableText.swift +++ b/iosApp/iosApp/Views/Components/ExpandableText.swift @@ -7,15 +7,14 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import SwiftUI - struct ExpandableText: View { @State private var expanded: Bool = false @State private var truncated: Bool = false @@ -28,18 +27,18 @@ struct ExpandableText: View { var animation: Animation { Animation.easeInOut } - - init(_ text: String,_ title: String, lineLimit: Int, color: Color = Color.more.secondary) { + + init(_ text: String, _ title: String, lineLimit: Int, color: Color = Color.more.secondary) { self.text = text self.title = title self.lineLimit = lineLimit self.color = color } - + private func determineTruncation(_ geometry: GeometryProxy) { // Calculate the bounding box we'd need to render the // text given the width from the GeometryReader. - let total = self.text.boundingRect( + let total = text.boundingRect( with: CGSize( width: geometry.size.width, height: .greatestFiniteMagnitude @@ -48,15 +47,15 @@ struct ExpandableText: View { attributes: [.font: UIFont.systemFont(ofSize: 16)], context: nil ) - + if total.size.height > geometry.size.height { - self.truncated = true + truncated = true } } - + var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack{ + HStack { SectionHeading(sectionTitle: title) Spacer() if self.truncated { @@ -65,12 +64,13 @@ struct ExpandableText: View { self.expanded.toggle() } } label: { - Image.more.toggleFoldView.rotationEffect(Angle.degrees(rotateFold ? 180 : 0)) - .animation(animation) + Image.more.toggleFoldView + .rotationEffect(Angle.degrees(rotateFold ? 180 : 0)) + .animation(.easeInOut, value: rotateFold) } } } - + Text(self.text) .foregroundColor(self.color) .lineLimit(self.expanded ? nil : self.lineLimit) @@ -79,12 +79,13 @@ struct ExpandableText: View { self.determineTruncation(geometry) } }) - + if self.truncated { - Button(action: { self.expanded.toggle() + Button(action: { + self.expanded.toggle() rotateFold.toggle() }) { - Text(self.expanded ? String.localize(forKey: "Read Less", withComment: "Read less information", inTable: stringTable) : String.localize(forKey: "Read More", withComment: "Read more information", inTable: stringTable)) + Text(LocalizedStringKey(self.expanded ? "Read Less" : "Read More")) .font(.system(size: 16)) } } @@ -94,7 +95,7 @@ struct ExpandableText: View { struct ExpandableText_Previews: PreviewProvider { static var previews: some View { - ScrollView() { + ScrollView { ExpandableText("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut laborum", "Title", lineLimit: 4) ExpandableText("Small text", "Title", lineLimit: 3) ExpandableText("Render the limited text and measure its size, R", "Title", lineLimit: 1) diff --git a/iosApp/iosApp/Views/Components/Filters/MoreFilter.swift b/iosApp/iosApp/Views/Components/Filters/MoreFilter.swift index 859083081..4fadc22b7 100644 --- a/iosApp/iosApp/Views/Components/Filters/MoreFilter.swift +++ b/iosApp/iosApp/Views/Components/Filters/MoreFilter.swift @@ -12,7 +12,7 @@ struct MoreFilter: View { @Binding var filterText: String var destination: NavigationScreen var image = Image(systemName: "slider.horizontal.3") - + @EnvironmentObject private var navigationModalState: NavigationModalState var body: some View { diff --git a/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift b/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift index b5bc0a111..e9f63b796 100644 --- a/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift +++ b/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift @@ -11,9 +11,9 @@ import SwiftUI struct MoreFilterOption: View { var option: String @Binding var isSelected: Bool - + private let stringTable = "DashboardFilter" - + var body: some View { VStack { HStack { @@ -24,7 +24,7 @@ struct MoreFilterOption: View { Spacer() .frame(width: 5) } - MoreFilterText(text: .constant(String.localize(forKey: option, withComment: "String representation of observation type", inTable: stringTable))) + MoreFilterText(text: .constant(option)) } .padding(5) } diff --git a/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift b/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift index 7f4ac24dd..1250b5415 100644 --- a/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift +++ b/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift @@ -10,13 +10,11 @@ import SwiftUI struct MoreFilterText: View { @Binding var text: String - + var body: some View { - Text(text) + Text(LocalizedStringKey(text)) .font(.system(size: 16)) .font(Font.body.bold()) .foregroundColor(Color.more.secondary) } } - - diff --git a/iosApp/iosApp/Views/Components/ForwardButton.swift b/iosApp/iosApp/Views/Components/ForwardButton.swift index 18064890b..bd14223b5 100644 --- a/iosApp/iosApp/Views/Components/ForwardButton.swift +++ b/iosApp/iosApp/Views/Components/ForwardButton.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -33,4 +33,3 @@ struct ForwardButton_Previews: PreviewProvider { ForwardButton() } } - diff --git a/iosApp/iosApp/Views/Components/InactiveText.swift b/iosApp/iosApp/Views/Components/InactiveText.swift index d798369e1..142e31003 100644 --- a/iosApp/iosApp/Views/Components/InactiveText.swift +++ b/iosApp/iosApp/Views/Components/InactiveText.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,7 +18,7 @@ import SwiftUI struct InactiveText: View { var text: String var body: some View { - Text(text) + Text(LocalizedStringKey(text)) .font(.moreFont.inactiveText) .foregroundColor(.more.textInactive) .lineLimit(1) diff --git a/iosApp/iosApp/Views/Components/InlineAbortButton.swift b/iosApp/iosApp/Views/Components/InlineAbortButton.swift index 58fcfe473..d9dd55b4e 100644 --- a/iosApp/iosApp/Views/Components/InlineAbortButton.swift +++ b/iosApp/iosApp/Views/Components/InlineAbortButton.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -22,18 +22,16 @@ struct InlineAbortButton: View { Button { action() } label: { - HStack{ + HStack { Image(systemName: "square.fill") .padding(0.5) - .foregroundColor(.more.important) - Text(String.localize(forKey: "Abort", withComment: "Abort running task.", inTable: stringTable)) + Text("Abort") .foregroundColor(.more.secondary) } .padding(5) - } - .accent(color: .more.primaryLight) + .tint(.more.primaryLight) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(Color.more.secondaryMedium, lineWidth: 1) diff --git a/iosApp/iosApp/Views/Components/ModuleListItem.swift b/iosApp/iosApp/Views/Components/ModuleListItem.swift index cfa1c7a64..dc3b41c65 100644 --- a/iosApp/iosApp/Views/Components/ModuleListItem.swift +++ b/iosApp/iosApp/Views/Components/ModuleListItem.swift @@ -7,25 +7,25 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct ModuleListItem: View { - let observation: ObservationSchema - + let observation: ObservationEntity + var body: some View { - VStack{ - HStack(){ - VStack(alignment: .leading){ + VStack { + HStack { + VStack(alignment: .leading) { BasicText(text: observation.observationTitle) - .padding(.bottom, (0.5)) - + .padding(.bottom, 0.5) + BasicText(text: observation.observationType, color: Color.more.secondary) } Spacer() @@ -33,12 +33,11 @@ struct ModuleListItem: View { } Divider() } - } } struct ModuleListItem_Previews: PreviewProvider { static var previews: some View { - ModuleListItem(observation: ObservationSchema()) + ModuleListItem(observation: ObservationEntity(observationId: "1", observationType: "gps", observationTitle: "GPS", participantInfo: "123", configuration: nil, hidden: false, scheduleLess: false, reminder: false, version: 0, required: true, collectionTimestamp: Date().timeIntervalSince1970.asTimestamp())) } } diff --git a/iosApp/iosApp/Views/Components/MoreActionButton.swift b/iosApp/iosApp/Views/Components/MoreActionButton.swift index 9c1c12f13..645e34bc6 100644 --- a/iosApp/iosApp/Views/Components/MoreActionButton.swift +++ b/iosApp/iosApp/Views/Components/MoreActionButton.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -25,21 +25,23 @@ struct MoreActionButton: View { var alertOpen: Binding = .constant(false) let action: () -> Void var label: () -> ButtonLabel - var errorAlert: () -> Alert = {Alert(title: Text("Alert"), dismissButton: .default(Text("Ok")))} + var errorAlert: () -> Alert = { + Alert(title: Text("Alert"), dismissButton: .default(Text("Ok"))) + } var body: some View { - Button(action: action){ + Button(action: action) { label() - .frame(maxWidth: maxWidth) - .padding() - .foregroundColor(disabled ? disabeldColor : .more.white) - .background(disabled ? disabledBackgroundColor : backgroundColor) - .cornerRadius(.moreBorder.cornerRadius) - .overlay( - RoundedRectangle(cornerRadius: .moreBorder.cornerRadius) - .stroke(disabled ? disabeldBorderColor : backgroundColor, lineWidth: 1) - ) - } + .frame(maxWidth: maxWidth) + .padding() + .foregroundColor(disabled ? disabeldColor : .more.white) + .background(disabled ? disabledBackgroundColor : backgroundColor) + .cornerRadius(.moreBorder.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: .moreBorder.cornerRadius) + .stroke(disabled ? disabeldBorderColor : backgroundColor, lineWidth: 1) + ) + } .disabled(disabled) .alert(isPresented: alertOpen, content: errorAlert) } diff --git a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift index a09d9cf5c..29e446e93 100644 --- a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift +++ b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift @@ -6,19 +6,18 @@ // Copyright © 2024 Redlink GmbH. All rights reserved. // -import shared import SwiftUI +import shared struct MoreAlertDialog: View { - var alertDialogModel: AlertDialogModel + let alertDialogModel: AlertDialogModel - private let stringTable = "AlertDialog" var body: some View { ZStack { Color.black.opacity(0.5) .ignoresSafeArea(edges: .all) VStack(spacing: 20) { - Text(String.localize(forKey: alertDialogModel.title, withComment: "alert dialog title", inTable: stringTable)) + Text(alertDialogModel.title.localized()) .foregroundColor(.more.primary) .font(.headline) .multilineTextAlignment(.center) @@ -28,7 +27,7 @@ struct MoreAlertDialog: View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading) { - Text(String.localize(forKey: alertDialogModel.message, withComment: "alert dialog message", inTable: stringTable)) + Text(alertDialogModel.message.localized()) .foregroundColor(.more.primary) .font(.subheadline) .multilineTextAlignment(.leading) @@ -40,20 +39,24 @@ struct MoreAlertDialog: View { VStack { MoreActionButton(disabled: .constant(false)) { - alertDialogModel.onPositive() + if let onConfirm = alertDialogModel.onConfirm { + onConfirm() + } } label: { - Text(String.localize(forKey: alertDialogModel.positiveTitle, withComment: "positive button", inTable: stringTable)) + Text(alertDialogModel.confirmLabel.localized()) } - if let negativeTitle = alertDialogModel.negativeTitle { + if let cancelLabel = alertDialogModel.cancelLabel { MoreActionButton(backgroundColor: .more.secondaryLight, disabled: .constant(false)) { - alertDialogModel.onNegative() + if let onDecline = alertDialogModel.onDecline { + onDecline() + } } label: { if #available(iOS 17.0, *) { - Text(String.localize(forKey: negativeTitle, withComment: "negative button", inTable: stringTable)) + Text(cancelLabel.localized()) .foregroundStyle(Color.more.primary) } else { - Text(String.localize(forKey: negativeTitle, withComment: "negative button", inTable: stringTable)) + Text(cancelLabel.localized()) .foregroundColor(.more.primary) } } @@ -73,9 +76,16 @@ struct MoreAlertDialog: View { } #Preview { - MoreAlertDialog(alertDialogModel: AlertDialogModel(title: "Needed permissions were not given", message: "This study needs one or more sensor permission to correctly work. You may decline sensor permissions, but if you do, the app and the study may not work fully or as expected. Would you like to go to the settings and allow the app to access needed sensor permissions?", positiveTitle: "Required Permissions Were Not Granted", negativeTitle: "Continue without allowing", onPositive: { - print("Settings") - }, onNegative: { - print("Continue") - })) + MoreAlertDialog( + alertDialogModel: AlertDialogModel.companion.fromStrings( + title: "Needed permissions were not given", + message: "This study needs one or more sensor permission to correctly work. You may decline sensor permissions, but if you do, the app and the study may not work fully or as expected. Would you like to go to the settings and allow the app to access needed sensor permissions?", + confirmLabel: "Required Permissions Were Not Granted", + cancelLabel: "Continue without allowing", + onConfirm: { + print("Settings") + }, + onDecline: { + print("Continue") + })) } diff --git a/iosApp/iosApp/Views/Components/MoreBackButton.swift b/iosApp/iosApp/Views/Components/MoreBackButton.swift deleted file mode 100644 index 7e0f25d94..000000000 --- a/iosApp/iosApp/Views/Components/MoreBackButton.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// MoreBackButton.swift -// iosApp -// -// Created by Julia Mayrhauser on 13.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - -@available(iOS 15.0, *) -struct MoreBackButton: View { - @Environment(\.dismiss) private var dismiss - var action: () -> Void = {} - var body: some View { - Button { - action() - dismiss() - } label: { - Image(systemName: "chevron.left") - } - } -} - -struct MoreBackButtonIOS14: View { - var action: () -> Void = {} - @State private var isActive: Bool = false - var body: some View { - HStack { - Button { - isActive = true - } label: { - Image(systemName: "chevron.left") - } - } - } -} diff --git a/iosApp/iosApp/Views/Components/MoreTextField.swift b/iosApp/iosApp/Views/Components/MoreTextField.swift index cebc76613..3ab21d5c0 100644 --- a/iosApp/iosApp/Views/Components/MoreTextField.swift +++ b/iosApp/iosApp/Views/Components/MoreTextField.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -23,7 +23,7 @@ struct MoreTextField: View { var textType: UITextContentType? = nil var body: some View { - TextField(titleKey, text: $inputText) + TextField(LocalizedStringKey(titleKey), text: $inputText) .textFieldAutoCapitalizataion(capitalization: capitalization) .autocorrectionDisabled(autoCorrectDisabled) .textContentType(textType) diff --git a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift index c130f9901..838396640 100644 --- a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift +++ b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,35 +18,34 @@ struct MoreTextFieldHL: View { @Binding var isSmTextfield: Bool var headerText: String @Binding var inputPlaceholder: String - + @Binding var input: String - + var capitalization: Capitalization = .normal var autoCorrectDisabled = false var textType: UITextContentType? = nil + var hlAlignment: TextAlignment = .leading + var body: some View { VStack(alignment: .leading) { - - HStack{ + HStack { Spacer() SectionHeading(sectionTitle: headerText, showAllText: true) + .multilineTextAlignment(hlAlignment) Spacer() } .padding(3) - + if isSmTextfield { - MoreTextFieldSmBottom(titleKey: .constant(inputPlaceholder),inputText: $input, capitalization: capitalization, autoCorrectDisabled: autoCorrectDisabled, textType: textType) + MoreTextFieldSmBottom(titleKey: $inputPlaceholder, inputText: $input, capitalization: capitalization, autoCorrectDisabled: autoCorrectDisabled, textType: textType) } else { - MoreTextField(titleKey: .constant(inputPlaceholder), inputText: $input, capitalization: capitalization, autoCorrectDisabled: autoCorrectDisabled, textType: textType) + MoreTextField(titleKey: $inputPlaceholder, inputText: $input, capitalization: capitalization, autoCorrectDisabled: autoCorrectDisabled, textType: textType) } - } - } } struct MoreTextFieldHL_Previews: PreviewProvider { - static var previews: some View { MoreTextFieldHL(isSmTextfield: .constant(false), headerText: "Hello World Key", inputPlaceholder: .constant(""), input: .constant("me"), capitalization: .normal) } diff --git a/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift b/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift index 9029ef68c..325fb64cd 100644 --- a/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift +++ b/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -24,7 +24,7 @@ struct MoreTextFieldSmBottom: View { var autoCorrectDisabled: Bool = false var textType: UITextContentType? = nil var body: some View { - TextField(titleKey, text: $inputText) + TextField(LocalizedStringKey(titleKey), text: $inputText) .textFieldAutoCapitalizataion(capitalization: capitalization) .autocorrectionDisabled(autoCorrectDisabled) .textContentType(textType) @@ -38,13 +38,10 @@ struct MoreTextFieldSmBottom: View { .foregroundColor(Color.more.primaryMedium) .offset(x: 0, y: 20) ) - - } } struct MoreTextFieldSmBottom_Previews: PreviewProvider { - static var previews: some View { MoreTextFieldSmBottom(titleKey: .constant("Hello World Key"), inputText: .constant(""), capitalization: .normal) } diff --git a/iosApp/iosApp/Views/Components/NavigationLinkButton.swift b/iosApp/iosApp/Views/Components/NavigationLinkButton.swift index fd2dcd01d..065a379de 100644 --- a/iosApp/iosApp/Views/Components/NavigationLinkButton.swift +++ b/iosApp/iosApp/Views/Components/NavigationLinkButton.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -16,15 +16,13 @@ import SwiftUI struct NavigationLinkButton: View { - @Binding var disabled: Bool var destination: () -> Destination var label: () -> Label - + var body: some View { - VStack { - if (!disabled) { + if !disabled { NavigationLink { destination() } label: { @@ -46,7 +44,6 @@ struct NavigationLinkButton: View { label() } } - } } } diff --git a/iosApp/iosApp/Views/Components/NavigationText.swift b/iosApp/iosApp/Views/Components/NavigationText.swift index 9c4c033fa..2f35f79c6 100644 --- a/iosApp/iosApp/Views/Components/NavigationText.swift +++ b/iosApp/iosApp/Views/Components/NavigationText.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,7 +18,7 @@ import SwiftUI struct NavigationText: View { var text: String var body: some View { - Text(text) + Text(LocalizedStringKey(text)) .font(.headline) .foregroundColor(.more.secondary) } diff --git a/iosApp/iosApp/Views/Components/NotificationItem.swift b/iosApp/iosApp/Views/Components/NotificationItem.swift index 04b10d721..8efc3e869 100644 --- a/iosApp/iosApp/Views/Components/NotificationItem.swift +++ b/iosApp/iosApp/Views/Components/NotificationItem.swift @@ -7,14 +7,14 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct NotificationItem: View { let notificationModel: NotificationModel @@ -37,18 +37,20 @@ struct NotificationItem: View { .font(.system(size: 10)) } } - + HStack(alignment: .center) { VStack(alignment: .leading) { - BasicText(text: notificationModel.notificationBody.applyHyperlinks().trimmingCharacters(in: .whitespacesAndNewlines), color: .more.secondary) - - BasicText(text: (notificationModel.timestamp / 1000).toDateString(dateFormat: "dd.MM.yyyy HH:mm:ss")) + BasicText(text: NotificationTextLocalization.shared.localize(raw: notificationModel.notificationBody, fallback: nil).applyHyperlinks().trimmingCharacters(in: .whitespacesAndNewlines), color: .more.secondary) + + BasicText(text: (notificationModel.timestamp).toDateString(dateFormat: "dd.MM.yyyy HH:mm:ss")) .padding(.top, 4) } Spacer() if notificationModel.deepLink != nil { - Image(systemName: notificationModel.read ? "checkmark.circle" : "chevron.right") - .foregroundColor(notificationModel.read ? .more.approved : .more.secondary) + if !notificationModel.read || notificationModel.completed { + Image(systemName: notificationModel.completed ? "checkmark.circle" : "chevron.right") + .foregroundColor(notificationModel.completed ? .more.approved : .more.secondary) + } } } } @@ -64,6 +66,6 @@ struct NotificationItem: View { struct NotificationItem_Preview: PreviewProvider { static var previews: some View { - NotificationItem(notificationModel: NotificationModel(notificationId: "abc2", channelId: nil, title: "Title", notificationBody: "Message", timestamp: Int64(Date().timeIntervalSince1970), priority: 2, read: true, userFacing: true, deepLink: "app://", notificationData: [:])) + NotificationItem(notificationModel: NotificationModel(notificationId: "abc2", channelId: nil, title: "Title", notificationBody: "Message", timestamp: Int64(Date().timeIntervalSince1970), priority: 2, read: true, completed: true, userFacing: true, deepLink: "app://", notificationData: [:])) } } diff --git a/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift b/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift index 19601ec10..e9809ebc4 100644 --- a/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift +++ b/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift @@ -7,23 +7,19 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import SwiftUI - struct ObservationDetailsData: View { var dateRange: String var timeframe: String - - private let stringTable = "TaskDetail" - + var body: some View { - VStack { HStack { Image(systemName: "calendar") @@ -32,15 +28,14 @@ struct ObservationDetailsData: View { Spacer() } HStack { - Image(systemName: "clock.fill") - .padding(0.7) - Text(String.localize(forKey: "Timeframe", withComment: "Timeframe of observation", inTable: stringTable)) - .foregroundColor(.more.primary) - - BasicText(text: timeframe, color: .more.secondary) - Spacer() + Image(systemName: "clock.fill") + .padding(0.7) + Text("Timeframe") + .foregroundColor(.more.primary) + + BasicText(text: timeframe, color: .more.secondary) + Spacer() } } - } } diff --git a/iosApp/iosApp/Views/Components/RadioButtonField.swift b/iosApp/iosApp/Views/Components/RadioButtonField.swift index 01fd1470b..685a2cfb1 100644 --- a/iosApp/iosApp/Views/Components/RadioButtonField.swift +++ b/iosApp/iosApp/Views/Components/RadioButtonField.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,35 +18,40 @@ import SwiftUI struct RadioButtonField: View { let id: String let label: String - let isMarked:Bool - let callback: (String)->() - + let isMarked: Bool + let callback: (String) -> Void + init( id: String, - label:String, + label: String, isMarked: Bool = false, - callback: @escaping (String)->() - ) { + callback: @escaping (String) -> Void + ) { self.id = id self.label = label self.isMarked = isMarked self.callback = callback } - + var body: some View { - Button(action:{ - self.callback(self.id) + Button(action: { + withAnimation(.none) { + self.callback(self.id) + } }) { HStack(alignment: .center) { Image(systemName: self.isMarked ? "largecircle.fill.circle" : "circle") - .clipShape(Circle()) .foregroundColor(.more.primary) + .animation(nil, value: isMarked) BasicText(text: label, color: .more.secondary) Spacer() }.foregroundColor(.more.primaryLight) } .foregroundColor(.more.white) .padding(.bottom, 7) + .buttonStyle(.plain) + .contentShape(Rectangle()) + .transaction { $0.animation = nil } } } @@ -54,7 +59,8 @@ struct RadioButtonField_Preview: PreviewProvider { static var previews: some View { RadioButtonField(id: "Test", label: "Test", isMarked: false, callback: { selected in - print("Selected item is \(selected)") - }) + print("Selected item is \(selected)") + }) } } + diff --git a/iosApp/iosApp/Views/Components/ReloadButton.swift b/iosApp/iosApp/Views/Components/ReloadButton.swift new file mode 100644 index 000000000..f5ba08e36 --- /dev/null +++ b/iosApp/iosApp/Views/Components/ReloadButton.swift @@ -0,0 +1,32 @@ +// +// ReloadButton.swift +// BlendedCare +// +// Created by Jan Cortiel on 27.01.26. +// Copyright © 2026 Redlink GmbH. All rights reserved. +// + +import SwiftUI +import shared + +struct ReloadButton: View { + @EnvironmentObject private var navigationModalState: NavigationModalState + @State private var isLoading = false + var body: some View { + MoreActionButton(backgroundColor: .more.primary, disabled: $isLoading) { + reload() + } label: { + Text("Reload study") + } + } + + private func reload() { + isLoading = true + AppDelegate.shared.updateStudy(oldStudyState: nil, newStudyState: nil) + isLoading = false + } +} + +#Preview { + ReloadButton() +} diff --git a/iosApp/iosApp/Views/Components/ScheduleListHeader.swift b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift index 14eb446e6..f30765e4b 100644 --- a/iosApp/iosApp/Views/Components/ScheduleListHeader.swift +++ b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift @@ -19,22 +19,22 @@ struct ScheduleListHeader: View { @ObservedObject var scheduleViewModel: ScheduleViewModel @Binding var totalTasks: Double @Binding var tasksCompleted: Double - + @EnvironmentObject var navigationModalState: NavigationModalState - private let stringTable = "DashboardView" var body: some View { VStack { - TaskCompletionBarView(viewModel: TaskCompletionBarViewModel(), progressViewTitle: String.localize(forKey: "tasks_completed", withComment: "string for completed tasks", inTable: "DashboardView")) + TaskCompletionBarView(viewModel: TaskCompletionBarViewModel(), progressViewTitle: "tasks_completed") .padding(.bottom) - if scheduleViewModel.numberOfObservationErrors() > 0 { + if scheduleViewModel.numberOfErrors > 0 { MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { navigationModalState.openView(screen: .observationErrors) } label: { HStack { Image(systemName: "exclamationmark.triangle") .padding(.trailing, 2) - Text("\(scheduleViewModel.numberOfObservationErrors()) \("errors".localize(withComment: "Errors", useTable: "Errors"))") + Text(verbatim: String(scheduleViewModel.numberOfErrors)) + Text("Error") } } .padding(.bottom) diff --git a/iosApp/iosApp/Views/Components/SectionHeading.swift b/iosApp/iosApp/Views/Components/SectionHeading.swift index 7fcb84651..bcf9bd48a 100644 --- a/iosApp/iosApp/Views/Components/SectionHeading.swift +++ b/iosApp/iosApp/Views/Components/SectionHeading.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -19,14 +19,14 @@ struct SectionHeading: View { var sectionTitle: String var font: Font = Font.more.headline var showAllText = false - + var body: some View { if showAllText { - Text(sectionTitle) + Text(LocalizedStringKey(sectionTitle)) .font(font) .fixedSize(horizontal: false, vertical: true) } else { - Text(sectionTitle) + Text(LocalizedStringKey(sectionTitle)) .font(font) } } diff --git a/iosApp/iosApp/Views/Components/Title.swift b/iosApp/iosApp/Views/Components/Title.swift index 74364fd51..beb41ab47 100644 --- a/iosApp/iosApp/Views/Components/Title.swift +++ b/iosApp/iosApp/Views/Components/Title.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -19,7 +19,7 @@ struct Title: View { var titleText: String var textAlignment: TextAlignment = .leading var body: some View { - Text(titleText) + Text(LocalizedStringKey(titleText)) .font(.more.title) .foregroundColor(.more.primary) .fontWeight(.more.title) diff --git a/iosApp/iosApp/Views/Components/Title2.swift b/iosApp/iosApp/Views/Components/Title2.swift index 4df67bdba..40c40b536 100644 --- a/iosApp/iosApp/Views/Components/Title2.swift +++ b/iosApp/iosApp/Views/Components/Title2.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -20,7 +20,7 @@ struct Title2: View { var color: Color = Color.more.primary var textAlignment: TextAlignment = .leading var body: some View { - Text(titleText) + Text(LocalizedStringKey(titleText)) .font(.more.title2) .foregroundColor(color) .fontWeight(.more.title) diff --git a/iosApp/iosApp/Views/Components/TriggerSlider.swift b/iosApp/iosApp/Views/Components/TriggerSlider.swift deleted file mode 100644 index 3f6b31f01..000000000 --- a/iosApp/iosApp/Views/Components/TriggerSlider.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// TriggerSlider.swift -// iosApp -// -// Created by Daniil Barkov on 17.04.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - - -public struct TriggerSlider: View { - - var sliderView: SliderView - var textView: TextView - var backgroundView: BackgroundView - - public var didSlideToEnd: ()->Void - - var settings: TriggerSliderSettings - - @Binding var offsetX: CGFloat - - /** - Initializer - - Parameter sliderView: The slider view - - Parameter textView: Text view that is located between the slider view and the background. Does not have to be a Text, can be any other view. - - Parameter backgroundView: The background view of the slider. - - Parameter offsetX: The horizontal offset of the slider view. Should be set to 0 as initial value. Value changes as the slider is moved by the user's drag gesture. - - Parameter didSlideToEnd: Closure is called when the slider is moved to the end position. In your code, determine what should happend in that case. - */ - public init(@ViewBuilder sliderView: ()->SliderView, textView: ()->TextView, backgroundView: ()->BackgroundView, offsetX: Binding, didSlideToEnd: @escaping ()->Void, settings: TriggerSliderSettings = TriggerSliderSettings()) { - self.sliderView = sliderView() - self.backgroundView = backgroundView() - self.textView = textView() - self._offsetX = offsetX - self.didSlideToEnd = didSlideToEnd - self.settings = settings - } - - public var body: some View { - GeometryReader { proxy in - ZStack { - - backgroundView - .frame(height: settings.sliderViewHeight + settings.sliderViewVPadding) - - textView - .opacity(self.textLabelOpacity(totalWidth: proxy.size.width)) - - HStack { - - if settings.slideDirection == .left { - Spacer() - } - - self.sliderView - .frame(width: settings.sliderViewWidth, height: settings.sliderViewHeight) - .padding(.horizontal, settings.sliderViewHPadding) - .padding(.vertical, settings.sliderViewVPadding) - .offset(x: self.offsetX, y: 0) - .gesture(DragGesture(coordinateSpace: .local) - .onChanged( - { - value in - self.dragOnChanged(value: value, totalWidth: proxy.size.width) - } - ).onEnded( - { - value in - self.dragOnEnded(value: value, totalWidth: proxy.size.width) - - })) - - if settings.slideDirection == .right { - Spacer() - } - } - - } - } - } - - func dragOnChanged(value: DragGesture.Value, totalWidth: CGFloat) { - - let rightSlidingChangeCondition = settings.slideDirection == .right && value.translation.width > 0 && offsetX <= totalWidth - settings.sliderViewWidth - settings.sliderViewHPadding * 2 - let leftSlidingChangeCondition = settings.slideDirection == .left && value.translation.width < 0 && offsetX >= -totalWidth + settings.sliderViewWidth + settings.sliderViewHPadding * 2 - - if rightSlidingChangeCondition || leftSlidingChangeCondition { - self.offsetX = value.translation.width - } - } - - func dragOnEnded(value: DragGesture.Value, totalWidth: CGFloat) { - - let resetConditionSlideRight = self.settings.slideDirection == .right && self.offsetX < totalWidth - settings.sliderViewWidth - settings.sliderViewHPadding * 2 - - let resetConditionSlideLeft = self.settings.slideDirection == .left && self.offsetX > -(totalWidth - settings.sliderViewWidth - settings.sliderViewHPadding * 2) - - if resetConditionSlideRight || resetConditionSlideLeft { - withAnimation { - self.offsetX = 0 - } - } else { - self.didSlideToEnd() - } - } - - func textLabelOpacity(totalWidth: CGFloat)->CGFloat { - let halfTotalWidth = totalWidth / 2 - return (halfTotalWidth - abs(self.offsetX)) / halfTotalWidth - } -} - -struct TriggerSlider_Previews: PreviewProvider { - static var previews: some View { - StatefulPreviewWrapper(0) { - TriggerSlider(sliderView: { - RoundedRectangle(cornerRadius: 30, style: .continuous).fill(Color.orange) - .overlay(Image(systemName: "arrow.right").font(.system(size: 30)).foregroundColor(.white)) - }, textView: { - Text("Slide to Unlock").foregroundColor(Color.orange) - }, - backgroundView: { - RoundedRectangle(cornerRadius: 30, style: .continuous) - .fill(Color.orange.opacity(0.5)) - }, offsetX: $0, - didSlideToEnd: { - print("trigger!") - }, settings: TriggerSliderSettings(sliderViewVPadding: 5, slideDirection: .right)).padding(10).padding(.horizontal, 20) - } - } -} - -struct StatefulPreviewWrapper: View { - @State var value: Value - var content: (Binding) -> Content - - var body: some View { - content($value) - } - - init(_ value: Value, content: @escaping (Binding) -> Content) { - self._value = State(wrappedValue: value) - self.content = content - } -} diff --git a/iosApp/iosApp/Views/Components/TriggerSliderSettings.swift b/iosApp/iosApp/Views/Components/TriggerSliderSettings.swift deleted file mode 100644 index ff02c13c1..000000000 --- a/iosApp/iosApp/Views/Components/TriggerSliderSettings.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// TriggerSliderSettings.swift -// iosApp -// -// Created by Daniil Barkov on 17.04.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - - -public struct TriggerSliderSettings { - - /** - Initializer - - Parameter sliderViewHeight: height of the slider. Default is 40 - - Parameter sliderViewWidth: width of the slider. Default is 40. - - Parameter sliderViewHPadding: horizontal padding of the sliderView relative to the background edges. Default 0. - - Parameter sliderViewVPadding: vertical padding of the sliderView relative to the background edges. Default 0. - - Parameter slideDirection: slide direction of the slider (left or right). Default: right. - */ - public init(sliderViewHeight: CGFloat = 40, sliderViewWidth: CGFloat = 40, sliderViewHPadding: CGFloat = 0, sliderViewVPadding:CGFloat = 0, slideDirection: SlideDirection = .right) { - self.sliderViewWidth = sliderViewWidth - self.sliderViewHeight = sliderViewHeight - self.sliderViewHPadding = sliderViewHPadding - self.sliderViewVPadding = sliderViewVPadding - self.slideDirection = slideDirection - } - - var sliderViewHeight: CGFloat - var sliderViewWidth: CGFloat - var sliderViewHPadding: CGFloat - var sliderViewVPadding: CGFloat - var slideDirection: SlideDirection - -} - -public enum SlideDirection { - case left, right -} diff --git a/iosApp/iosApp/Views/Components/UIToggleButtonView.swift b/iosApp/iosApp/Views/Components/UIToggleButtonView.swift deleted file mode 100644 index 82db5d5b6..000000000 --- a/iosApp/iosApp/Views/Components/UIToggleButtonView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// UIToggleButtonView.swift -// iosApp -// -// Created by Jan Cortiel on 06.02.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - -struct UIToggleButtonView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -struct UIToggleButtonView_Previews: PreviewProvider { - static var previews: some View { - UIToggleButtonView() - } -} diff --git a/iosApp/iosApp/Views/Components/UIToggleFoldViewButton.swift b/iosApp/iosApp/Views/Components/UIToggleFoldViewButton.swift index 565e0e629..22cd6305d 100644 --- a/iosApp/iosApp/Views/Components/UIToggleFoldViewButton.swift +++ b/iosApp/iosApp/Views/Components/UIToggleFoldViewButton.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // diff --git a/iosApp/iosApp/Views/Components/ViewAdaptsToOpenKeyboard.swift b/iosApp/iosApp/Views/Components/ViewAdaptsToOpenKeyboard.swift index 2e2f216d4..f3fe4b321 100644 --- a/iosApp/iosApp/Views/Components/ViewAdaptsToOpenKeyboard.swift +++ b/iosApp/iosApp/Views/Components/ViewAdaptsToOpenKeyboard.swift @@ -7,15 +7,15 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import Combine import Foundation import SwiftUI -import Combine class KeyboardResponder: ObservableObject { @Published var currentHeight: CGFloat = 0 @@ -42,11 +42,10 @@ class KeyboardResponder: ObservableObject { } } - struct ViewAdaptsToOpenKeyboard: ViewModifier { @ObservedObject var keyboardResponder = KeyboardResponder() var animation: Animation = .easeOut(duration: 0.16) - + func body(content: Content) -> some View { content .padding(.bottom, keyboardResponder.currentHeight) diff --git a/iosApp/iosApp/Views/Components/WebView/WebView.swift b/iosApp/iosApp/Views/Components/WebView/WebView.swift index b35a0cafc..d01ec4ab1 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebView.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,46 +17,56 @@ import SwiftUI import WebKit struct WebView: View { - @State var url: URL? + @State var url: URLRequest? @StateObject var viewModel: WebViewViewModel - + var body: some View { VStack { if viewModel.progress < 1 { - ProgressView(value: viewModel.progress, total: 1) + ProgressView(value: viewModel.progress, total: 1) + .tint(.more.primary) } SwiftUIWebView(viewModel: viewModel, url: url) + .refreshable { + viewModel.webView.reload() + } + } + } + + private func refreshPage() { + if let currentURL = viewModel.webView.url { + viewModel.webView.load(URLRequest(url: currentURL)) } } } struct SwiftUIWebView: UIViewRepresentable { typealias UIViewType = WKWebView - - private let url: URL? + + private let url: URLRequest? private let viewModel: WebViewViewModel - - init(viewModel: WebViewViewModel, url: URL?) { + + init(viewModel: WebViewViewModel, url: URLRequest?) { self.viewModel = viewModel self.url = url } - + func makeUIView(context: Context) -> WKWebView { - self.viewModel.webView + viewModel.webView } - + func updateUIView(_ uiView: WKWebView, context: Context) { print("WebView URL: \(String(describing: url))") if let url { - self.viewModel.webView.load(URLRequest(url: url)) + viewModel.webView.load(url) } else { - self.viewModel.webView.load(URLRequest(url: URL(string:"about:blank")!)) + viewModel.webView.load(URLRequest(url: URL(string: "about:blank")!)) } } } struct SwiftUIWebView_Previews: PreviewProvider { static var previews: some View { - WebView(url: URL(string: "https://www.devtechie.com")!, viewModel: WebViewViewModel()) + WebView(url: URLRequest(url: URL(string: "https://www.devtechie.com")!), viewModel: WebViewViewModel()) } } diff --git a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift index 4172572f4..c35ec1f2f 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,13 +17,13 @@ import Foundation import WebKit protocol WebViewListener { - func onRedirect(navigationAction: WKNavigationAction) -> WKNavigationActionPolicy + func onRedirect(navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy } class WebViewViewModel: NSObject, ObservableObject { private static let webViewProgressObserverKey = "estimatedProgress" let webView = WKWebView(frame: .zero) - + var delegate: WebViewListener? @Published var progress: Float = 0 @@ -50,6 +50,7 @@ extension WebViewViewModel: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { print("WebView didFinish") + Napier.event(.urlOpen, message: "WebView: \(webView.url?.absoluteString ?? "")") } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { @@ -60,7 +61,6 @@ extension WebViewViewModel: WKNavigationDelegate { print("WebView didStartProviisonalNavigation") } - @available(iOS 14.5, *) func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { print("WebView didBecome download") } @@ -74,6 +74,9 @@ extension WebViewViewModel: WKNavigationDelegate { } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { - return delegate?.onRedirect(navigationAction: navigationAction) ?? .allow + if let delegate = delegate { + return await delegate.onRedirect(navigationAction: navigationAction) + } + return .allow } } diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 2d604fa25..30c89ab5c 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -7,92 +7,94 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct ConsentView: View { - @StateObject var viewModel: ConsentViewModel + @StateObject private var viewModel: ConsentViewModel + @ObservedObject private var registration: RegistrationObservable + + init(registration: RegistrationObservable) { + _registration = ObservedObject(wrappedValue: registration) + _viewModel = StateObject(wrappedValue: ConsentViewModel(registrationService: registration.service)) + } - private let stringsTable = "ConsentView" - private let taskStringTable = "TaskDetail" var body: some View { - VStack { - Title2(titleText: viewModel.permissionModel.studyTitle) - .padding(.bottom, 30) + if let permissionModel = viewModel.permissionModel { + VStack { + Title2(titleText: permissionModel.studyTitle) + .padding(.bottom, 30) - ScrollView { - ExpandableText(viewModel.permissionModel.studyParticipantInfo, String.localize(forKey: "Participant Information", withComment: "Participant Information of study.", inTable: taskStringTable), lineLimit: 4) - .padding(.bottom, 35) - - ConsentList(permissionModel: viewModel.permissionModel) - } - - Spacer() - if viewModel.isLoading { - ProgressView() - .progressViewStyle(.circular) - } else { - MoreActionButton(disabled: $viewModel.requestedPermissions, alertOpen: $viewModel.showErrorAlert) { - viewModel.requestPermissions() - } label: { - VStack { - if viewModel.requestedPermissions { - ProgressView() - .progressViewStyle(.circular) - } else { - Text(verbatim: .localize( - forKey: "accept_button", - withComment: "Button to accept the study consent", inTable: stringsTable)) - } - } - } errorAlert: { - Alert(title: - Text(verbatim: .localize( - forKey: "permissions_denied", - withComment: "Error dialog title", inTable: stringsTable)) - .foregroundColor(.more.important), - message: Text(viewModel.error), - primaryButton: .default(Text( - verbatim: .localize( - forKey: "to_settings", - withComment: "Dialog button to retry sending your consent for this study", inTable: stringsTable)), - action: { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } + ScrollView { + ExpandableText(permissionModel.studyParticipantInfo, "Participant Information", lineLimit: 4) + .padding(.bottom, 35) - }), - secondaryButton: .cancel({ viewModel.decline() }) - ) + ConsentList(permissionModel: permissionModel) } + Spacer() - MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { - viewModel.decline() - } label: { - Text(verbatim: .localize( - forKey: "decline_button", - withComment: "Button to decline the study", inTable: stringsTable)) + if registration.isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.more.primary) + } else { + MoreActionButton(disabled: .constant(viewModel.requestedPermissions || registration.isLoading), alertOpen: $viewModel.showErrorAlert) { + Napier.event(.buttonPress, message: "Consent accepted") + viewModel.requestPermissions() + } label: { + VStack { + if viewModel.requestedPermissions { + ProgressView() + .progressViewStyle(.circular) + .tint(.more.primary) + } else { + Text("accept_button") + } + } + } + Spacer() + MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { + Napier.event(.buttonPress, message: "Consent declined") + viewModel.decline() + } label: { + Text("decline_button") + } } } - } - .padding(24) - .onAppear { - viewModel.onAppear() - } - .onDisappear { - viewModel.onDisappear() + .padding(24) + .onAppear { + viewModel.onAppear() + } + .onDisappear { + viewModel.onDisappear() + } + } else { + StudyLoadingView() } } } -struct ConsentView_Previews: PreviewProvider { - static var previews: some View { - ConsentView(viewModel: ConsentViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(dataManager: ObservationDataManager()), dataRecorder: IOSDataRecorder())))) - } +#Preview("ConsentView") { + let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + let repos = MainRepositoryImpl(appDatabase: database) + let dataManager = iOSObservationDataManager(repository: repos, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) + let userDefaults = UserDefaultsRepository() + let shared = Shared( + localNotificationListener: LocalPushNotifications(), + repositories: repos, + sharedStorageRepository: userDefaults, + observationDataManager: dataManager, + mainBluetoothConnector: IOSBluetoothConnector(), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), + dataRecorder: IOSDataRecorder(), + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() + ) + let registration = RegistrationObservable(service: RegistrationService(shared: shared)) + ConsentView(registration: registration) } diff --git a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index 349515123..ccd723cda 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -7,133 +7,102 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import shared -import UIKit import AVFoundation +import Combine +import KMPNativeCoroutinesCombine +import UIKit +import shared -protocol ConsentViewModelListener { - func credentialsStored() - func decline() - func credentialsDeleted() -} - -class ConsentViewModel: NSObject, ObservableObject { - private let coreModel: CorePermissionViewModel - var consentInfo: String? = nil - var delegate: ConsentViewModelListener? = nil +class ConsentViewModel: ObservableObject { + private let coreModel: CoreConsentViewModel + private let registration: RegistrationService + var consentInfo: String? private let stringTable = "SettingsView" - - @Published private(set) var permissionModel: PermissionModel = PermissionModel(studyTitle: "Title", studyParticipantInfo: "Info", studyConsentInfo: String.localize(forKey: "study_consent", withComment: "Consent of the study", inTable: "SettingsView"), consentInfo: []) { - didSet { - self.permissionManager.setPermissionValues(observationPermissions: AppDelegate.shared.observationFactory.studySensorPermissions()) - } - } - @Published var isLoading = false - @Published var error: String = "" + + @Published var permissionModel: PermissionModel? = nil @Published var showErrorAlert: Bool = false @Published var requestedPermissions = false + private var cancellables = Set() + lazy var permissionManager = PermissionManager() var permissionGranted = false - + init(registrationService: RegistrationService) { - print("ConsentViewModel allocated!") - coreModel = CorePermissionViewModel(registrationService: registrationService, studyConsentTitle: String.localize(forKey: "study_consent", withComment: "Consent of the study", inTable: stringTable)) - super.init() - coreModel.onConsentModelChange { model in - DispatchQueue.main.async { - self.permissionModel = model - } - } - coreModel.onLoadingChange { loading in - if let loading = loading as? Bool { - DispatchQueue.main.async { - self.isLoading = loading - } + registration = registrationService + coreModel = CoreConsentViewModel(registrationService: registrationService, studyConsentTitle: String(localized: "study_consent")) + + createPublisher(for: coreModel.permissions) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] model in + self?.permissionModel = model } - } + .store(in: &cancellables) } - + func onAppear() { - coreModel.viewDidAppear() permissionManager.observer = self + coreModel.viewDidAppear() } func onDisappear() { - coreModel.viewDidDisappear() permissionManager.observer = nil + coreModel.viewDidDisappear() } - + func resetPermissionRequest() { - self.requestedPermissions = false - self.permissionManager.resetRequest() + requestedPermissions = false + permissionManager.resetRequest() } func requestPermissions() { - self.requestedPermissions = true + requestedPermissions = true permissionManager.requestPermission(permissionRequest: true) } - func reloadPermissions() { - coreModel.onConsentModelChange { model in - self.permissionModel = model - } - } - private func acceptConsent() { - if let consentInfo, let uniqueId = UIDevice.current.identifierForVendor?.uuidString { - coreModel.acceptConsent(consentInfoMd5: consentInfo.toMD5(), uniqueDeviceId: uniqueId) { credentialsStored in - DispatchQueue.main.async { - self.delegate?.credentialsStored() - } - } onError: { error in - if let error { - DispatchQueue.main.async { - self.error = error.message - } - } - } + if let uniqueId = UIDevice.current.identifierForVendor?.uuidString { + registration.acceptConsent(uniqueDeviceId: uniqueId) } } - - func buildConsentModel() { - coreModel.buildConsentModel() - } - + func decline() { - coreModel.declineConsent() - delegate?.decline() - } - - deinit { - print("ConsentViewModel deallocated!") + registration.declineConsent() } } extension ConsentViewModel: PermissionManagerObserver { func accepted() { - if permissionManager.anyNeededPermissionDeclined() { - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "Required Permissions Were Not Granted", message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", positiveTitle: "Proceed to Settings", negativeTitle: "Proceed Without Granting Permissions", onPositive: { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - AlertController.shared.closeAlertDialog() - self.resetPermissionRequest() - }, onNegative: { + Task { @MainActor in + if permissionManager.anyNeededPermissionDeclined() { + AlertController.shared.openAlertDialog( + model: + AlertDialogModel.companion.fromStrings( + title: "Required Permissions Were Not Granted", + message: "This study requires one or more sensor permissions to function correctly. You may choose to decline these permissions; however, doing so may result in the application and study not functioning fully or as expected. Would you like to navigate to settings to allow the app access to these necessary permissions?", + confirmLabel: "Proceed to Settings", + cancelLabel: "Proceed Without Granting Permissions", + onConfirm: { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + self.resetPermissionRequest() + }, + onDecline: { + self.acceptConsent() + self.requestedPermissions = false + })) + } else { self.acceptConsent() self.requestedPermissions = false - AlertController.shared.closeAlertDialog() - })) - } else { - self.acceptConsent() - self.requestedPermissions = false + } } - } } diff --git a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift index 4a5e802b0..04126a5b3 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift @@ -19,10 +19,10 @@ struct DashboardFilterView: View { var body: some View { ScrollView { VStack { - SectionHeading(sectionTitle: String.localize(forKey: "Select Time", withComment: "Set time filter", inTable: stringTable)) + SectionHeading(sectionTitle: "Select Time") .padding(15) Divider() - + ForEach(viewModel.currentDateFilter.keys.sorted { $0.sortIndex < $1.sortIndex }, id: \.self) { filter in if let selected = viewModel.currentDateFilter[filter]?.boolValue { Button { @@ -39,23 +39,23 @@ struct DashboardFilterView: View { } } }.padding(.vertical, 20) - + VStack { - SectionHeading(sectionTitle: String.localize(forKey: "Select Type", withComment: "Set titypeme filter", inTable: stringTable)) + SectionHeading(sectionTitle: "Select Type") .padding(15) Divider() - + Button { viewModel.clearTypeFilter() } label: { HStack { - MoreFilterOption(option: String.localize(forKey: "All Items", withComment: "String for All Items", inTable: stringTable), isSelected: $viewModel.typeFilterActive) + MoreFilterOption(option: "All Items", isSelected: $viewModel.typeFilterActive) Spacer() } } .buttonStyle(.borderless) .frame(maxWidth: .infinity) - + Divider() ForEach(viewModel.currentTypeFilter.keys.sorted(), id: \.self) { filter in if let selected = viewModel.currentTypeFilter[filter]?.boolValue { @@ -69,13 +69,13 @@ struct DashboardFilterView: View { } .buttonStyle(.borderless) .frame(maxWidth: .infinity) - + Divider() } } } Spacer() } - .customNavigationTitle(with: NavigationScreen.dashboardFilter.localize(useTable: navigationStrings, withComment: "Select Dashboard Filter")) + .customNavigationTitle(with: NavigationScreen.dashboardFilter.localize()) } } diff --git a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift index 58bbcaa11..3ce8a6051 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift @@ -14,21 +14,20 @@ protocol DashboardFilterObserver { } class DashboardFilterViewModel: ObservableObject { - let coreViewModel: CoreDashboardFilterViewModel = CoreDashboardFilterViewModel() - private let stringTable = "DashboardFilter" - - var delegate: DashboardFilterObserver? = nil - + let coreViewModel: CoreDashboardFilterViewModel = CoreDashboardFilterViewModel(repository: AppDelegate.shared.repositories) + + var delegate: DashboardFilterObserver? + @Published var currentTypeFilter: [String: KotlinBoolean] = [:] @Published var typeFilterActive = false - + @Published var currentDateFilter: [DateFilter: KotlinBoolean] = [:] - + init() { coreViewModel.onNewDateFilter { [weak self] dateFilter in self?.currentDateFilter = dateFilter } - + coreViewModel.onNewTypeFilter { [weak self] typeFilter in if let self { self.currentTypeFilter = typeFilter @@ -36,34 +35,34 @@ class DashboardFilterViewModel: ObservableObject { } } } - + func viewDidAppear() { coreViewModel.viewDidAppear() } - + func viewDidDisappear() { coreViewModel.viewDidDisappear() } - + func toggleTypeFilter(type: String) { coreViewModel.toggleTypeFilter(type: type) } - + func clearTypeFilter() { coreViewModel.clearTypeFilters() } - + func toggleDateFilter(dateFilter: DateFilter) { coreViewModel.toggleDateFilter(date: dateFilter) } - - func updateFilterText() -> String { - return self.delegate?.updateFilterText() ?? "" + + func updateFilterText() -> String { + return delegate?.updateFilterText() ?? "" } - + func isItemSelected(selectedValuesInList: [String], option: String) -> Bool { var isSelected = false - let allItemsString = String.localize(forKey: "All Items", withComment: "String for All Items", inTable: stringTable) + let allItemsString = String(localized: "All Items") if option == allItemsString && selectedValuesInList.isEmpty { isSelected = true } else { diff --git a/iosApp/iosApp/Views/Dashboard/DashboardPicker.swift b/iosApp/iosApp/Views/Dashboard/DashboardPicker.swift deleted file mode 100644 index 6bf7964b8..000000000 --- a/iosApp/iosApp/Views/Dashboard/DashboardPicker.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// DashboardPicker.swift -// iosApp -// -// Created by Julia Mayrhauser on 07.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - -struct DashboardPicker: View { - @Binding var selection: Int - private let stringTable = "DashboardView" - var firstTab: String - var secondTab: String - var body: some View { - Picker ("", selection: $selection){ - BasicText(text: firstTab) - .tag(0) - BasicText(text: secondTab).tag(1) - }.pickerStyle(.segmented) - .frame(height: 50) - .colorMultiply(Color.more.primaryLight) - } -} - -struct DashboardPicker_Previews: PreviewProvider { - static var previews: some View { - DashboardPicker(selection: .constant(0), firstTab: "tab 1", secondTab: "tab 2") - } -} diff --git a/iosApp/iosApp/Views/Dashboard/DashboardView.swift b/iosApp/iosApp/Views/Dashboard/DashboardView.swift index 51b77e0de..84f678e08 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardView.swift @@ -18,27 +18,25 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject private var navigationModalState: NavigationModalState - @StateObject var viewModel: DashboardViewModel - private let stringTable = "DashboardView" + @StateObject var viewModel: ScheduleViewModel @State var totalTasks: Double = 0 @State var selection: Int = 0 @State var tasksCompleted: Double = 0 - private let navigationStrings = "Navigation" var body: some View { VStack { - ScheduleListHeader(scheduleViewModel: viewModel.scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) + ScheduleListHeader(scheduleViewModel: viewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) if selection == 0 { - ScheduleView(viewModel: viewModel.scheduleViewModel) + ScheduleView(viewModel: viewModel) } else { EmptyView() } } - .customNavigationTitle(with: NavigationScreen.dashboard.localize(useTable: navigationStrings, withComment: "Dashboard title"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.dashboard.localize(), displayMode: .inline) .onAppear { - viewModel.viewDidAppear() + viewModel.coreModel.viewDidAppear() } .onDisappear { - viewModel.viewDidDisappear() + viewModel.coreModel.viewDidDisappear() } } } @@ -46,7 +44,7 @@ struct DashboardView: View { struct DashboardView_Previews: PreviewProvider { static var previews: some View { MoreMainBackgroundView { - DashboardView(viewModel: DashboardViewModel(scheduleViewModel: ScheduleViewModel(scheduleListType: .all))) + DashboardView(viewModel: ScheduleViewModel(scheduleListType: .all)) .environmentObject(ContentViewModel()) } } diff --git a/iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift b/iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift deleted file mode 100644 index d4fda7533..000000000 --- a/iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// DashboardViewModel.swift -// iosApp -// -// Created by Julia Mayrhauser on 02.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import shared - -class DashboardViewModel: ObservableObject { - private let coreViewModel: CoreDashboardViewModel = CoreDashboardViewModel() - let scheduleViewModel: ScheduleViewModel - - @Published var studyTitle: String = "" - @Published var study: StudySchema? = StudySchema() - @Published var filterText: String = "" - - init(scheduleViewModel: ScheduleViewModel) { - self.scheduleViewModel = scheduleViewModel - coreViewModel.onLoadStudy { study in - if let study { - self.study = study - self.studyTitle = study.studyTitle - } - } - self.filterText = String.localize(forKey: "no_filter_activated", withComment: "String for no filter set", inTable: "DashboardFilter") - } - - func viewDidAppear() { - coreViewModel.viewDidAppear() - } - - func viewDidDisappear() { - coreViewModel.viewDidDisappear() - } -} diff --git a/iosApp/iosApp/Views/Dashboard/StudyTitleForwardButton.swift b/iosApp/iosApp/Views/Dashboard/StudyTitleForwardButton.swift deleted file mode 100644 index ca9b50c20..000000000 --- a/iosApp/iosApp/Views/Dashboard/StudyTitleForwardButton.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// StudyTitleForwardButton.swift -// iosApp -// -// Created by Julia Mayrhauser on 07.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import SwiftUI - -struct StudyTitleForwardButton: View { - var title: String - let action: () -> Void = {} - - var body: some View { - Button { - action() - } label: { - HStack { - Title(titleText: title) - Spacer() - Image(systemName: "chevron.forward") - } - } - } -} - -struct StudyTitleForwardButton_Previews: PreviewProvider { - static var previews: some View { - StudyTitleForwardButton(title: "Study Title") - } -} diff --git a/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift new file mode 100644 index 000000000..a9b6050c8 --- /dev/null +++ b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift @@ -0,0 +1,52 @@ +// +// GarminConnectView.swift +// More +// +// Created by Jan Cortiel on 13.11.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import SwiftUI + +struct GarminConnectView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = GarminConnectViewModel() + + var body: some View { + MoreMainBackgroundView(contentPadding: 0) { + VStack { + if let url = viewModel.getUrl() { + WebView(url: url, viewModel: viewModel.webViewModel) + .ignoresSafeArea(.all, edges: .bottom) + } else { + Text("Could not receive the url") + } + } + } + .customNavigationTitle(with: NavigationScreen.garminConnect.localize(), displayMode: .inline) + .toolbar { + Button { + viewModel.coreViewModel.closeView() + } label: { + Image(systemName: "chevron.down") + .foregroundColor(.more.important) + } + } + .onAppear { + viewModel.coreViewModel.viewDidAppear() + viewModel.clearAllWebViewData() + } + .onDisappear() { + viewModel.coreViewModel.viewDidDisappear() + } + .onReceive(viewModel.$shouldClose.removeDuplicates()) { close in + if close { + dismiss() + } + } + } +} + +#Preview { + GarminConnectView() +} diff --git a/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift b/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift new file mode 100644 index 000000000..3ef90142d --- /dev/null +++ b/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift @@ -0,0 +1,156 @@ +// +// GarminConnectViewModel.swift +// More +// +// Created by Jan Cortiel on 13.11.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import Foundation +import shared +import KMPNativeCoroutinesCombine +import Combine +import WebKit + +class GarminConnectViewModel: ObservableObject { + let coreViewModel = CoreGarminConnectViewModel(networkService: AppDelegate.shared.networkService, sharedStorageRepository: AppDelegate.shared.sharedStorageRepository) + let webViewModel = WebViewViewModel() + + @Published var isLoading = false + @Published var shouldClose = false + private var didHandleCallback = false + private var allowedHost: String? = nil + private var injectedURLs: Set = [] + + private var cancellables: Set = [] + + init() { + webViewModel.delegate = self + createPublisher(for: coreViewModel.isLoading) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] isLoading in + self?.isLoading = isLoading.boolValue + } + .store(in: &cancellables) + } + + func getUrl() -> URLRequest? { + if let url = coreViewModel.garminSSOUrl()?.description(), let requestUrl = URL(string: url) { + print(url) + var request = URLRequest(url: requestUrl) + + allowedHost = requestUrl.host + + request.setValue(coreViewModel.basicAuthHeader(forUrl: url), forHTTPHeaderField: "Authorization") + return request + } + return nil + } + + func handleCallbackIfNeeded(_ url: URL) { + guard !didHandleCallback, + coreViewModel.checkIfUrlIsCallback(url: url.absoluteString) + else { + return + } + + didHandleCallback = true + coreViewModel.setLoading(state: false) + + var failure = true + Task { + do { + if try await coreViewModel.sendCallback(url: url.absoluteString).boolValue { + failure = false + await MainActor.run { [weak self] in + self?.coreViewModel.onSuccess() + self?.shouldClose = true + } + } + } catch { + print("Exception during Callback \(error)") + didHandleCallback = false + } + if failure { + await MainActor.run { + let dialog = AlertDialogModel.companion.fromStrings(title: "Error during Callback", message: "Error accessing your Garmin Connect Account! Please try again later!", confirmLabel: "Ok", cancelLabel: nil, onConfirm: { [weak self] in + self?.coreViewModel.closeView() + self?.shouldClose = true + }) + AlertController.shared.openAlertDialog(model: dialog) + } + } + } + } + + func clearAllWebViewData(completion: (() -> Void)? = nil) { + let dataStore = WKWebsiteDataStore.default() + let types = WKWebsiteDataStore.allWebsiteDataTypes() + + dataStore.fetchDataRecords(ofTypes: types) { records in + WKWebsiteDataStore.default().removeData(ofTypes: types, for: records) { + completion?() + } + } + } + + +} + +extension GarminConnectViewModel: WebViewListener { + func onRedirect(navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + guard await navigationAction.targetFrame?.isMainFrame == true, + let url = await navigationAction.request.url + else { + return .allow + } + + + if coreViewModel.checkIfUrlIsCallback(url: url.absoluteString) { + await MainActor.run { + self.handleCallbackIfNeeded(url) + } + return .cancel + } + + + if let allowedHost, let host = url.host, !host.hasSuffix(allowedHost) { + return .allow + } + + + let urlKey = url.absoluteString + if injectedURLs.contains(urlKey) { + injectedURLs.remove(urlKey) + return .allow + } + + + if await navigationAction.request.value(forHTTPHeaderField: "Authorization") != nil { + return .allow + } + + + guard let authHeader = coreViewModel.basicAuthHeader(forUrl: url.absoluteString) else { + return .allow + } + + + var request = await navigationAction.request + + request.url = url + + if request.httpMethod == nil { + request.httpMethod = "GET" + } + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + + + injectedURLs.insert(urlKey) + + await webViewModel.webView.load(request) + return .cancel + } +} + diff --git a/iosApp/iosApp/Views/Info/ContactInfo.swift b/iosApp/iosApp/Views/Info/ContactInfo.swift index 4d7a90d60..1655aeaae 100644 --- a/iosApp/iosApp/Views/Info/ContactInfo.swift +++ b/iosApp/iosApp/Views/Info/ContactInfo.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,12 +18,12 @@ import SwiftUI struct ContactInfo: View { var title: String var info: String - + let contactInstitute: String? let contactPerson: String? let contactEmail: String? let contactPhoneNumber: String? - + var body: some View { VStack { HStack(spacing: 10) { @@ -42,7 +42,7 @@ struct ContactInfo: View { .padding(.bottom, 18) .multilineTextAlignment(.center) } - + if contactInstitute != nil { BasicText( text: contactInstitute ?? "", @@ -51,7 +51,7 @@ struct ContactInfo: View { .padding(.bottom, 9) .multilineTextAlignment(.center) } - + if contactPerson != nil { BasicText( text: contactPerson ?? "", @@ -61,7 +61,7 @@ struct ContactInfo: View { .padding(.bottom, 0) .multilineTextAlignment(.center) } - + if contactEmail != nil { BasicText( text: contactEmail ?? "", @@ -71,7 +71,7 @@ struct ContactInfo: View { .padding(.bottom, 0) .multilineTextAlignment(.center) } - + if contactPhoneNumber != nil { BasicText( text: contactPhoneNumber ?? "", @@ -81,23 +81,22 @@ struct ContactInfo: View { .padding(.bottom, 0) .multilineTextAlignment(.center) } - + if contactPerson != nil || contactEmail != nil || contactPhoneNumber != nil { Divider() .frame(height: 36) - + BasicText( text: info, color: .more.secondary, font: .system(size: 14) ) - .multilineTextAlignment(.center) + .multilineTextAlignment(.center) Spacer() } } Spacer() } } - } } diff --git a/iosApp/iosApp/Views/Info/InfoList.swift b/iosApp/iosApp/Views/Info/InfoList.swift index 4f522a2a4..0386855e8 100644 --- a/iosApp/iosApp/Views/Info/InfoList.swift +++ b/iosApp/iosApp/Views/Info/InfoList.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,15 +17,14 @@ import SwiftUI struct InfoList: View { @EnvironmentObject var contentViewModel: ContentViewModel - private let stringTable = "Info" var body: some View { VStack(spacing: 14) { - InfoListItem(title: String.localize(forKey: "Study Details", withComment: "Shows detail description of the study and it's observation moduls.", inTable: stringTable), icon: "info.circle.fill", destination: .studyDetails) - InfoListItem(title: String.localize(forKey: "Running Observations", withComment: "Shows a detailed list of running observation", inTable: stringTable), icon: "arrow.triangle.2.circlepath", destination: .runningObservations) - InfoListItem(title: String.localize(forKey: "Past Observations", withComment: "Shows a detailed list of past observations", inTable: stringTable), icon: "checkmark", destination: .pastObservations) - InfoListItem(title: String.localize(forKey: "Devices", withComment: "Lists all connected or needed devices.", inTable: stringTable), icon: "applewatch", destination: .bluetoothConnections) - InfoListItem(title: String.localize(forKey: "Settings", withComment: "Shows the settings for the study.", inTable: stringTable), icon: "gearshape.fill", destination: .settings) - InfoListItem(title: String.localize(forKey: "Leave Study", withComment: "Leave the study for good.", inTable: stringTable), icon: "rectangle.portrait.and.arrow.right", destination: .withdrawStudy) + InfoListItem(title: "Study Details", icon: "info.circle.fill", destination: .studyDetails) + InfoListItem(title: "Running Observations", icon: "arrow.triangle.2.circlepath", destination: .runningObservations) + InfoListItem(title: "Past Observations", icon: "checkmark", destination: .pastObservations) + InfoListItem(title: "Devices", icon: "applewatch", destination: .bluetoothConnections) + InfoListItem(title: "Settings", icon: "gearshape.fill", destination: .settings) + InfoListItem(title: "Leave Study", icon: "rectangle.portrait.and.arrow.right", destination: .withdrawStudy) } } } diff --git a/iosApp/iosApp/Views/Info/InfoListItem.swift b/iosApp/iosApp/Views/Info/InfoListItem.swift index f0a89707d..28c064ea9 100644 --- a/iosApp/iosApp/Views/Info/InfoListItem.swift +++ b/iosApp/iosApp/Views/Info/InfoListItem.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -31,7 +31,6 @@ struct InfoListItem: View { NavigationText(text: title) Spacer() } - } Divider() } diff --git a/iosApp/iosApp/Views/Info/InfoListItemModal.swift b/iosApp/iosApp/Views/Info/InfoListItemModal.swift index 6f0b4fb85..6658f4913 100644 --- a/iosApp/iosApp/Views/Info/InfoListItemModal.swift +++ b/iosApp/iosApp/Views/Info/InfoListItemModal.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -23,7 +23,6 @@ struct InfoListItemModal: View { let action: () -> Void var body: some View { VStack(spacing: 14) { - Button( action: action, label: { @@ -35,16 +34,14 @@ struct InfoListItemModal: View { } } ) - + Divider() } } } - struct InfoListItemModal_Previews: PreviewProvider { static var previews: some View { InfoListItem(title: "Test", icon: "info.circle", destination: .settings) } } - diff --git a/iosApp/iosApp/Views/Info/InfoView.swift b/iosApp/iosApp/Views/Info/InfoView.swift index c5b88e344..d58844612 100644 --- a/iosApp/iosApp/Views/Info/InfoView.swift +++ b/iosApp/iosApp/Views/Info/InfoView.swift @@ -16,53 +16,51 @@ import SwiftUI struct InfoView: View { - @StateObject var viewModel: InfoViewModel - private let navigationStrings = "Navigation" - private let infoStrings = "Info" - + let viewModel: InfoViewModel @EnvironmentObject private var navigationModalState: NavigationModalState + var body: some View { ScrollView { Divider() VStack { InfoList() - .hideListRowSeparator() + .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .listRowBackground(Color.more.primaryLight) .padding(.top, 7) Spacer() } .listStyle(.plain) - .clearListBackground() - + .scrollContentBackground(.hidden) + Spacer() - + if let id = viewModel.participantId, let alias = viewModel.participantAlias { HStack(alignment: .center) { - BasicText(text: "\("Participant".localize(withComment: "Participant ID", useTable: infoStrings)) \(id): \(alias)", color: .more.secondary) + BasicText(text: "\("Participant") \(id): \(alias)", color: .more.secondary) } Divider() } - + ContactInfo( - title: String.localize(forKey: "info_contact_title", withComment: "Contact us.", inTable: infoStrings), - info: String.localize(forKey: "info_disclaimer", withComment: "Contact us.", inTable: infoStrings), + title: "info_contact_title", + info: "info_disclaimer", contactInstitute: viewModel.contactInstitute, contactPerson: viewModel.contactPerson, contactEmail: viewModel.contactEmail, contactPhoneNumber: viewModel.contactPhoneNumber ) - + Spacer() AppVersion() } .padding(.horizontal, 10) - .customNavigationTitle(with: NavigationScreen.info.localize(useTable: navigationStrings, withComment: "Information Title")) + .customNavigationTitle(with: NavigationScreen.info.localize()) .onAppear { - viewModel.viewDidAppear() + viewModel.studyCoreModel.viewDidAppear() } .onDisappear { - viewModel.viewDidDisappear() + viewModel.studyCoreModel.viewDidDisappear() } } } diff --git a/iosApp/iosApp/Views/Info/InfoViewModel.swift b/iosApp/iosApp/Views/Info/InfoViewModel.swift index 4fb0c20ad..a0a125872 100644 --- a/iosApp/iosApp/Views/Info/InfoViewModel.swift +++ b/iosApp/iosApp/Views/Info/InfoViewModel.swift @@ -7,17 +7,18 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import Combine +import KMPNativeCoroutinesCombine import shared class InfoViewModel: ObservableObject { - - private let studyCoreModel = CoreStudyDetailsViewModel() + let studyCoreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared, customViewIdentifier: NavigationRoute.info.viewIdentifier) @Published var studyTitle: String? @Published var contactInstitute: String? @Published var contactPerson: String? @@ -25,11 +26,14 @@ class InfoViewModel: ObservableObject { @Published var contactPhoneNumber: String? @Published var participantId: Int? @Published var participantAlias: String? - + + private var cancellables = Set() + init() { - studyCoreModel.onLoadStudyDetails() { - studyDetails in - if let studyDetails { + createPublisher(for: studyCoreModel.studyModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] studyDetails in + if let self, let studyDetails { self.studyTitle = studyDetails.study.studyTitle self.contactInstitute = studyDetails.study.contactInstitute self.contactPerson = studyDetails.study.contactPerson @@ -39,13 +43,6 @@ class InfoViewModel: ObservableObject { self.participantAlias = studyDetails.study.participantAlias } } - } - - func viewDidAppear() { - studyCoreModel.viewDidAppear() - } - - func viewDidDisappear() { - studyCoreModel.viewDidDisappear() + .store(in: &cancellables) } } diff --git a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift index 5e8283e3d..e05a92c1b 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift @@ -7,30 +7,25 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import SwiftUI + struct LeaveStudyConfirmationView: View { @StateObject var viewModel: SettingsViewModel - @EnvironmentObject private var contentViewModel: ContentViewModel @EnvironmentObject private var navigationModalState: NavigationModalState - var float = CGFloat(0) - - private let stringTable = "SettingsView" - private let navigationStrings = "Navigation" - + @State private var simpleRightDirectionSliderOffsetX: CGFloat = 0 @State private var simpleLeftDirectionSliderOffsetX: CGFloat = 0 @State private var rectangularSliderOffsetX: CGFloat = 0 - @State private var neumorphicSliderOffsetX: CGFloat = 0 + @State private var neumorphicSliderOffsetX: CGFloat = 0 @State private var alertPresented: Bool = false @State var continueButton = Color.more.approved - - + var body: some View { MoreMainBackgroundView { VStack { @@ -38,28 +33,28 @@ struct LeaveStudyConfirmationView: View { .padding(.vertical) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - + Spacer() - + Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 60)) .foregroundColor(Color.more.important) .padding() - - Text(String.localize(forKey: "second_message", withComment: "second exit message", inTable: stringTable)) + + Text("leave_confirmation_message") .foregroundColor(Color.more.secondary) .padding(.bottom, 2) .multilineTextAlignment(.center) - - Text(String.localize(forKey: "sure_message", withComment: "last exit message", inTable: stringTable)) + + Text("sure_message") .foregroundColor(Color.more.primary) .fontWeight(.bold) .padding(.bottom, 2) .multilineTextAlignment(.center) - + Spacer() .frame(height: 150) - + MoreActionButton( backgroundColor: .more.approved, disabled: .constant(false) @@ -67,28 +62,21 @@ struct LeaveStudyConfirmationView: View { navigationModalState.closeView(screen: .withdrawStudy) navigationModalState.closeView(screen: .withdrawStudyConfirm) } label: { - Text(String.localize(forKey: "continue_study", withComment: "button to continue study", inTable: stringTable)).foregroundColor(Color.more.white) + Text("continue_study").foregroundColor(Color.more.white) } .padding(.bottom, 2) - + MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { viewModel.leaveStudy() navigationModalState.clearViews() } label: { - Text(String.localize(forKey: "withdraw", withComment: "button to exit study", inTable: stringTable)) + Text("withdraw") } - + Spacer() } .padding(.horizontal, 40) } - .customNavigationTitle(with: NavigationScreen.settings.localize(useTable: navigationStrings, withComment: "Settings Screen")) - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear{ - viewModel.viewDidDisappear() - } - + .customNavigationTitle(with: NavigationScreen.settings.localize()) } } diff --git a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift index 432e14949..35e824a72 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift @@ -7,24 +7,23 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import SwiftUI +import shared struct LeaveStudyView: View { - @StateObject var viewModel: SettingsViewModel + private let viewModel: SettingsViewModel = SettingsViewModel(viewIdentifier: NavigationRoute.leaveStudy.viewIdentifier) @EnvironmentObject var contentViewModel: ContentViewModel @EnvironmentObject private var navigationModalState: NavigationModalState - private let stringTable = "SettingsView" - private let navigationStrings = "Navigation" @State var accButton = Color.more.approved @State var decButton = Color.more.important - + var body: some View { MoreMainBackgroundView { VStack(alignment: .center) { @@ -32,58 +31,56 @@ struct LeaveStudyView: View { .padding(.vertical) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - + Spacer() - + Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 60)) .foregroundColor(Color.more.important) .padding() - - SectionHeading(sectionTitle: String.localize(forKey: "first_message", withComment: "exit message", inTable: stringTable)) + + SectionHeading(sectionTitle: "first_message") .foregroundColor(Color.more.important) .padding(.bottom, 2) .multilineTextAlignment(.center) - + Spacer() - - Text(String.localize(forKey: "really_message", withComment: "second question message", inTable: stringTable)) + + Text("really_message") .padding(.bottom) - - + MoreActionButton( backgroundColor: .more.approved, disabled: .constant(false) ) { navigationModalState.closeView(screen: .withdrawStudy) } label: { - Text(String.localize(forKey: "continue_study", withComment: "button to continue study", inTable: stringTable)).foregroundColor(Color.more.white) + Text("continue_study").foregroundColor(Color.more.white) } .padding(.bottom, 2) - + MoreActionButton( backgroundColor: .more.important, disabled: .constant(false) ) { navigationModalState.openView(screen: .withdrawStudyConfirm) } label: { - Text(String.localize(forKey: "withdraw_study", withComment: "button to withdraw study", inTable: stringTable)).foregroundColor(Color.more.white) + Text("withdraw_study").foregroundColor(Color.more.white) } - - + Spacer() } .padding(.horizontal, 40) } .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .withdrawStudyConfirm)) { - LeaveStudyConfirmationView(viewModel: contentViewModel.settingsViewModel) + LeaveStudyConfirmationView(viewModel: viewModel) } - .customNavigationTitle(with: NavigationScreen.withdrawStudy.localize(useTable: navigationStrings, withComment: "Withdraw from Study")) + .customNavigationTitle(with: NavigationScreen.withdrawStudy.localize()) .onAppear { - viewModel.viewDidAppear() + viewModel.coreViewModel.viewDidAppear() } - .onDisappear{ - viewModel.viewDidDisappear() + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() } } } diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift index 866950055..117bdaf61 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift @@ -16,24 +16,27 @@ import SwiftUI struct LimeSurveyView: View { - @StateObject var viewModel: LimeSurveyViewModel + @StateObject private var viewModel: LimeSurveyViewModel + @Environment(\.dismiss) private var dismiss + + init(navigationState: NavigationState) { + _viewModel = StateObject(wrappedValue: LimeSurveyViewModel(navigationState: navigationState)) + } - private let stringsTable = "LimeSurvey" var body: some View { - MoreMainBackgroundView(contentPadding: 0) { + MoreMainBackgroundView(contentPadding: 0) { VStack { if viewModel.dataLoading { HStack { Text("Data is loading...") } - } else { - - WebView(url: viewModel.limeSurveyLink, viewModel: viewModel.webViewModel) + } else if let url = viewModel.limeSurveyLink { + WebView(url: URLRequest(url: url), viewModel: viewModel.webViewModel) .ignoresSafeArea(.all, edges: .bottom) } } } - .customNavigationTitle(with: NavigationScreen.limeSurvey.localize(useTable: stringsTable, withComment: "LimeSurvey View"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.limeSurvey.localize(), displayMode: .inline) .toolbar { if viewModel.wasAnswered { Button { @@ -57,11 +60,16 @@ struct LimeSurveyView: View { .onDisappear { viewModel.viewDidDisappear() } + .onReceive(viewModel.$shouldClose.removeDuplicates()) { close in + if close { + dismiss() + } + } } } struct LimeSurveyView_Previews: PreviewProvider { static var previews: some View { - LimeSurveyView(viewModel: LimeSurveyViewModel()) + LimeSurveyView(navigationState: NavigationState()) } } diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift index f60743096..ef3a217bc 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -16,71 +16,62 @@ import Foundation import shared import WebKit +import Combine +import KMPNativeCoroutinesCombine class LimeSurveyViewModel: ObservableObject { - private let coreViewModel = CoreLimeSurveyViewModel(observationFactory: AppDelegate.shared.observationFactory) - + private let coreViewModel: CoreLimeSurveyViewModel let webViewModel = WebViewViewModel() @Published var limeSurveyLink: URL? @Published var dataLoading = false @Published var wasAnswered = false + @Published var shouldClose = false - private var navigationModalState: NavigationModalState? - - private var limeSurveyLinkChange: Ktor_ioCloseable? + private var cancellables = Set() - init() { + init(navigationState: NavigationState) { + coreViewModel = CoreLimeSurveyViewModel(repositories: AppDelegate.shared.repositories, observationFactory: AppDelegate.shared.observationFactory, scheduleId: navigationState.scheduleId, notificationId: navigationState.notificationId, observationId: navigationState.observationId) webViewModel.delegate = self - coreViewModel.onDataLoadingChange { [weak self] boolean in - DispatchQueue.main.async { - self?.dataLoading = boolean.boolValue + + createPublisher(for: coreViewModel.dataLoading) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in}) { [weak self] in + self?.dataLoading = $0.boolValue } - } + .store(in: &cancellables) + createPublisher(for: coreViewModel.limeSurveyLink) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in}) { [weak self] in + self?.limeSurveyLink = if let link = $0 { + URL(string: link) + } else { + nil + } + } + .store(in: &cancellables) } func viewDidAppear() { coreViewModel.viewDidAppear() - - limeSurveyLinkChange = coreViewModel.onLimeSurveyLinkChange { [weak self] link in - DispatchQueue.main.async { - if let link { - self?.limeSurveyLink = URL(string: link) - } else { - self?.limeSurveyLink = nil - } - } - } - } func viewDidDisappear() { - limeSurveyLinkChange?.close() - limeSurveyLinkChange = nil dataLoading = false wasAnswered = false coreViewModel.viewDidDisappear() } - - func setNavigationModalState(navigationModalState: NavigationModalState) { - self.navigationModalState = navigationModalState - if let state = navigationModalState.navigationState(for: .limeSurvey) { - if let scheduleId = state.scheduleId { - coreViewModel.setScheduleId(scheduleId: scheduleId, notificationId: state.notificationId) - } else if let observationId = state.observationId { - coreViewModel.setObservationId(observationId: observationId, notificationId: state.notificationId) - } - } - } + @MainActor func onFinish() { if wasAnswered { coreViewModel.finish() } else { coreViewModel.cancel() } - self.navigationModalState?.closeView(screen: .limeSurvey) + shouldClose = true } private func extractPathAndParameters(url: URL) -> (String, [String: String]) { @@ -96,12 +87,12 @@ class LimeSurveyViewModel: ObservableObject { } extension LimeSurveyViewModel: WebViewListener { - func onRedirect(navigationAction: WKNavigationAction) -> WKNavigationActionPolicy { - if let url = navigationAction.request.url { + func onRedirect(navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + if let url = await navigationAction.request.url { print("onRedirect URL: \(url)") let (endPath, parameters) = extractPathAndParameters(url: url) if endPath.lowercased().contains("end.htm"), parameters.keys.contains("savedid") { - DispatchQueue.main.async { + Task { @MainActor in self.wasAnswered = true self.onFinish() } @@ -110,3 +101,4 @@ extension LimeSurveyViewModel: WebViewListener { return .allow } } + diff --git a/iosApp/iosApp/Views/Login/CameraPreviewView.swift b/iosApp/iosApp/Views/Login/CameraPreviewView.swift new file mode 100644 index 000000000..e8b72fbde --- /dev/null +++ b/iosApp/iosApp/Views/Login/CameraPreviewView.swift @@ -0,0 +1,31 @@ +// +// CameraPreviewView.swift +// iosApp +// +// Created by Isabella Aigner on 06.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import AVFoundation +import UIKit + +class CameraPreviewView: UIView { + private var previewLayer: AVCaptureVideoPreviewLayer? + + func configure(session: AVCaptureSession) { + if let layer = previewLayer { + layer.removeFromSuperlayer() + } + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = bounds + self.layer.insertSublayer(layer, at: 0) + previewLayer = layer + } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } +} diff --git a/iosApp/iosApp/Views/Login/LoginButton.swift b/iosApp/iosApp/Views/Login/LoginButton.swift index d6b1c51ff..d6ad9f2e6 100644 --- a/iosApp/iosApp/Views/Login/LoginButton.swift +++ b/iosApp/iosApp/Views/Login/LoginButton.swift @@ -7,32 +7,33 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct LoginButton: View { - @EnvironmentObject var model: LoginViewModel - @Binding var stringTable: String @Binding var disabled: Bool + let action: () -> Void var body: some View { - MoreActionButton(backgroundColor: Color.more.primary, disabled: $disabled) { - model.validate() + MoreActionButton(backgroundColor: Color.more.primary, disabled: .constant(disabled)) { + action() } label: { - Text(verbatim:.localize(forKey: "login_button", withComment: "button to log into a more study", inTable: stringTable)) + Text("login_button") } } } struct LoginButton_Previews: PreviewProvider { + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) static var previews: some View { - LoginButton(stringTable: .constant("LoginView"), disabled: .constant(false)) - .environmentObject(LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(dataManager: ObservationDataManager()), dataRecorder: IOSDataRecorder())))) + LoginButton(disabled: .constant(false)) { + print("Hello World") + } } } diff --git a/iosApp/iosApp/Views/Login/LoginQRCode/LoginQRCodeView.swift b/iosApp/iosApp/Views/Login/LoginQRCode/LoginQRCodeView.swift index be79d2c2a..350b130b8 100644 --- a/iosApp/iosApp/Views/Login/LoginQRCode/LoginQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/LoginQRCode/LoginQRCodeView.swift @@ -25,8 +25,6 @@ struct LoginQRCodeView: View { @Environment(\.presentationMode) var presentationMode var body: some View { - - VStack(alignment: .center) { Image("more_welcome") .padding(.top, 15) diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 13ccf14ef..b7a4831d7 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -7,23 +7,28 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct LoginView: View { - @StateObject var model: LoginViewModel + @ObservedObject private var registration: RegistrationObservable + @StateObject private var model: LoginViewModel @State private var rotationAngle = 0.0 @State private var showTokenInput = true @State private var showEndpoint = false + @State private var disabledQRCodeButton = false - private let stringTable = "LoginView" + init(registration: RegistrationObservable) { + _registration = ObservedObject(wrappedValue: registration) + _model = StateObject(wrappedValue: LoginViewModel(registration: registration.service)) + } var body: some View { ZStack { @@ -32,40 +37,96 @@ struct LoginView: View { .onTapGesture { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } - + VStack(alignment: .center) { Image("more_welcome") .padding(.vertical, 40) - - MoreTextFieldHL(isSmTextfield: .constant(false), - headerText: String.localize(forKey: "participation_key_entry", withComment: "headline for participation token entry field", inTable: stringTable), - inputPlaceholder: .constant(String.localize(forKey: "participation_key_entry", withComment: "headline for participation token entry field", inTable: stringTable)), - input: $model.token, - capitalization: .uppercase, - autoCorrectDisabled: true, - textType: .oneTimeCode + + MoreTextFieldHL( + isSmTextfield: .constant(false), + headerText: "participation_key_entry", + inputPlaceholder: .constant("participation_key_entry_placeholder"), + input: $model.token, + capitalization: .uppercase, + autoCorrectDisabled: true, + textType: .oneTimeCode, + hlAlignment: .center ) .padding(.bottom, 12) - + + MoreActionButton(backgroundColor: .more.secondary, disabled: $disabledQRCodeButton) { + model.showQRCodeView = true + } label: { + HStack { + Text("scan_qr_code") + Spacer() + Image(systemName: "qrcode") + .foregroundColor(.more.primaryLight200) + } + } + .sheet(isPresented: $model.showQRCodeView) { + ScanQRCodeView(model: model) + } + .padding(.bottom, 12) + + Divider() + if showTokenInput { - ErrorLogin(stringTable: .constant(stringTable), disabled: .constant(model.checkTokenCount())) - .environmentObject(model) + VStack { + if let error = registration.error { + let errorMessage = + if error.code == 404 { + "Token or Endpoint invalid" + } else if let code = error.code?.intValue, code >= 500 && code < 600 { + "System Error! Please try again later or contact your Study Administrator!" + } else if error.message.count > 0 { + error.message + } else { + "token_error" + } + ErrorText(message: errorMessage) + .padding(.bottom, 5) + } + + VStack(alignment: .center) { + if registration.isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.more.primary) + } else { + LoginButton(disabled: .constant(model.token.count == 0)) { + if registration.connected { + model.validate() + } else { + AlertController.shared.openAlertDialog( + model: AlertDialogModel.companion.fromStrings( + title: "no_internet_title", + message: "no_internet_message", + confirmLabel: "Ok", + cancelLabel: nil, onConfirm: nil) + ) + } + } + } + } + } + .frame(minHeight: 75) } - + Spacer() .frame(maxHeight: .infinity) - + VStack { ExpandableInput( expanded: $showEndpoint, isSmTextfield: .constant(true), - headerText: .constant(String.localize(forKey: "study_endpoint_headling", withComment: "headling for endpoint entryfield", inTable: stringTable)), - inputPlaceholder: .constant(String.localize(forKey: "enter_study_endpoint", withComment: "Text input field for the study endpoint", inTable: stringTable)), + headerText: .constant("study_endpoint_headling"), + inputPlaceholder: .constant("enter_study_endpoint_placeholder"), input: $model.endpoint, - capitalization: .lowercase, + capitalization: .lowercase, textType: .URL ) - + if !showEndpoint { BasicText(text: "\(model.currentStudyEndpoint())", font: .footnote, lineLimit: 1, textAlign: .center) } @@ -78,8 +139,21 @@ struct LoginView: View { } } -struct LoginView_Previews: PreviewProvider { - static var previews: some View { - LoginView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(dataManager: iOSObservationDataManager()), dataRecorder: IOSDataRecorder())))) - } +#Preview("LoginView") { + let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + let repos = MainRepositoryImpl(appDatabase: database) + let dataManager = iOSObservationDataManager(repository: repos, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) + let userDefaults = UserDefaultsRepository() + let shared = Shared( + localNotificationListener: LocalPushNotifications(), + repositories: repos, + sharedStorageRepository: userDefaults, + observationDataManager: dataManager, + mainBluetoothConnector: IOSBluetoothConnector(), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), + dataRecorder: IOSDataRecorder(), + reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() + ) + let registration = RegistrationObservable(service: RegistrationService(shared: shared)) + LoginView(registration: registration) } diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index e271af476..792a1a528 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -7,69 +7,65 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // - +import Combine +import KMPNativeCoroutinesCombine import shared -protocol LoginViewModelListener { - func tokenValid(study: Study) -} - class LoginViewModel: ObservableObject { - - - private let coreModel: CoreLoginViewModel - - var delegate: LoginViewModelListener? = nil - - @Published var isLoading = false + private let registrationService: RegistrationService @Published var endpoint: String = "" @Published var defaultEndpoint: String = "" @Published var token: String = "" - @Published var error: String = "" - - - init(registrationService: RegistrationService) { - print("LoginViewModel allocated!") - coreModel = CoreLoginViewModel(registrationService: registrationService) - defaultEndpoint = registrationService.getEndpointRepository().endpoint() - - coreModel.onLoadingChange { loading in - if let loading = loading as? Bool { - self.isLoading = loading - } + + @Published var showQRCodeView: Bool = false + + private var cancellables: Set = [] + + init(registration: RegistrationService) { + registrationService = registration + defaultEndpoint = registration.getEndpointRepository().endpoint() + + Publishers.CombineLatest($endpoint, $token) + .map { + !$0.isEmpty || !$1.isEmpty + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.registrationService.clearError() } + .store(in: &cancellables) } - + + func extractValuesFromQRCode(qrCodeUrl: String) { + endpoint = qrCodeUrl.components(separatedBy: "signup?").first ?? "" + + if let tokenPart = qrCodeUrl.components(separatedBy: "token=").last, + tokenPart != qrCodeUrl { + token = tokenPart.components(separatedBy: "&").first ?? "" + } + } + func validate() { - self.error = "" - coreModel.sendRegistrationToken(token: token, endpoint: endpoint.isEmpty ? nil : endpoint) { study in - self.delegate?.tokenValid(study: study) - DispatchQueue.main.async { - self.token = "" - } - } onError: { error in - DispatchQueue.main.async { - self.error = error?.message ?? "" - } + let loginModel = LoginModel(token: token, endpoint: currentStudyEndpoint()) + if loginModel.valid() { + registrationService.sendRegistrationToken(loginModel: loginModel) + } else { + AlertController.shared.openAlertDialog(model: AlertDialogModel.companion.fromStrings(title: "Token or Endpoint invalid", message: "login_model_invalid_body", confirmLabel: "Ok", cancelLabel: nil, onConfirm: nil, onDecline: nil)) } } - + func checkTokenCount() -> Bool { - return self.token.count == 0 + return token.count == 0 } - + func currentStudyEndpoint() -> String { endpoint.isEmpty ? defaultEndpoint : endpoint } - - deinit { - print("LoginViewModel deallocated") - } } - diff --git a/iosApp/iosApp/Views/Login/QRCodeCameraView.swift b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift new file mode 100644 index 000000000..dd597e37f --- /dev/null +++ b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift @@ -0,0 +1,38 @@ +// +// Untitled.swift +// iosApp +// +// Created by Isabella Aigner on 04.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import AVKit +import SwiftUI + +// Camera View using built in AVCaptureVideoPreviewLayer +struct QRCodeCameraView: UIViewRepresentable { + var frameSize: CGSize + @Binding var cameraSession: AVCaptureSession + + func makeUIView(context: Context) -> UIView { + let view = CameraPreviewView() + view.configure(session: cameraSession) + + return view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + uiView.setNeedsLayout() + } +} + +struct QRCodeCameraView_Previews: PreviewProvider { + @State static var previewSession = AVCaptureSession() + + static var previews: some View { + QRCodeCameraView( + frameSize: CGSize(width: 300, height: 300), + cameraSession: $previewSession + ) + } +} diff --git a/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift b/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift new file mode 100644 index 000000000..adb770d11 --- /dev/null +++ b/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift @@ -0,0 +1,22 @@ +// +// QRCodeScanDeligate.swift +// iosApp +// +// Created by Isabella Aigner on 06.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import AVFoundation +import AVKit +import SwiftUI + +class QRScannerDelegate: NSObject, AVCaptureMetadataOutputObjectsDelegate { + var onCodeScanned: ((String) -> Void)? + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + if let metaObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let code = metaObject.stringValue { + onCodeScanned?(code) + } + } +} diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift new file mode 100644 index 000000000..6c567d399 --- /dev/null +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -0,0 +1,133 @@ +// +// Untitled.swift +// iosApp +// +// Created by Isabella Aigner on 04.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import AVFoundation +import SwiftUI +import shared + +struct ScanQRCodeView: View { + @StateObject private var viewModel = ScanQRCodeViewModel() + @ObservedObject var model: LoginViewModel + + // Error Properties + @State private var errorMessage: String = "" + @Environment(\.openURL) private var openURL + + var body: some View { + VStack(spacing: 8) { + Button { + model.showQRCodeView = false + } label: { + Image(systemName: "xmark") + .foregroundColor(.more.textDefault) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image("more_welcome") + .padding(.top, 20) + .padding(.bottom, 10) + + HStack(spacing: 8) { + Image(systemName: "qrcode.viewfinder") + .font(.largeTitle) + .foregroundColor(.more.textDefault) + + Text("scan_qr_code") + .foregroundColor(.more.primary) + } + + Spacer(minLength: 0) + + /// Scanner Frame + ZStack { + GeometryReader { + let size = $0.size + + QRCodeCameraView(frameSize: CGSize(width: size.width, height: size.height), cameraSession: $viewModel.cameraSession) + .onAppear { + viewModel.setupCamera() + } + + ZStack { + ForEach(0...4, id: \.self) { index in + let rotation = Double(index) * 90 + RoundedRectangle(cornerRadius: 2, style: .circular) + .trim(from: 0.61, to: 0.64) + .stroke(Color("Secondary"), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .rotationEffect(.init(degrees: rotation)) + } + } + .frame(width: size.width, height: size.width) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if viewModel.showError { + HStack(spacing: 8) { + Text("provide_camera_access") + .foregroundColor(.more.important) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + } + } + + Spacer(minLength: 45) + } + .padding(15) + // check camera permission + .onAppear(perform: viewModel.checkCameraPermission) + .alert(isPresented: $viewModel.showError) { + Alert( + title: Text("permission_needed"), + message: Text(viewModel.errorMessage), + primaryButton: .default( + Text("open_settings"), + action: { + let settingsString = UIApplication.openSettingsURLString + if let settingsURL = URL(string: settingsString) { + // open app settings, using openURL SwiftUI API + openURL(settingsURL) + } + }), + secondaryButton: .cancel(Text("Cancel")) + ) + } + .onChange(of: viewModel.scannedCode) { code in + if let code { + model.extractValuesFromQRCode(qrCodeUrl: code) + model.showQRCodeView = false // View schließen + } + } + } +} + +#Preview { + let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + let repos = MainRepositoryImpl(appDatabase: database) + let dataManager = iOSObservationDataManager( + repository: repos, + scope: Scope.shared, + studyScope: StudyScope.shared, + dispatchers: AppDispatchers.shared + ) + let userDefaults = UserDefaultsRepository() + let sharedContainer = Shared( + localNotificationListener: LocalPushNotifications(), + repositories: repos, + sharedStorageRepository: userDefaults, + observationDataManager: dataManager, + mainBluetoothConnector: IOSBluetoothConnector(), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), + dataRecorder: IOSDataRecorder(), + reminderNotificationSchedulingLimit: nil, + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() + ) + let registrationService = RegistrationService(shared: sharedContainer) + ScanQRCodeView(model: LoginViewModel(registration: registrationService)) +} diff --git a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift new file mode 100644 index 000000000..90614ded0 --- /dev/null +++ b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift @@ -0,0 +1,119 @@ +// +// QRCodeCameraModel.swift +// iosApp +// +// Created by Isabella Aigner on 06.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import AVFoundation +import shared +import SwiftUI + +enum CameraPermissionStatus: String { + case idle = "Not Determined" + case approved = "Access Granted" + case denied = "Access Denied" +} + +class ScanQRCodeViewModel: NSObject, ObservableObject { + // QR Code Scanner Properties + @Published var cameraSession = AVCaptureSession() + @Published var cameraPermission: CameraPermissionStatus = .idle + @Published var scannedCode: String? = nil + + // Error Properties + @Published var errorMessage: String = "" + @Published var showError: Bool = false + + // QR Code Scanner Output + private let qrOutput = AVCaptureMetadataOutput() + // Camera QR Code Output Delegate + private let qrDelegate = QRScannerDelegate() + + override init() { + super.init() + qrDelegate.onCodeScanned = { [weak self] code in + DispatchQueue.main.async { + self?.scannedCode = code + Task.detached { [weak self] in + guard let session = self?.cameraSession else { return } + session.stopRunning() + } + } + } + } + + func checkCameraPermission() { + Task { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + await MainActor.run { + self.cameraPermission = .approved + Task.detached { [weak self] in + self?.setupCamera() + } + } + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + await MainActor.run { + if granted { + self.cameraPermission = .approved + Task.detached { [weak self] in + self?.setupCamera() + } + self.showError = false + } else { + self.showError = true + } + } + case .denied, .restricted: + self.showError = true + default: + break + } + } + } + + func setupCamera() { + guard let device = AVCaptureDevice.default(for: .video) else { + Task { @MainActor in + self.showError = true + } + return + } + + do { + let input = try AVCaptureDeviceInput(device: device) + + if cameraSession.canAddInput(input) { + cameraSession.addInput(input) + } + + if cameraSession.canAddOutput(qrOutput) { + cameraSession.addOutput(qrOutput) + qrOutput.metadataObjectTypes = [.qr] + qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) + } + + Task.detached { [weak self] in + guard let session = self?.cameraSession else { return } + session.startRunning() + } + + } catch { + presentError(errorDescription: error.localizedDescription) + } + } + + func presentError(errorDescription: String?) { + if errorDescription != nil { + print("Error when setting up camera: ") + print(errorDescription! as String) + } + + DispatchQueue.main.async { + self.showError = true + } + } +} diff --git a/iosApp/iosApp/Views/MainTabView.swift b/iosApp/iosApp/Views/MainTabView.swift index 362a84c4b..466e2132e 100644 --- a/iosApp/iosApp/Views/MainTabView.swift +++ b/iosApp/iosApp/Views/MainTabView.swift @@ -18,50 +18,37 @@ import SwiftUI struct MainTabView: View { @EnvironmentObject var contentViewModel: ContentViewModel @EnvironmentObject private var navigationModalState: NavigationModalState - private let strings = "Navigation" var body: some View { TabView(selection: $navigationModalState.tagState) { Group { NavigationWithDestinations { - DashboardView(viewModel: contentViewModel.dashboardViewModel) + DashboardView(viewModel: contentViewModel.manualSchedule) .padding(.horizontal, navigationModalState.horizontalContentPadding) } .tabItem { - Label(NavigationScreen.dashboard.localize(useTable: strings, withComment: "Dashboard Tab"), systemImage: "house") + Label(NavigationScreen.dashboard.localize(), systemImage: "house") } .tag(0) - if #available(iOS 15.0, *) { - NavigationWithDestinations { - NotificationView(notificationViewModel: contentViewModel.notificationViewModel, filterVM: contentViewModel.notificationFilterViewModel) - .padding(.horizontal, navigationModalState.horizontalContentPadding) - } - .tabItem { - Label(NavigationScreen.notifications.localize(useTable: strings, withComment: "Notifications Tab"), systemImage: "bell") - } - .tag(1) - .badge(contentViewModel.unreadNotificationCount) - } else { - NavigationWithDestinations { - NotificationView(notificationViewModel: contentViewModel.notificationViewModel, filterVM: contentViewModel.notificationFilterViewModel) - .padding(.horizontal, navigationModalState.horizontalContentPadding) - } - .tabItem { - Label(NavigationScreen.notifications.localize(useTable: strings, withComment: "Notifications Tab"), systemImage: "bell") - } - .tag(1) + NavigationWithDestinations { + NotificationView(coreFilterVM: contentViewModel.coreNotificationFilterViewModel) + .padding(.horizontal, navigationModalState.horizontalContentPadding) } + .tabItem { + Label(NavigationScreen.notifications.localize(), systemImage: "bell") + } + .tag(1) NavigationWithDestinations { InfoView(viewModel: contentViewModel.infoViewModel) .padding(.horizontal, navigationModalState.horizontalContentPadding) } .tabItem { - Label(NavigationScreen.info.localize(useTable: strings, withComment: "Info Tab"), systemImage: "info.circle") + Label(NavigationScreen.info.localize(), systemImage: "info.circle") } .tag(2) } } - .accent(color: .more.primaryDark) + .tint(.more.primaryDark) .onAppear { UITabBar.appearance().barTintColor = UIColor(Color.more.primaryLight) UITabBar.appearance().unselectedItemTintColor = UIColor(Color.more.primary) @@ -70,7 +57,7 @@ struct MainTabView: View { .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .questionObservation)) { if let navigationState = navigationModalState.navigationState(for: .questionObservation) { Navigation { - SimpleQuetionObservationView(viewModel: contentViewModel.getSimpleQuestionObservationVM(navigationState: navigationState)) + QuestionObservationView(navigationState: navigationState) .navigationBarTitleDisplayMode(.inline) } .onDisappear { @@ -80,7 +67,7 @@ struct MainTabView: View { } .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .questionObservationThanks)) { Navigation { - SimpleQuestionThankYouView() + QuestionThankYouView() .navigationBarTitleDisplayMode(.inline) } .onDisappear { @@ -88,17 +75,28 @@ struct MainTabView: View { } } .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .limeSurvey)) { + if let navigationState = navigationModalState.navigationState(for: .limeSurvey) { + Navigation { + LimeSurveyView(navigationState: navigationState) + .navigationBarTitleDisplayMode(.inline) + } + .onDisappear { + navigationModalState.removeNavigationAction() + } + } + } + .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .withdrawStudy)) { + LeaveStudyView() + } + .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .garminConnect)) { Navigation { - LimeSurveyView(viewModel: contentViewModel.getLimeSurveyVM(navigationModalState: navigationModalState)) + GarminConnectView() .navigationBarTitleDisplayMode(.inline) } .onDisappear { navigationModalState.removeNavigationAction() } } - .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .withdrawStudy)) { - LeaveStudyView(viewModel: contentViewModel.settingsViewModel) - } } } @@ -107,3 +105,4 @@ struct MainTabView_Previews: PreviewProvider { MainTabView() } } + diff --git a/iosApp/iosApp/Views/ModalView.swift b/iosApp/iosApp/Views/ModalView.swift index 6bbe7df76..17103d9bb 100644 --- a/iosApp/iosApp/Views/ModalView.swift +++ b/iosApp/iosApp/Views/ModalView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,33 +18,33 @@ import SwiftUI struct ModalView: UIViewControllerRepresentable { let view: T let isModal: Bool - let onDismissalAttempt: (()->())? - + let onDismissalAttempt: (() -> Void)? + func makeUIViewController(context: Context) -> UIHostingController { UIHostingController(rootView: view) } - + func updateUIViewController(_ uiViewController: UIHostingController, context: Context) { context.coordinator.modalView = self uiViewController.rootView = view uiViewController.parent?.presentationController?.delegate = context.coordinator } - + func makeCoordinator() -> Coordinator { Coordinator(self) } - + class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate { var modalView: ModalView - + init(_ modalView: ModalView) { self.modalView = modalView } - + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { !modalView.isModal } - + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { modalView.onDismissalAttempt?() } @@ -52,7 +52,7 @@ struct ModalView: UIViewControllerRepresentable { } extension View { - func presentation(isModal: Bool, onDismissalAttempt: (()->())? = nil) -> some View { + func presentation(isModal: Bool, onDismissalAttempt: (() -> Void)? = nil) -> some View { ModalView(view: self, isModal: isModal, onDismissalAttempt: onDismissalAttempt) } } diff --git a/iosApp/iosApp/Views/Navigation.swift b/iosApp/iosApp/Views/Navigation.swift index 618e3b844..a858e2f03 100644 --- a/iosApp/iosApp/Views/Navigation.swift +++ b/iosApp/iosApp/Views/Navigation.swift @@ -15,7 +15,6 @@ import SwiftUI -@available(iOS 16.0, *) struct NavigationWithStack: View { var content: () -> Content @@ -63,18 +62,7 @@ struct Navigation: View { @EnvironmentObject private var contentViewModel: ContentViewModel var body: some View { VStack { - if #available(iOS 16.0, *) { - NavigationWithStack(content: content) - } else { - NavigationView { - content() - .background(Color.more.mainBackground) - .navigationBarTitleDisplayMode(.inline) - .environmentObject(navigationModalState) - .environmentObject(contentViewModel) - } - .background(Color.more.mainBackground) - } + NavigationWithStack(content: content) } .background(Color.more.mainBackground) } @@ -87,73 +75,45 @@ struct NavigationWithDestinations: View { var body: some View { Navigation { VStack { - if #available(iOS 16.0, *) { - content() - .background(Color.more.mainBackground) - .navigationDestination(for: NavigationScreen.self) { screen in - viewForScreen(screen) - } - } else { - ForEach(NavigationScreen.allCases) { screen in - if screen == navigationModalState.currentScreen() { - NavigationLink(destination: viewForOldScreen(screen), isActive: navigationModalState.screenBinding(for: screen)) { - EmptyView() - } - .opacity(0) - } + content() + .background(Color.more.mainBackground) + .navigationDestination(for: NavigationScreen.self) { screen in + viewForScreen(screen) } - - content() - .background(Color.more.mainBackground) - } } } } - - - @ViewBuilder - private func viewForOldScreen(_ screen: NavigationScreen) -> some View { - VStack { - ForEach(NavigationScreen.allCases) { screen in - if screen == navigationModalState.currentScreen() { - NavigationLink(destination: viewForScreen(screen), isActive: navigationModalState.screenBinding(for: screen)) { - EmptyView() - } - .opacity(0) - } - } - viewForScreen(screen) - } - } - + @ViewBuilder private func viewForScreen(_ screen: NavigationScreen) -> some View { MoreMainBackgroundView(contentPadding: navigationModalState.horizontalContentPadding) { VStack { switch screen { case .taskDetails: - if let navigationState = navigationModalState.navigationState(for: screen) { - TaskDetailsView(viewModel: contentViewModel.getTaskDetailsVM(navigationState: navigationState)) + if let scheduleId = navigationModalState.navigationState(for: screen)?.scheduleId { + TaskDetailsView(scheduleId: scheduleId) } else { EmptyView() } case .settings: - SettingsView(viewModel: SettingsViewModel()) + SettingsView() case .studyDetails: StudyDetailsView(viewModel: StudyDetailsViewModel()) case .dashboardFilter: - DashboardFilterView(viewModel: contentViewModel.dashboardViewModel.scheduleViewModel.filterViewModel) + DashboardFilterView(viewModel: contentViewModel.manualSchedule.filterViewModel) case .notificationFilter: - NotificationFilterView(viewModel: contentViewModel.notificationFilterViewModel) + NotificationFilterView(coreVM: contentViewModel.coreNotificationFilterViewModel) case .pastObservations: CompletedSchedules(scheduleViewModel: contentViewModel.completedViewModel) case .runningObservations: RunningSchedules(scheduleViewModel: contentViewModel.runningViewModel) case .bluetoothConnections: - BluetoothConnectionView(viewModel: contentViewModel.bluetoothViewModel, viewOpen: .constant(false)) + BluetoothConnectionView(viewOpen: .constant(false)) case .observationDetails: if let observationId = navigationModalState.navigationState(for: screen)?.observationId { - ObservationDetailsView(viewModel: ObservationDetailsViewModel(observationId: observationId)) + ObservationDetailsView(observationId: observationId) + } else { + EmptyView() } case .observationErrors: ObservationErrorsView() @@ -165,7 +125,6 @@ struct NavigationWithDestinations: View { } } -@available(iOS 16, *) struct NavigationTitleViewModifier: ViewModifier { var text: String var displayMode: NavigationBarItem.TitleDisplayMode = .automatic @@ -177,16 +136,6 @@ struct NavigationTitleViewModifier: ViewModifier { } } -struct NavigationBarTitleViewModifier: ViewModifier { - var text: String - var displayMode: NavigationBarItem.TitleDisplayMode = .automatic - - func body(content: Content) -> some View { - content - .navigationBarTitle(text, displayMode: displayMode) - } -} - enum Capitalization { case uppercase, lowercase, normal } @@ -194,31 +143,17 @@ enum Capitalization { extension View { @ViewBuilder func customNavigationTitle(with text: String, displayMode: NavigationBarItem.TitleDisplayMode = .inline) -> some View { - if #available(iOS 16, *) { - self.modifier(NavigationTitleViewModifier(text: text, displayMode: displayMode)) - } else { - modifier(NavigationBarTitleViewModifier(text: text, displayMode: displayMode)) - } + modifier(NavigationTitleViewModifier(text: text, displayMode: displayMode)) } @ViewBuilder func textFieldAutoCapitalizataion(capitalization: Capitalization) -> some View { - if #available(iOS 15, *) { - if capitalization == .uppercase { - self.modifier(TextFieldViewModifier(capitalization: .characters)) - } else if capitalization == .lowercase { - self.modifier(TextFieldViewModifier(capitalization: .never)) - } else { - self.modifier(TextFieldViewModifier(capitalization: .sentences)) - } + if capitalization == .uppercase { + modifier(TextFieldViewModifier(capitalization: .characters)) + } else if capitalization == .lowercase { + modifier(TextFieldViewModifier(capitalization: .never)) } else { - if capitalization == .uppercase { - modifier(TextFieldOldViewModifier(capitalization: .allCharacters)) - } else if capitalization == .lowercase { - modifier(TextFieldOldViewModifier(capitalization: .none)) - } else { - modifier(TextFieldOldViewModifier(capitalization: .sentences)) - } + modifier(TextFieldViewModifier(capitalization: .sentences)) } } @@ -227,31 +162,9 @@ extension View { } } -@available(iOS 15, *) -struct PresentationViewModifier: ViewModifier { - func body(content: Content) -> some View { - content - .interactiveDismissDisabled() - } -} - -struct PresentationCoverViewModifier: ViewModifier { - func body(content: Content) -> some View { - content - } -} - -@available(iOS 15, *) struct TextFieldViewModifier: ViewModifier { var capitalization: TextInputAutocapitalization = .words func body(content: Content) -> some View { content.textInputAutocapitalization(capitalization) } } - -struct TextFieldOldViewModifier: ViewModifier { - var capitalization: UITextAutocapitalizationType = .words - func body(content: Content) -> some View { - content.autocapitalization(capitalization) - } -} diff --git a/iosApp/iosApp/Views/NavigationModalState.swift b/iosApp/iosApp/Views/NavigationModalState.swift index d1e3028fb..6bef5b1f5 100644 --- a/iosApp/iosApp/Views/NavigationModalState.swift +++ b/iosApp/iosApp/Views/NavigationModalState.swift @@ -13,8 +13,10 @@ // https://commonsclause.com/). // -import shared +import Combine +import KMPNativeCoroutinesCombine import SwiftUI +import shared struct NavigationState: Hashable { var scheduleId: String? = nil @@ -29,7 +31,6 @@ struct NavigationActions { } class NavigationModalState: ObservableObject { - let horizontalContentPadding: CGFloat = 24 @Published var navigationStack: [NavigationScreen] = [] @@ -43,6 +44,8 @@ class NavigationModalState: ObservableObject { @Published var studyIsUpdating: Bool = false @Published var currentStudyState: StudyState = .none + @Published var studyLoadingError: Bool = false + @Published var tagState: Int = 0 { didSet { if let onReset = currentNavigationAction()?.onReset { @@ -51,6 +54,55 @@ class NavigationModalState: ObservableObject { } } + private var cancellables: Set = [] + + init(repos: MainRepository) { + createPublisher(for: repos.study.studyState) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] state in + self?.currentStudyState = state + if state == StudyState.closed || state == StudyState.paused { + self?.clearViews() + } + } + .store(in: &cancellables) + + createPublisher(for: ViewManager.shared.studyLoadingError) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] studyLoadingError in + self?.studyLoadingError = studyLoadingError.boolValue + } + .store(in: &cancellables) + + createPublisher(for: ViewManager.shared.showGarminConnectView) + .removeDuplicates() + .map { + $0.boolValue + } + .flatMap { show -> AnyPublisher in + if show { + return Just(true) + .delay(for: .seconds(0.5), scheduler: DispatchQueue.global(qos: .userInitiated)) + .eraseToAnyPublisher() + } else { + return Just(false).eraseToAnyPublisher() + } + } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] show in + Task {@MainActor in + if show { + self?.openView(screen: .garminConnect) + } else { + self?.closeView(screen: .garminConnect) + } + } + } + .store(in: &cancellables) + } + func screenBinding(for screen: NavigationScreen) -> Binding { Binding( get: { @@ -62,10 +114,12 @@ class NavigationModalState: ObservableObject { }, set: { newValue in if self.mayChangeViewStructure() { - if newValue { - self.openView(screen: screen) - } else { - self.closeView(screen: screen) + Task { @MainActor in + if newValue { + self.openView(screen: screen) + } else { + self.closeView(screen: screen) + } } } } @@ -79,13 +133,6 @@ class NavigationModalState: ObservableObject { } } - func setStudyState(_ state: StudyState) { - currentStudyState = state - if state == StudyState.closed || state == StudyState.paused { - clearViews() - } - } - func currentNavigationAction() -> NavigationActions? { navigationActions.last } @@ -97,13 +144,25 @@ class NavigationModalState: ObservableObject { return nil } + @MainActor func openView(screen: NavigationScreen, scheduleId: String? = nil, observationId: String? = nil, notificationId: String? = nil) { if mayChangeViewStructure() { if !screen.values.fullScreen { - navigationStateStack.append(NavigationState(scheduleId: scheduleId, observationId: observationId, notificationId: notificationId)) - navigationStack.append(screen) - if let onViewOpen = currentNavigationAction()?.onViewOpen { - onViewOpen(screen) + switch screen.values.navigationLink { + case .dashboard: + tagState = 0 + case .notifications: + tagState = 1 + case .info: + tagState = 2 + default: + navigationStateStack.append(NavigationState(scheduleId: scheduleId, observationId: observationId, notificationId: notificationId)) + navigationStack.append(screen) + if let onViewOpen = currentNavigationAction()?.onViewOpen { + Task { @MainActor in + onViewOpen(screen) + } + } } } else { fullscreenNavigationStateStack.append(NavigationState(scheduleId: scheduleId, observationId: observationId, notificationId: notificationId)) @@ -114,11 +173,13 @@ class NavigationModalState: ObservableObject { func navigationState(for screen: NavigationScreen) -> NavigationState? { if !screen.values.fullScreen && !navigationStateStack.isEmpty, - let index = navigationStack.lastIndex(where: { $0 == screen }), - index > -1 { + let index = navigationStack.lastIndex(where: { $0 == screen }), + index > -1 + { return navigationStateStack[index] } else if screen.values.fullScreen && !fullscreenNavigationStateStack.isEmpty, let index = fullscreenNavigationStack.lastIndex(where: { $0 == screen }), - index > -1 { + index > -1 + { return fullscreenNavigationStateStack[index] } return nil @@ -170,47 +231,56 @@ class NavigationModalState: ObservableObject { } func popNavigationAction() { - let _ = navigationActions.removeFirst() + _ = navigationActions.removeFirst() } func removeNavigationAction() { if !navigationActions.isEmpty { - let _ = navigationActions.popLast() + _ = navigationActions.popLast() } } func mayChangeViewStructure() -> Bool { - !studyIsUpdating && currentStudyState == StudyState.active || currentStudyState == StudyState.none + let notUpdatingOrError = !studyIsUpdating && !studyLoadingError + let allowedState = currentStudyState == .active || currentStudyState == .none + return notUpdatingOrError && allowedState } func openWithDeepLink(url: URL, notificationId: String? = nil) { - AppDelegate.shared.deeplinkManager.modifyDeepLink(deepLink: url.absoluteString, protocolReplacement: nil, hostReplacement: nil) { modifiedDeepLink in - if let modifiedDeepLink, - let modifiedURL = URL(string: modifiedDeepLink) { - let path = modifiedURL.path - if let matchingScreen = NavigationScreen.allCases.first(where: { $0.values.navigationLink == path }) { - var parameters: [NavigationParameter: String] = [:] - let components = URLComponents(url: modifiedURL, resolvingAgainstBaseURL: false) - - for queryItem in components?.queryItems ?? [] { - if let value = queryItem.value, let parameter = NavigationParameter(rawValue: queryItem.name) { - parameters[parameter] = value - } - } + guard !ViewManager.shared.studyIsUpdatingValue else { + return + } + AppDelegate.shared.deeplinkManager.modifyDeepLink(deepLink: url.absoluteString) { modifiedDeepLink in + if let modifiedDeepLink { + if let match = NavigationScreen.match(from: modifiedDeepLink.route) { + let params = match.params - let observationId = parameters[.observationId] - let notificationId = parameters[.notificaitonId] ?? notificationId - let scheduleId = parameters[.scheduleId] + let observationId = params[.observationId] + let notificationId = params[.notificationId] ?? notificationId + let scheduleId = params[.scheduleId] - if let notificationId { - AppDelegate.shared.notificationManager.handleNotificationInteraction(notificationId: notificationId, deeplink: modifiedDeepLink) + Task {@MainActor in + self.openView(screen: match.screen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) } - - self.openView(screen: matchingScreen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) } + } else if modifiedDeepLink == nil, let notificationId { AppDelegate.shared.notificationManager.markNotificationAsRead(notificationId: notificationId) } } } + + func openRoute(to data: DeepLinkData) { + if let url = URL(string: data.route), let match = NavigationScreen.match(from: url) { + let params = data.params + + let observationId: String? = params[NavigationRouteParameter.observationId.key] as? String + let notificationId: String? = params[NavigationRouteParameter.notificationId.key] as? String + let scheduleId: String? = params[NavigationRouteParameter.scheduleId.key] as? String + + Task {@MainActor in + self.openView(screen: match.screen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) + } + } + } } diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift index d9e922bbb..77939407b 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift @@ -7,53 +7,56 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct NotificationFilterView: View { - @StateObject var viewModel: NotificationFilterViewModel + private let viewModel: NotificationFilterViewModel @State var filtersChanged = false - private let stringTable = "NotificationView" - private let navigationStrings = "Navigation" - + + init(coreVM: CoreNotificationFilterViewModel) { + viewModel = NotificationFilterViewModel(coreViewModel: coreVM) + } + var body: some View { VStack(alignment: .leading) { ScrollView { - SectionHeading(sectionTitle: String.localize(forKey: "Select Filter", withComment: "Set Notification Filter", inTable: stringTable)) + SectionHeading(sectionTitle: "Select Filter") .padding(15) Divider() - - ForEach(viewModel.allFilters.keys.sorted{$0.sortIndex < $1.sortIndex}, id: \.self) { filter in - if let selected = viewModel.allFilters[filter]?.boolValue { + + ForEach(viewModel.allFilters.keys.sorted { + $0.sortIndex < $1.sortIndex + }, id: \.self) { filter in + if let selected = viewModel.allFilters[filter] { Button { viewModel.toggleFilters(filter: filter) } label: { - MoreFilterOption(option: filter.type.localize(withComment: filter.type, useTable: stringTable), isSelected: .constant(selected)) + MoreFilterOption(option: filter.type, isSelected: .constant(selected)) Spacer() } .buttonStyle(.borderless) .frame(maxWidth: .infinity) Divider() - } } - }.padding(.vertical, 20) + } + .padding(.vertical, 20) Spacer() } + .customNavigationTitle(with: NavigationScreen.notificationFilter.localize()) + .navigationBarTitleDisplayMode(.inline) .onAppear { - viewModel.viewDidAppear() + viewModel.coreViewModel.viewDidAppear() } .onDisappear { - viewModel.viewDidDisappear() + viewModel.coreViewModel.viewDidDisappear() } - .customNavigationTitle(with: NavigationScreen.notificationFilter.localize(useTable: navigationStrings, withComment: "Select Notification Filter")) - .navigationBarTitleDisplayMode(.inline) } } - diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift index 5deb0d58a..d849cd285 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift @@ -7,36 +7,36 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import Combine +import KMPNativeCoroutinesCombine import shared class NotificationFilterViewModel: ObservableObject { - private let stringTable = "NotificationFilter" let coreViewModel: CoreNotificationFilterViewModel - - @Published var allFilters: [NotificationFilterTypeModel: KotlinBoolean] = [:] - + + @Published var allFilters: [NotificationFilterTypeModel: Bool] = [:] + + private var cancellables: Set = [] + init(coreViewModel: CoreNotificationFilterViewModel) { self.coreViewModel = coreViewModel - coreViewModel.onFilterChange { filters in - self.allFilters = filters - } + createPublisher(for: coreViewModel.filters) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] filters in + self?.allFilters = filters.mapValues { + $0.boolValue + } + }) + .store(in: &cancellables) } - - func viewDidAppear() { - coreViewModel.viewDidAppear() - } - - func viewDidDisappear() { - coreViewModel.viewDidDisappear() - } - - + func toggleFilters(filter: NotificationFilterTypeModel) { coreViewModel.toggleFilter(filter: filter) } diff --git a/iosApp/iosApp/Views/Notification/NotificationView.swift b/iosApp/iosApp/Views/Notification/NotificationView.swift index 16afeaf5c..4820dffbc 100644 --- a/iosApp/iosApp/Views/Notification/NotificationView.swift +++ b/iosApp/iosApp/Views/Notification/NotificationView.swift @@ -17,48 +17,53 @@ import shared import SwiftUI struct NotificationView: View { - @StateObject var notificationViewModel: NotificationViewModel - @StateObject var filterVM: NotificationFilterViewModel - private let navigationStrings = "Navigation" - private let stringTable = "NotificationView" + @StateObject private var notificationViewModel: NotificationViewModel @EnvironmentObject private var navigationModalState: NavigationModalState + init(coreFilterVM: CoreNotificationFilterViewModel) { + _notificationViewModel = StateObject(wrappedValue: NotificationViewModel(filterViewModel: coreFilterVM)) + } + var body: some View { VStack { MoreFilter(filterText: $notificationViewModel.filterText, destination: .notificationFilter) .padding(.bottom) if notificationViewModel.notificationList.isEmpty { - EmptyListView(text: "There are currently no notficiations to show".localize(withComment: "Empty notification list", useTable: stringTable)) + EmptyListView(text: "There are currently no notficiations to show") + Spacer() } else { - ScrollView { - ForEach(notificationViewModel.notificationList.sorted { $0.timestamp > $1.timestamp }, id: \.self) { notification in - VStack { - NotificationItem(notificationModel: notification) - Divider() - .padding(.vertical, 4) - } - .background(Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - if !notification.read { - notificationViewModel.handleNotificationAction(notification: notification, navigationModalState: navigationModalState) + ScrollViewReader { _ in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(notificationViewModel.notificationList.sorted { $0.timestamp > $1.timestamp }, id: \.self) { notification in + VStack { + NotificationItem(notificationModel: notification) + Divider() + .padding(.vertical, 4) + } + .background(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + if !notification.read { + notificationViewModel.handleNotificationAction(notification: notification, navigationModalState: navigationModalState) + } + } } } } } } - Spacer() } .frame(maxWidth: .infinity) + .customNavigationTitle(with: NavigationScreen.notifications.localize()) .onAppear { - notificationViewModel.getFilterText(stringTable: stringTable) - notificationViewModel.viewDidAppear() + notificationViewModel.coreModel.viewDidAppear() } .onDisappear { - notificationViewModel.viewDidDisappear() + notificationViewModel.coreModel.viewDidDisappear() } - .customNavigationTitle(with: NavigationScreen.notifications.localize(useTable: navigationStrings, withComment: "Navigation title")) } } + diff --git a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift index 55dee3304..3d11c12a3 100644 --- a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift +++ b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift @@ -7,60 +7,68 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // - +import Combine +import KMPNativeCoroutinesCombine import shared - class NotificationViewModel: ObservableObject { - let recorder = IOSDataRecorder() private let filterViewModel: CoreNotificationFilterViewModel - private let coreModel: CoreNotificationViewModel + let coreModel: CoreNotificationViewModel @Published var notificationList: [NotificationModel] = [] - @Published var filterText: String = "FilterText" + + @Published var filterText: String = "" + + private var cancellables = Set() init(filterViewModel: CoreNotificationFilterViewModel) { self.filterViewModel = filterViewModel - self.coreModel = CoreNotificationViewModel(coreFilterModel: filterViewModel, notificationManager: AppDelegate.shared.notificationManager, protocolReplacement: nil, hostReplacement: nil) - coreModel.onNotificationLoad { [weak self] notifications in - DispatchQueue.main.async { - self?.notificationList = [] - self?.notificationList = notifications - } + coreModel = CoreNotificationViewModel(coreFilterModel: filterViewModel, notificationManager: AppDelegate.shared.notificationManager) + + createPublisher(for: coreModel.notificationList) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] notifications in + self?.notificationList = notifications } - } + .store(in: &cancellables) - func handleNotificationAction(notification: NotificationModel, navigationModalState: NavigationModalState) { - coreModel.handleNotificationAction(notification: notification) { (actionHandler, data) in - switch(actionHandler) { - case NotificationActionHandler.deeplink: - if let uri = URL(string: data) { - navigationModalState.openWithDeepLink(url: uri, notificationId: notification.notificationId) + createPublisher(for: filterViewModel.activeTypes) + .map { (types: Set) -> String in + guard !types.isEmpty else { + return "" } - default: - return - } - } - } - func viewDidAppear() { - coreModel.viewDidAppear() - } + let localized = types + .sorted() + .map { String(localized: String.LocalizationValue($0)) } - func viewDidDisappear() { - coreModel.viewDidDisappear() + return ListFormatter.localizedString(byJoining: localized) + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] text in + self?.filterText = text + } + .store(in: &cancellables) } - func getFilterText(stringTable: String) { - self.filterText = filterViewModel.getActiveTypes().map { - $0.localize(withComment: $0, useTable: stringTable) + func handleNotificationAction(notification: NotificationModel, navigationModalState: NavigationModalState) { + coreModel.handleNotificationAction(notification: notification) { actionHandler, data in + if let data { + switch actionHandler { + case NotificationActionHandler.deeplink: + AppDelegate.navigationScreenHandler.openRoute(to: data) + default: + return + } + } } - .joined(separator: ", ") } } diff --git a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift index 09c728956..217303d4b 100644 --- a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift +++ b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,10 +17,12 @@ import shared import SwiftUI struct ObservationDetailsView: View { - @StateObject var viewModel: ObservationDetailsViewModel - private let stringTable = "ObservationDetails" - private let navigationStrings = "Navigation" - + @StateObject private var viewModel: ObservationDetailsViewModel + + init(observationId: String) { + _viewModel = StateObject(wrappedValue: ObservationDetailsViewModel(observationId: observationId)) + } + var body: some View { VStack( spacing: 20 @@ -29,7 +31,6 @@ struct ObservationDetailsView: View { HStack { Title2(titleText: viewModel.observationDetailModel?.observationTitle ?? "") .padding(0.5) - } .frame(height: 40) HStack( @@ -38,23 +39,22 @@ struct ObservationDetailsView: View { Spacer() } } - - + let date: String = - (viewModel.observationDetailModel?.start.toDateString(dateFormat: "dd.MM.yyyy") ?? "") == (viewModel.observationDetailModel?.end.toDateString(dateFormat: "dd.MM.yyyy") ?? "") ? (viewModel.observationDetailModel?.start.toDateString(dateFormat: "dd.MM.yyyy") ?? "") : (viewModel.observationDetailModel?.start.toDateString(dateFormat: "dd.MM.yyyy") ?? "") + " - " + (viewModel.observationDetailModel?.end.toDateString(dateFormat: "dd.MM.yyyy") ?? "") - + (viewModel.observationDetailModel?.start.toDateString(dateFormat: "dd.MM.yyyy") ?? "") == (viewModel.observationDetailModel?.end.toDateString(dateFormat: "dd.MM.yyyy") ?? "") ? (viewModel.observationDetailModel?.start.toDateString(dateFormat: "dd.MM.yyyy") ?? "") : (viewModel.observationDetailModel?.start.toDateString(dateFormat: "dd.MM.yyyy") ?? "") + " - " + (viewModel.observationDetailModel?.end.toDateString(dateFormat: "dd.MM.yyyy") ?? "") + let time: String = (viewModel.observationDetailModel?.start.toDateString(dateFormat: "HH:mm") ?? "") + " - " + (viewModel.observationDetailModel?.end.toDateString(dateFormat: "HH:mm") ?? "") - + ObservationDetailsData(dateRange: date, timeframe: time) - + HStack { - AccordionItem(title: String.localize(forKey: "Participant Information", withComment: "Participant Information of specific task.", inTable: stringTable), info: viewModel.observationDetailModel?.participantInformation ?? "", isOpen: true) + AccordionItem(title: "Participant Information", info: viewModel.observationDetailModel?.participantInformation ?? "", isOpen: true) } .padding(.top, 10) - + Spacer() } - .customNavigationTitle(with: NavigationScreen.observationDetails.localize(useTable: navigationStrings, withComment: "Observation Detail")) + .customNavigationTitle(with: NavigationScreen.observationDetails.localize()) .onAppear { viewModel.viewDidAppear() } diff --git a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift index bb349e8c3..1d11f4b54 100644 --- a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift +++ b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,22 +17,22 @@ import shared class ObservationDetailsViewModel: ObservableObject { private let coreModel: CoreObservationDetailsViewModel - + @Published var observationDetailModel: ObservationDetailsModel? - - + init(observationId: String) { - self.coreModel = CoreObservationDetailsViewModel(observationId: observationId) + coreModel = CoreObservationDetailsViewModel(repository: AppDelegate.shared.repositories, observationId: observationId) coreModel.onLoadObservationDetails { observationDetails in if let observationDetails { self.observationDetailModel = observationDetails } } } - + func viewDidAppear() { coreModel.viewDidAppear() } + func viewDidDisappear() { coreModel.viewDidDisappear() } diff --git a/iosApp/iosApp/Views/ObservationErrorListView.swift b/iosApp/iosApp/Views/ObservationErrorListView.swift index 0f2c2995f..a8bfa45cb 100644 --- a/iosApp/iosApp/Views/ObservationErrorListView.swift +++ b/iosApp/iosApp/Views/ObservationErrorListView.swift @@ -14,14 +14,12 @@ struct ObservationErrorListView: View { let taskObservationErrorActions: [String] @State private var scrollViewContentSize: CGSize = .zero - private let errorStrings = "Errors" - private let navigationStrings = "Navigation" var body: some View { if !taskObservationErrors.isEmpty || !taskObservationErrorActions.isEmpty { VStack { if !taskObservationErrors.isEmpty { - ScrollView { + ScrollView { VStack { ForEach(taskObservationErrors, id: \.self) { error in HStack { @@ -29,7 +27,7 @@ struct ObservationErrorListView: View { .font(.more.headline) .foregroundColor(.more.important) .padding(.trailing, 4) - BasicText(text: "\(error.localize(withComment: "Error message", useTable: errorStrings))!") + BasicText(text: "\(error)!") } .padding(.bottom) } @@ -37,7 +35,7 @@ struct ObservationErrorListView: View { } .frame(maxHeight: 100) } - + if !taskObservationErrorActions.isEmpty { if taskObservationErrorActions .contains(Observation_.companion.ERROR_DEVICE_NOT_CONNECTED) { @@ -48,7 +46,7 @@ struct ObservationErrorListView: View { Image(systemName: "applewatch") .foregroundColor(.more.white) .padding(.trailing, 4) - Text(String.localize(forKey: "Devices", withComment: "Lists all connected or needed devices.", inTable: navigationStrings)) + Text("Devices") } } } @@ -60,5 +58,4 @@ struct ObservationErrorListView: View { #Preview { ObservationErrorListView(taskObservationErrors: ["Error"], taskObservationErrorActions: [Observation_.companion.ERROR_DEVICE_NOT_CONNECTED]) - } diff --git a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift index 7f6d42eff..ce513857d 100644 --- a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift +++ b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift @@ -10,12 +10,12 @@ import SwiftUI struct ObservationErrorsView: View { @StateObject private var observationErrorsViewModel = ObservationErrorsViewModel() - + private let navigationStrings = "Navigation" var body: some View { ObservationErrorListView(taskObservationErrors: observationErrorsViewModel.observationErrors, taskObservationErrorActions: observationErrorsViewModel.observationErrorActions) .padding(.vertical) - .customNavigationTitle(with: NavigationScreen.observationErrors.localize(useTable: navigationStrings, withComment: "Observation Errors title"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.observationErrors.localize(), displayMode: .inline) } } diff --git a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsViewModel.swift b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsViewModel.swift index 407927769..f2c3c2982 100644 --- a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsViewModel.swift +++ b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsViewModel.swift @@ -6,21 +6,35 @@ // Copyright © 2024 Redlink GmbH. All rights reserved. // +import Combine import Foundation +import KMPNativeCoroutinesCombine import shared class ObservationErrorsViewModel: ObservableObject { @Published var observationErrors: [String] = [] @Published var observationErrorActions: [String] = [] - + + private var cancellables = Set() + init() { - AppDelegate.shared.observationFactory.observationErrorsAsClosure { [weak self] errors in - DispatchQueue.main.async { - if let self { - self.observationErrors = Array(Set(errors.filterValues { $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED }.flatMap{$0.value})) - self.observationErrorActions = Array(Set(errors.filterValues { $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED }.flatMap{$0.value})) - } + createPublisher(for: ObservationStates.shared.observationErrors) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] errors in + self?.observationErrors = Array(Set(errors.filterValues { + $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED + } + .flatMap { + $0.value + })) + self?.observationErrorActions = Array(Set(errors.filterValues { + $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED } + .flatMap { + $0.value + })) } + .store(in: &cancellables) } } diff --git a/iosApp/iosApp/Views/QuestionObservation/QuestionObservationView.swift b/iosApp/iosApp/Views/QuestionObservation/QuestionObservationView.swift new file mode 100644 index 000000000..427aac97c --- /dev/null +++ b/iosApp/iosApp/Views/QuestionObservation/QuestionObservationView.swift @@ -0,0 +1,115 @@ +// +// SimpleQuetionObservationView.swift +// iosApp +// +// Created by Isabella Aigner on 27.03.23. +// Copyright © 2023 Ludwig Boltzmann Institute for +// Digital Health and Prevention - A research institute +// of the Ludwig Boltzmann Gesellschaft, +// Oesterreichische Vereinigung zur Foerderung +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause +// (see https://www.apache.org/licenses/LICENSE-2.0 and +// https://commonsclause.com/). +// + +import shared +import SwiftUI +import UIKit + +struct QuestionObservationView: View { + @StateObject private var viewModel: QuestionViewModel + + @EnvironmentObject private var navigationModalState: NavigationModalState + + @State private var singleSelected: String? = nil + @State private var multiSelected: Set = [] + + init(navigationState: NavigationState) { + _viewModel = StateObject(wrappedValue: QuestionViewModel(navigationState: navigationState)) + } + + var body: some View { + MoreMainBackgroundView { + VStack { + Title2(titleText: viewModel.questionModel?.question ?? "Question") + .padding(.bottom, 20) + .padding(.top, 40) + + VStack(alignment: .leading) { + if viewModel.questionModel?.type == .singleChoice { + SingleChoiceView(viewModel: viewModel, selected: $singleSelected) + } else if viewModel.questionModel?.type == .multipleChoice { + MultiChoiceView(viewModel: viewModel, selected: $multiSelected) + } + + VStack { + MoreActionButton(disabled: .constant(answerEntered())) { + // Prepare data to submit based on the question type + let dataToSubmit: AnyObject? = { + switch viewModel.questionModel?.type { + case .singleChoice: + if let selected = singleSelected { return selected as NSString } + case .multipleChoice: + if !multiSelected.isEmpty { return multiSelected.map { $0 as NSString } as NSArray } + default: + break + } + return nil + }() + + // Navigate immediately for responsiveness + navigationModalState.openView(screen: .questionObservationThanks) + navigationModalState.closeView(screen: .questionObservation) + + // Perform submission off the main thread to avoid blocking UI + if let data = dataToSubmit { + DispatchQueue.global(qos: .userInitiated).async { + viewModel.finish(data: data) + } + } + } label: { + Text("Answer") + } + .padding(.top, 30) + } + } + .padding(.horizontal, 10) + .onAppear { + singleSelected = nil + multiSelected.removeAll() + prewarmSymbols() + viewModel.viewDidAppear() + } + .onDisappear { + viewModel.viewDidDisappear() + } + Spacer() + } + } + .customNavigationTitle(with: NavigationScreen.questionObservation.localize(), displayMode: .inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + navigationModalState.closeView(screen: .questionObservation) + } label: { + Image(systemName: "chevron.down") + .foregroundColor(.more.important) + } + } + } + } + + private func prewarmSymbols() { + // Preload SF Symbols used in answer rows to avoid first-tap lag + _ = UIImage(systemName: "largecircle.fill.circle") + _ = UIImage(systemName: "circle") + _ = UIImage(systemName: "checkmark.square.fill") + _ = UIImage(systemName: "square") + } + + private func answerEntered() -> Bool { + (viewModel.questionModel?.type == .singleChoice && singleSelected == nil) || (viewModel.questionModel?.type == .multipleChoice && multiSelected.isEmpty) + } +} + diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift b/iosApp/iosApp/Views/QuestionObservation/QuestionThankYouView.swift similarity index 51% rename from iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift rename to iosApp/iosApp/Views/QuestionObservation/QuestionThankYouView.swift index 80b45098f..8a334ffc8 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift +++ b/iosApp/iosApp/Views/QuestionObservation/QuestionThankYouView.swift @@ -7,44 +7,41 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import SwiftUI -struct SimpleQuestionThankYouView: View { +struct QuestionThankYouView: View { @EnvironmentObject var questionModelState: NavigationModalState - private let navigationStrings = "Navigation" - private let simpleQuestionStrings = "SimpleQuestionObservation" - + var body: some View { - MoreMainBackgroundView { + MoreMainBackgroundView { VStack( alignment: .leading, spacing: 10 ) { - Title2(titleText: String.localize(forKey: "thank_you", withComment: "Thank You!", inTable: simpleQuestionStrings)) + Title2(titleText: "thank_you") .padding(.top, 30) .padding(.bottom, 10) - BasicText(text: String.localize(forKey: "answer_submitted", withComment: "Your answer was submitted!", inTable: simpleQuestionStrings), color: .more.secondary) + BasicText(text: "answer_submitted", color: .more.secondary) .padding(.bottom, 8) - BasicText(text: String.localize(forKey: "thank_you_participation", withComment: "Thanks for your participation!", inTable: simpleQuestionStrings), color: .more.secondary) + BasicText(text: "thank_you_participation", color: .more.secondary) Spacer() - + MoreActionButton(disabled: .constant(false)) { questionModelState.closeView(screen: .questionObservationThanks) } label: { - BasicText(text: String.localize(forKey: "close", withComment: "Close", inTable: simpleQuestionStrings), color: .more.white) + BasicText(text: "Close", color: .more.white) } .padding(.bottom, 20) } } .navigationBarBackButtonHidden(true) .padding(.horizontal, 40) - .customNavigationTitle(with: NavigationScreen.questionObservation.localize(useTable: navigationStrings, withComment: "Thank you for answering the Question Observation"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.questionObservation.localize(), displayMode: .inline) } - } diff --git a/iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/MultiChoiceView.swift b/iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/MultiChoiceView.swift new file mode 100644 index 000000000..ee1358b03 --- /dev/null +++ b/iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/MultiChoiceView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct MultiChoiceView: View { + @ObservedObject var viewModel: QuestionViewModel + @Binding var selected: Set + + var body: some View { + VStack(alignment: .leading) { + ForEach(viewModel.answers, id: \.self) { answerOption in + CheckboxField( + id: answerOption, + label: answerOption, + isSelected: selected.contains(answerOption), + callback: { toggledId in + if selected.contains(toggledId) { + selected.remove(toggledId) + } else { + selected.insert(toggledId) + } + } + ) + } + } + } +} diff --git a/iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/SingleChoiceView.swift b/iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/SingleChoiceView.swift new file mode 100644 index 000000000..c5ff72c2d --- /dev/null +++ b/iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/SingleChoiceView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct SingleChoiceView: View { + @ObservedObject var viewModel: QuestionViewModel + @Binding var selected: String? + + var body: some View { + VStack(alignment: .leading) { + ForEach(viewModel.answers, id: \.self) { answerOption in + RadioButtonField( + id: answerOption, + label: answerOption, + isMarked: selected == answerOption, + callback: { selectedId in + if selected == selectedId { + selected = nil + } else { + selected = selectedId + } + } + ) + } + } + } +} diff --git a/iosApp/iosApp/Views/QuestionObservation/QuestionViewModel.swift b/iosApp/iosApp/Views/QuestionObservation/QuestionViewModel.swift new file mode 100644 index 000000000..8a835461a --- /dev/null +++ b/iosApp/iosApp/Views/QuestionObservation/QuestionViewModel.swift @@ -0,0 +1,53 @@ +// +// SimplequestionObservationViewModel.swift +// iosApp +// +// Created by Isabella Aigner on 27.03.23. +// Copyright © 2023 Ludwig Boltzmann Institute for +// Digital Health and Prevention - A research institute +// of the Ludwig Boltzmann Gesellschaft, +// Oesterreichische Vereinigung zur Foerderung +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause +// (see https://www.apache.org/licenses/LICENSE-2.0 and +// https://commonsclause.com/). +// + +import shared + +import Combine +import KMPNativeCoroutinesCombine + +class QuestionViewModel: ObservableObject { + private let coreModel: QuestionCoreViewModel + + @Published var questionModel: QuestionModel? + @Published var answers: [String] = [] + + private var cancellables = Set() + + init(navigationState: NavigationState) { + coreModel = QuestionCoreViewModel(repository: AppDelegate.shared.repositories, observationFactory: AppDelegate.shared.observationFactory, scheduleId: navigationState.scheduleId, notificationId: navigationState.notificationId, observationId: navigationState.observationId) + + createPublisher(for: coreModel.questionModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in}) { [weak self] model in + self?.questionModel = model + self?.answers = (model?.answers as? [NSString])?.map { $0 as String } ?? [] + } + .store(in: &cancellables) + } + + func viewDidAppear() { + coreModel.viewDidAppear() + } + + func viewDidDisappear() { + coreModel.viewDidDisappear() + } + + func finish(data: AnyObject) { + coreModel.finishQuestion(data: data) + } +} + diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift deleted file mode 100644 index ef4f9a685..000000000 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// SimplequestionObservationViewModel.swift -// iosApp -// -// Created by Isabella Aigner on 27.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import shared - -protocol SimpleQuestionObservationListener { - func onQuestionAnswered() -} - -class SimpleQuestionObservationViewModel: ObservableObject { - private let coreModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(observationFactory: AppDelegate.shared.observationFactory) - - @Published var simpleQuestoinModel: SimpleQuestionModel? - @Published var answers: [String] = [] - @Published var answerSet: String = "" - - init() { - coreModel.onLoadSimpleQuestionObservation { model in - if let model { - self.simpleQuestoinModel = model - self.answers = model.answers.map { value in - String(describing: value) - } - } - } - } - - func setScheduleId(navigationState: NavigationState) { - if let scheduleId = navigationState.scheduleId { - coreModel.setScheduleId(scheduleId: scheduleId, notificationId: navigationState.notificationId) - } else if let observationId = navigationState.observationId { - coreModel.setScheduleViaObservationId(observationId: observationId, notificationId: navigationState.notificationId) - } - } - - func viewDidAppear() { - coreModel.viewDidAppear() - } - - func viewDidDisappear() { - coreModel.viewDidDisappear() - answerSet = "" - } - - func setAnswer(answer: String) { - self.answerSet = answer - } - - func finish() { - if !self.answerSet.isEmpty { - coreModel.finishQuestion(data: self.answerSet, setObservationToDone: true) - } - } - -} diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift deleted file mode 100644 index dee8370ad..000000000 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// SimpleQuetionObservationView.swift -// iosApp -// -// Created by Isabella Aigner on 27.03.23. -// Copyright © 2023 Ludwig Boltzmann Institute for -// Digital Health and Prevention - A research institute -// of the Ludwig Boltzmann Gesellschaft, -// Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause -// (see https://www.apache.org/licenses/LICENSE-2.0 and -// https://commonsclause.com/). -// - -import shared -import SwiftUI - -struct SimpleQuetionObservationView: View { - @StateObject var viewModel: SimpleQuestionObservationViewModel - - @EnvironmentObject var navigationModalState: NavigationModalState - - private let navigationStrings = "Navigation" - private let simpleQuestionStrings = "SimpleQuestinoObservation" - - var body: some View { - MoreMainBackgroundView { - VStack { - Title2(titleText: viewModel.simpleQuestoinModel?.question ?? "Question") - .padding(.bottom, 20) - .padding(.top, 40) - - VStack( - alignment: .leading) { - ForEach(viewModel.answers, id: \.self) { answerOption in - RadioButtonField(id: answerOption, label: answerOption, isMarked: viewModel.answerSet == answerOption ? true : false, - callback: { selected in - viewModel.setAnswer(answer: selected) - }) - } - - VStack { - MoreActionButton(disabled: .constant(viewModel.answerSet.isEmpty)) { - if !self.viewModel.answerSet.isEmpty { - viewModel.finish() - navigationModalState.openView(screen: .questionObservationThanks) - navigationModalState.closeView(screen: .questionObservation) - } - } label: { - Text(String.localize(forKey: "Answer", withComment: "Click answer button to send your answer.", inTable: simpleQuestionStrings)) - } - .padding(.top, 30) - } - } - .padding(.horizontal, 10) - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear { - viewModel.viewDidDisappear() - } - Spacer() - } - } - .customNavigationTitle(with: NavigationScreen.questionObservation.localize(useTable: navigationStrings, withComment: "Answer the Question Observation"), displayMode: .inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button { - navigationModalState.closeView(screen: .questionObservation) - } label: { - Image(systemName: "chevron.down") - .foregroundColor(.more.important) - } - } - } - } -} diff --git a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift new file mode 100644 index 000000000..c822745ea --- /dev/null +++ b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift @@ -0,0 +1,81 @@ +// +// RegistrationObservable.swift +// More +// +// Created by Jan Cortiel on 15.09.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import Combine +import Foundation +import KMPNativeCoroutinesCombine +import shared + +class RegistrationObservable: ObservableObject { + let service: RegistrationService + + @Published var validLoginModel: LoginModel? + @Published var study: Study? + @Published var error: NetworkServiceError? + @Published var isLoading: Bool = false + @Published var connected: Bool = false + + private var cancellables: Set = [] + + init(service: RegistrationService) { + self.service = service + + createPublisher(for: service.validLoginModel) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] model in + self?.validLoginModel = model + } + .store(in: &cancellables) + + createPublisher(for: service.study_) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] study in + self?.study = study + } + .store(in: &cancellables) + + createPublisher(for: service.error) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] networkError in + self?.error = networkError + if networkError != nil && self?.study != nil { + AlertController.shared.openAlertDialog(model: AlertDialogModel.companion.fromStrings( + title: "consent_error_title", + message: "consent_error_body", + confirmLabel: "Ok", + cancelLabel: nil, + onConfirm: nil) + ) + } + } + .store(in: &cancellables) + + createPublisher(for: service.isLoading) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] isLoading in + self?.isLoading = isLoading.boolValue + } + .store(in: &cancellables) + + createPublisher(for: service.connected) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] connected in + self?.connected = connected.boolValue + } + .store(in: &cancellables) + } +} diff --git a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift index bde8edc62..9356c826c 100644 --- a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift +++ b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift @@ -20,12 +20,17 @@ struct RunningSchedules: View { @StateObject var scheduleViewModel: ScheduleViewModel @State var totalTasks: Double = 0 @State var tasksCompleted: Double = 0 - private let navigationStrings = "Navigation" var body: some View { VStack { ScheduleListHeader(scheduleViewModel: scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) ScheduleView(viewModel: scheduleViewModel) } - .customNavigationTitle(with: NavigationScreen.runningObservations.localize(useTable: navigationStrings, withComment: "Running Schedules title"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.runningObservations.localize(), displayMode: .inline) + .onAppear { + scheduleViewModel.coreModel.viewDidAppear() + } + .onDisappear { + scheduleViewModel.coreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift index ea79298b2..b81f9f7ab 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -23,52 +23,41 @@ struct ObservationButton: View { var observationType: String var state: ScheduleState var disabled: Bool - private let stringTable = "ScheduleListView" - + var body: some View { VStack { - if observationType == "question-observation" { - MoreActionButton(disabled: .constant(disabled), action: { - navigationModalState.openView(screen: .questionObservation, scheduleId: scheduleId) - }) { - VStack { - Text( - String.localize(forKey: "start_questionnaire", withComment: "Button to start a questionnaire", inTable: stringTable) - ) - } - } - } else if observationType == "lime-survey-observation" { - MoreActionButton(disabled: .constant(disabled), action: { - navigationModalState.openView(screen: .limeSurvey, scheduleId: scheduleId) - }) { - VStack { - Text( - "Start LimeSurvey" - .localize(withComment: "Button to start a limesurvey", useTable: stringTable) - ) - } - } - } else { - MoreActionButton(disabled: .constant(disabled), action: { - if state == .running { - observationActionDelegate.pause(scheduleId: scheduleId) + MoreActionButton(disabled: .constant(disabled), action: buttonAction) { + VStack { + if QuestionType_().matches(type: observationType) { + Text("start_questionnaire") + } else if LimeSurveyType().matches(type: observationType) { + Text("Start LimeSurvey") + } else if state == ScheduleState.running { + Text("pause_observation") } else { - observationActionDelegate.start(scheduleId: scheduleId) - } - }) { - VStack { - if state == ScheduleState.running { - Text( - String.localize(forKey: "pause_observation", withComment: "Button to pause an observation", inTable: stringTable) - ) - } else { - Text( - String.localize(forKey: "start_observation", withComment: "Button to start an observation", inTable: stringTable) - ) - } + Text("start_observation") } } } } } + + private func buttonAction() { + var screenToOpen: NavigationScreen? = + if QuestionType_().matches(type: observationType) { + .questionObservation + } else if LimeSurveyType().matches(type: observationType) { + .limeSurvey + } else { + nil + } + Napier.event(.buttonPress, message: "\(state == .running ? "Pause" : "Start") observation \(observationType)") + if let screenToOpen { + navigationModalState.openView(screen: screenToOpen, scheduleId: scheduleId) + } else if state == .running { + observationActionDelegate.pause(scheduleId: scheduleId) + } else { + observationActionDelegate.start(scheduleId: scheduleId) + } + } } diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift b/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift index a3b624520..946c728b0 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -20,15 +20,15 @@ struct ObservationDetails: View { let observationType: String let numberOfObservationErrors: Int var action: () -> Void = {} - + var body: some View { - HStack{ + HStack { VStack(alignment: .leading) { BasicText(text: observationTitle) .font(Font.more.headline) .foregroundColor(Color.more.primary) .padding(.bottom, 1) - Text(observationType) + Text(LocalizedStringKey(observationType)) .foregroundColor(Color.more.secondary) } .padding(4) @@ -50,6 +50,6 @@ struct ObservationDetails: View { struct ObservationDetails_Previews: PreviewProvider { static var previews: some View { - ObservationDetails(observationTitle:"Observation Title", observationType: "Observation Type", numberOfObservationErrors: 1) + ObservationDetails(observationTitle: "Observation Title", observationType: "Observation Type", numberOfObservationErrors: 1) } } diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift b/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift index 575810ec4..ee2d94b9a 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -24,7 +24,7 @@ struct ObservationTimeDetails: View { var body: some View { HStack { Image(systemName: "clock.fill") - BasicText(text: String(format: "%@:", String.localize(forKey: "timeframe", withComment: "when the observation was started", inTable: stringTable))) + BasicText(text: String(format: "%@:", String(localized: "timeframe"))) Text(String(format: "%@ - %@", start.toDateString(dateFormat: "HH:mm"), end.toDateString(dateFormat: "HH:mm"))) .foregroundColor(Color.more.secondary) } diff --git a/iosApp/iosApp/Views/Schedule/List/ScheduleList.swift b/iosApp/iosApp/Views/Schedule/List/ScheduleList.swift index 6cbe2e97a..018a7e0d3 100644 --- a/iosApp/iosApp/Views/Schedule/List/ScheduleList.swift +++ b/iosApp/iosApp/Views/Schedule/List/ScheduleList.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -21,7 +21,7 @@ struct ScheduleList: View { @EnvironmentObject var navigationModalState: NavigationModalState var scheduleModels: [ScheduleModel] = [] var scheduleListType: ScheduleListType - + var body: some View { ForEach(scheduleModels, id: \.scheduleId) { schedule in VStack { @@ -30,7 +30,6 @@ struct ScheduleList: View { Divider() } } - } } diff --git a/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift b/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift index edafbbb4b..f0ecd5904 100644 --- a/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift +++ b/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -21,7 +21,7 @@ import SwiftUI struct ScheduleListItem: View { @EnvironmentObject var navigationModalState: NavigationModalState @ObservedObject var viewModel: ScheduleViewModel - + var scheduleModel: ScheduleModel var showButton: Bool diff --git a/iosApp/iosApp/Views/Schedule/ScheduleView.swift b/iosApp/iosApp/Views/Schedule/ScheduleView.swift index 32cf4aa32..fde77e65a 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleView.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleView.swift @@ -7,68 +7,59 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct ScheduleView: View { @StateObject var viewModel: ScheduleViewModel - - @EnvironmentObject var navigationModalState: NavigationModalState - - private let stringsTable = "ScheduleListView" var body: some View { VStack { - ScrollView(.vertical) { - if (viewModel.schedulesByDate.isEmpty) { - if viewModel.scheduleListType == ScheduleListType.running { - EmptyListView(text: "No running tasks currently".localize(withComment: "No running tasks in list", useTable: stringsTable)) - } else if viewModel.scheduleListType == ScheduleListType.completed { - EmptyListView(text: "No tasks completed by now".localize(withComment: "No completed tasks in list", useTable: stringsTable)) + ScrollViewReader { _ in + ScrollView(.vertical) { + if viewModel.schedulesByDate.isEmpty { + if viewModel.scheduleListType == ScheduleListType.running { + EmptyListView(text: "No running tasks currently") + } else if viewModel.scheduleListType == ScheduleListType.completed { + EmptyListView(text: "No tasks completed by now") + } else { + EmptyListView(text: "No tasks to show") + } } else { - EmptyListView(text: "No tasks to show".localize(withComment: "No tasks in list shown", useTable: stringsTable)) - } - } else { - LazyVStack(alignment: .leading, pinnedViews: .sectionHeaders) { - ForEach(viewModel.schedulesByDate.keys.sorted(), id: \.self) { key in - let schedules = viewModel.schedulesByDate[key, default: []] - if !schedules.isEmpty { - Section { - ForEach(schedules, id: \.scheduleId) { schedule in - VStack { - ScheduleListItem(viewModel: viewModel, scheduleModel: schedule, showButton: viewModel.scheduleListType != .completed) - Divider() + LazyVStack(alignment: .leading, pinnedViews: .sectionHeaders) { + ForEach(viewModel.schedulesByDate.keys.sorted(), id: \.self) { key in + let schedules = viewModel.schedulesByDate[key, default: []] + if !schedules.isEmpty { + Section { + ForEach(schedules, id: \.scheduleId) { schedule in + VStack { + ScheduleListItem(viewModel: viewModel, scheduleModel: schedule, showButton: viewModel.scheduleListType != .completed) + Divider() + } } + } header: { + VStack(alignment: .leading) { + BasicText(text: key.formattedString(), color: Color.more.primaryDark) + .font(Font.more.headline) + Divider() + }.background(Color.more.secondaryLight) } - } header: { - VStack(alignment: .leading) { - BasicText(text: key.formattedString(), color: Color.more.primaryDark) - .font(Font.more.headline) - Divider() - }.background(Color.more.secondaryLight) + .padding(.bottom) + } else { + EmptyView() } - .padding(.bottom) - } else { - EmptyView() } } + .background(Color.more.secondaryLight) } - .background(Color.more.secondaryLight) } } } - .onAppear { - viewModel.viewDidAppear() - //navigationModalState.closeView(screen: .taskDetails) - } - .onDisappear { - viewModel.viewDidDisappear() - } } } @@ -76,6 +67,6 @@ struct ScheduleView_Previews: PreviewProvider { static var previews: some View { MoreMainBackgroundView { ScheduleView(viewModel: ScheduleViewModel(scheduleListType: .all)) - } + } } } diff --git a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift index f0d1935e3..4b135f230 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -13,122 +13,50 @@ // https://commonsclause.com/). // +import Combine +import KMPNativeCoroutinesCombine import shared class ScheduleViewModel: ObservableObject { let recorder = AppDelegate.shared.dataRecorder let scheduleListType: ScheduleListType - private let coreModel: CoreScheduleViewModel + let coreModel: CoreScheduleViewModel let filterViewModel: DashboardFilterViewModel = DashboardFilterViewModel() @Published var schedulesByDate: [Date: [ScheduleModel]] = [:] @Published var observationErrors: [String: Set] = [:] - @Published var observationErrorActions: [String: Set] = [:] + @Published var numberOfErrors: Int = 0 + + private var cancellables = Set() init(scheduleListType: ScheduleListType) { self.scheduleListType = scheduleListType - coreModel = CoreScheduleViewModel(dataRecorder: recorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) - loadSchedules() + coreModel = CoreScheduleViewModel(repos: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) - ViewManager.shared.studyIsUpdatingAsClosure { [weak self] kBool in - if kBool.boolValue { - DispatchQueue.main.async { - self?.schedulesByDate.removeAll() - } + createPublisher(for: coreModel.schedulesByDate) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] schedules in + self?.schedulesByDate = schedules.mapKeys { + $0.toInt64().toDate() } } - } - - func loadSchedules() { - coreModel.onScheduleStateUpdated { [weak self] triple in - guard let self = self else { return } - - let added = triple.first as? Set ?? [] - let removed = triple.second as? Set ?? [] - let updated = triple.third as? Set ?? [] - - let idsToRemove = removed.union(updated.map { $0.scheduleId }) - - if !removed.isEmpty || !updated.isEmpty { - for (date, schedules) in schedulesByDate { - let filteredSchedules = schedules.filter { !idsToRemove.contains($0.scheduleId) } - if filteredSchedules.isEmpty { - DispatchQueue.main.async { - self.schedulesByDate.removeValue(forKey: date) - } - } else { - DispatchQueue.main.async { - self.schedulesByDate[date] = filteredSchedules - } - } - } - } - - if !added.isEmpty || !updated.isEmpty { - let itemsToBeAdded = self.mergeSchedules(Array(added), Array(updated)) - let groupedSchedulesToAdd = Dictionary(grouping: itemsToBeAdded, by: { $0.start.startOfDate() }) - - for (date, schedules) in groupedSchedulesToAdd { - var existingSchedules = self.schedulesByDate[date] ?? [] - existingSchedules.append(contentsOf: schedules) - let uniqueSchedules = self.removeDuplicates(from: existingSchedules) + .store(in: &cancellables) - let sortedSchedules = uniqueSchedules.sorted(by: { - if $0.start == $1.start { - if $0.end == $1.end { - if $0.observationTitle == $1.observationTitle { - return $0.scheduleId < $1.scheduleId - } - return $0.observationTitle < $1.observationTitle - } - return $0.end < $1.end - } - return $0.start < $1.start - }) - - DispatchQueue.main.async { - self.schedulesByDate[date] = sortedSchedules - } - } - } + createPublisher(for: coreModel.observationErrors) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] errors in + self?.observationErrors = errors } + .store(in: &cancellables) - AppDelegate.shared.observationFactory.observationErrorsAsClosure { [weak self] errors in - DispatchQueue.main.async { - if let self = self { - self.observationErrors = errors.filterValues { $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED } - self.observationErrorActions = errors.filterValues { $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED } - } - } + createPublisher(for: coreModel.numberOfErrors) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] numberOfErrors in + self?.numberOfErrors = numberOfErrors.intValue } - } - - func removeDuplicates(from schedules: [ScheduleModel]) -> [ScheduleModel] { - var uniqueSchedules = [String: ScheduleModel]() - for schedule in schedules { - uniqueSchedules[schedule.scheduleId] = schedule - } - return Array(uniqueSchedules.values) - } - - func viewDidAppear() { - coreModel.viewDidAppear() - } - - func viewDidDisappear() { - coreModel.viewDidDisappear() - } - - func mergeSchedules(_ lhs: [ScheduleModel], _ rhs: [ScheduleModel]) -> [ScheduleModel] { - let lhsIds = Set(lhs.map { $0.scheduleId }) - let filteredRhs = rhs.filter { !lhsIds.contains($0.scheduleId) } - return lhs + filteredRhs - } - - func numberOfObservationErrors() -> Int { - let errors = Set(observationErrors.values.flatMap { $0 }).count - return errors > 0 ? errors : Set(observationErrorActions.values.flatMap { $0 }).count + .store(in: &cancellables) } } diff --git a/iosApp/iosApp/Views/Settings/SettingsView.swift b/iosApp/iosApp/Views/Settings/SettingsView.swift index 235d3a7bc..f5960d066 100644 --- a/iosApp/iosApp/Views/Settings/SettingsView.swift +++ b/iosApp/iosApp/Views/Settings/SettingsView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -17,36 +17,75 @@ import SwiftUI import shared struct SettingsView: View { - @StateObject var viewModel: SettingsViewModel - @State var exitButton = Color.more.important - - private let stringTable = "SettingsView" - private let navigationStrings = "Navigation" - + @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() + @State private var exitButton = Color.more.important + var body: some View { VStack(alignment: .leading) { - Text(String.localize(forKey: "settings_text", withComment: "information about accepted permissions", inTable: stringTable)) + MoreActionButton( + disabled: .constant(false), + action: { + viewModel.coreViewModel.openSettings() + } + ) { + Text("open_settings") + } + .padding(.bottom, 16) + + if viewModel.needsTracking { + VStack(alignment: .leading) { + HStack(alignment: .center) { + Toggle(isOn: viewModel.allowTrackingBinding) { + Text( + SharedRes + .strings() + .app_tracking_dialog_title + .desc() + .localized() + ) + } + } + Divider() + Text( + SharedRes + .strings() + .app_tracking_dialog_message + .desc() + .localized() + ) + } + .padding(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.more.secondary, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.bottom, 8) + + } + + Text("settings_text") .foregroundColor(.more.secondary) .padding(.bottom, 15) if let permissions = viewModel.permissionModel { ConsentList(permissionModel: permissions) .padding(.top) } - + Spacer() } - .customNavigationTitle(with: NavigationScreen.settings.localize(useTable: navigationStrings, withComment: "Settings Screen")) + .customNavigationTitle(with: NavigationScreen.settings.localize()) .onAppear { - viewModel.viewDidAppear() + viewModel.coreViewModel.viewDidAppear() } - .onDisappear{ - viewModel.viewDidDisappear() + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() } } } struct SettingsView_Previews: PreviewProvider { static var previews: some View { - SettingsView(viewModel: SettingsViewModel()) + SettingsView() } } diff --git a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift index 1bcde4559..911b53de8 100644 --- a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift +++ b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift @@ -7,45 +7,87 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import AppTrackingTransparency +import Combine import Foundation +import KMPNativeCoroutinesCombine +import SwiftUI import shared class SettingsViewModel: ObservableObject { - private let coreSettingsViewModel: CoreSettingsViewModel - var delegate: ConsentViewModelListener? = nil - + let coreViewModel: CoreSettingsViewModel + @Published var studyTitle: String? - @Published private(set) var permissionModel: PermissionModel? - @Published var dataDeleted = false + @Published var permissionModel: PermissionModel? @Published var showSettings = false - - init() { - coreSettingsViewModel = CoreSettingsViewModel(shared: AppDelegate.shared) - coreSettingsViewModel.onLoadStudy { [weak self] study in - self?.studyTitle = study?.studyTitle - } - coreSettingsViewModel.onPermissionChange { [weak self] permissions in - self?.permissionModel = permissions - } - } - - func leaveStudy() { - dataDeleted = true - coreSettingsViewModel.exitStudy() - self.delegate?.credentialsDeleted() + @Published var allowTracking = ATTrackingManager.trackingAuthorizationStatus == .authorized + @Published var needsTracking = false + + private var cancellables: Set = [] + private let permissionManager = PermissionManager() + + init(viewIdentifier: String = NavigationRoute.settings.viewIdentifier) { + coreViewModel = CoreSettingsViewModel(mainRepository: AppDelegate.shared.repositories, sharedStorageRepository: AppDelegate.shared.sharedStorageRepository, customViewIdentifier: viewIdentifier) + coreViewModel.setExitStudyObserver(observer: AppDelegate.shared) + createPublisher(for: coreViewModel.study) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] studyEntity in + self?.studyTitle = studyEntity?.studyTitle + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.permissionModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] permissions in + self?.permissionModel = permissions + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.allowTracking) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] allowTracking in + if ATTrackingManager.trackingAuthorizationStatus == .authorized { + self?.allowTracking = allowTracking.boolValue + } + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.needsTracking) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] needsTracking in + self?.needsTracking = needsTracking.boolValue + } + .store(in: &cancellables) } - - func viewDidAppear() { - coreSettingsViewModel.viewDidAppear() + + var allowTrackingBinding: Binding { + Binding( + get: { self.allowTracking }, + set: { [weak self] newValue in + if ATTrackingManager.trackingAuthorizationStatus != .denied { + if newValue { + Task { @MainActor in + self?.permissionManager.requestAppTrackingAuthorization() + } + } + self?.allowTracking = newValue + self?.coreViewModel.setTrackingPermission(allow: newValue) + } else { + self?.coreViewModel.showAppTrackingPermissionDialog() + } + } + ) } - - func viewDidDisappear() { - coreSettingsViewModel.viewDidDisappear() + + func leaveStudy() { + coreViewModel.exitStudy() } } diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift index a81c5b62d..88131d9ea 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift @@ -33,23 +33,22 @@ struct StudyDetailsView: View { Title2(titleText: viewModel.studyDetailsModel?.study.studyTitle ?? "") .padding(.top) .padding(.bottom) - - TaskCompletionBarView(viewModel: TaskCompletionBarViewModel(), progressViewTitle: String.localize(forKey: "tasks_completed", withComment: "string for completed tasks", inTable: stringTable)) + + TaskCompletionBarView(viewModel: TaskCompletionBarViewModel(), progressViewTitle: "tasks_completed") .padding(.bottom, 0.2) - + HStack(alignment: .center) { - BasicText(text: String - .localize(forKey: "study_duration", withComment: "string for study duration", inTable: stringTable)) - + BasicText(text: "study_duration") + Spacer() BasicText(text: (viewModel.studyStart.formattedString()) + " - " + (viewModel.studyEnd.formattedString()), color: Color.more.secondary ) }.padding(.bottom) - - ExpandableText(viewModel.studyDetailsModel?.study.participantInfo ?? "", String.localize(forKey: "participant_info", withComment: "Participant Information of study.", inTable: stringTable), lineLimit: 4) + + ExpandableText(viewModel.studyDetailsModel?.study.participantInfo ?? "", "participant_info", lineLimit: 4) .padding(.bottom, 35) - + ExpandableContentWithLink( content: { ScrollView { @@ -65,13 +64,13 @@ struct StudyDetailsView: View { } } }, - title: { String.localize(forKey: "obs_modules", withComment: "Observation modules of study.", inTable: stringTable) }, expanded: $isObservationListOpen + title: { String(localized: "obs_modules") }, expanded: $isObservationListOpen ).padding(.top, 0.5) - + Spacer() } } - .customNavigationTitle(with: NavigationScreen.studyDetails.localize(useTable: navigationStrings, withComment: "Study Details"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.studyDetails.localize(), displayMode: .inline) .onAppear { viewModel.viewDidAppear() } @@ -86,4 +85,3 @@ struct StudyDetailsView_Previews: PreviewProvider { StudyDetailsView(viewModel: StudyDetailsViewModel()) } } - diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift index 8abd3890d..72b949f78 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift @@ -7,38 +7,45 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import Combine +import KMPNativeCoroutinesCombine import shared class StudyDetailsViewModel: ObservableObject { - private let coreModel = CoreStudyDetailsViewModel() + private let coreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared, customViewIdentifier: nil) @Published var studyDetailsModel: StudyDetailsModel? var studyStart: Date = Date() var studyEnd: Date = Date() - + + private var cancellables = Set() + init() { - coreModel.onLoadStudyDetails() {[weak self] studyDetails in - if let self, let studyDetails { - self.studyDetailsModel = studyDetails - if let start = studyDetails.study.start { - self.studyStart = start.epochSeconds.toDate() + createPublisher(for: coreModel.studyModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] studyDetails in + self?.studyDetailsModel = studyDetails + if let studyDetailsModel = studyDetails { + if let start = studyDetailsModel.study.start?.toInt64().toDate() { + self?.studyStart = start } - if let end = studyDetails.study.end { - self.studyEnd = end.epochSeconds.toDate() + if let end = studyDetailsModel.study.end?.toInt64().toDate() { + self?.studyEnd = end } } } + .store(in: &cancellables) } - + func viewDidAppear() { coreModel.viewDidAppear() } - + func viewDidDisappear() { coreModel.viewDidDisappear() studyDetailsModel = nil diff --git a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift index bfff029d3..101be69dd 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -16,33 +16,27 @@ import SwiftUI struct StudyClosedView: View { - @StateObject var viewModel: ContentViewModel - private let stringsTable = "StudyStates" var body: some View { VStack { ScrollView { VStack(alignment: .center) { - Title(titleText: "\("Study was completed".localize(withComment: "Study closed", useTable: stringsTable))!") + Title(titleText: "Study closed") .padding(.bottom, 8) - Title2(titleText: "\("Thank you for your participation".localize(withComment: "Thanks for the participation", useTable: stringsTable))!") + Title2(titleText: "\("Thank you for your participation")!") } - Divider() - VStack { - SectionHeading(sectionTitle: "\("Message by the Study Operator".localize(withComment: "Study Operator message", useTable: stringsTable)):") - .padding(.vertical, 8) - if let finishText = AppDelegate.shared.finishText { + if let finishText = AppDelegate.shared.repositories.study.finishTextValue { + Divider() + VStack { + SectionHeading(sectionTitle: "\("Message by the Study Operator"):") + .padding(.vertical, 8) BasicText(text: finishText) - } else { - BasicText(text: "Study was completed".localize(withComment: "Study closed", useTable: stringsTable)) } } } MoreActionButton(disabled: .constant(false)) { - AppDelegate.shared.exitStudy { - viewModel.showLoginView() - } + AppDelegate.shared.exitStudy(onComplete: {}) } label: { - BasicText(text: "Leave Study".localize(withComment: "Leave Study Button Text", useTable: stringsTable), color: .more.white, font: .headline) + BasicText(text: "Leave Study", color: .more.white, font: .headline) } } .padding(.vertical) @@ -51,6 +45,6 @@ struct StudyClosedView: View { struct StudyClosedView_Previews: PreviewProvider { static var previews: some View { - StudyClosedView(viewModel: ContentViewModel()) + StudyClosedView() } } diff --git a/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift b/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift new file mode 100644 index 000000000..40dd2f16f --- /dev/null +++ b/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift @@ -0,0 +1,32 @@ +// +// StudyLoadingErrorView.swift +// More +// +// Created by Jan Cortiel on 25.09.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import SwiftUI + +struct StudyLoadingErrorView: View { + var body: some View { + VStack(alignment: .center) { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(Color.more.important) + .padding() + Title(titleText: "study_loading_error_title", textAlignment: .center) + .padding(.bottom, 8) + Title2(titleText: "study_loading_error_message", textAlignment: .center) + Spacer() + + ReloadButton() + ExitButton() + } + } +} + +#Preview { + StudyLoadingErrorView() +} diff --git a/iosApp/iosApp/Views/StudyStates/StudyLoadingView.swift b/iosApp/iosApp/Views/StudyStates/StudyLoadingView.swift new file mode 100644 index 000000000..45b1ce3da --- /dev/null +++ b/iosApp/iosApp/Views/StudyStates/StudyLoadingView.swift @@ -0,0 +1,27 @@ +// +// StudyLoadingView.swift +// More +// +// Created by Jan Cortiel on 17.09.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import SwiftUI + +struct StudyLoadingView: View { + var body: some View { + VStack(alignment: .center) { + Spacer() + Title(titleText: "Study loading…", textAlignment: .center) + ProgressView() + .tint(.more.primary) + .scaleEffect(1.5) + .padding(.vertical, 8) + Spacer() + } + } +} + +#Preview { + StudyLoadingView() +} diff --git a/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift b/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift index 169984cc8..26f6160b3 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -16,14 +16,15 @@ import SwiftUI struct StudyPausedView: View { - private let stringsTable = "StudyStates" var body: some View { VStack(alignment: .center) { Spacer() - Title(titleText: "\("Study currently paused".localize(withComment: "Study paused", useTable: stringsTable))!", textAlignment: .center) + Title(titleText: "\(String(localized: "Study currently paused"))!", textAlignment: .center) .padding(.bottom, 8) - Title2(titleText: "\("This study is currently paused by the Study Operator and will be resumed shortly".localize(withComment: "Study will be resumed shortly", useTable: stringsTable))!", textAlignment: .center) + Title2(titleText: "\(String(localized: "This study is currently paused by the Study Operator and will be resumed shortly"))!", textAlignment: .center) Spacer() + ReloadButton() + ExitButton() } } } diff --git a/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift b/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift index 515950edb..59009ba41 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -16,15 +16,14 @@ import SwiftUI struct StudyUpdateView: View { - - private let stringTable = "StudyStates" var body: some View { VStack(alignment: .center) { Spacer() - Title(titleText: "The study configuration is currently updating".localize(withComment: "Study is currently updating", useTable: stringTable), textAlignment: .center) + Title(titleText: "study_update_title", textAlignment: .center) .padding(.bottom, 8) - Title2(titleText: "Please wait until this process is finished".localize(withComment: "Please wait until this process finishes", useTable: stringTable), textAlignment: .center) + Title2(titleText: "study_updating_message", textAlignment: .center) ProgressView() + .tint(.more.primary) .scaleEffect(1.5) .padding(.vertical, 8) Spacer() diff --git a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift index c478bb02e..a47cffd79 100644 --- a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift +++ b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift @@ -7,28 +7,26 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct TaskCompletionBarView: View { - @StateObject var viewModel: TaskCompletionBarViewModel var progressViewTitle: String = "" - var body: some View { VStack { HStack { if progressViewTitle != "" { BasicText(text: progressViewTitle, color: Color.more.secondary) } - + Spacer() if viewModel.taskCompletion.totalTasks != 0 { BasicText(text: String(format: "%.2f%%", viewModel.taskCompletionPercentage)) @@ -41,9 +39,6 @@ struct TaskCompletionBarView: View { .scaleEffect(x: 1, y: 5) .padding(.bottom) } - .onAppear { - viewModel.loadTaskCompletion() - } } } diff --git a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift index bddebf3ef..16a462a63 100644 --- a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift +++ b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift @@ -7,29 +7,30 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import Combine +import KMPNativeCoroutinesCombine import shared class TaskCompletionBarViewModel: ObservableObject { @Published var taskCompletion: TaskCompletion = TaskCompletion(finishedTasks: 0, totalTasks: 0) @Published var taskCompletionPercentage: Double = 0 - var coreViewModel = CoreTaskCompletionBarViewModel() - + var coreViewModel = CoreTaskCompletionBarViewModel(repository: AppDelegate.shared.repositories, dispatcher: AppDispatchers.shared.default_) + + private var cancellables: Set = [] + init() { - loadTaskCompletion() - } - - func loadTaskCompletion() { - self.coreViewModel.onLoadTaskCompletion { taskCompletion in - self.taskCompletion = taskCompletion - if taskCompletion.totalTasks != 0 { - self.taskCompletionPercentage = (Double(taskCompletion.finishedTasks)/Double(taskCompletion.totalTasks)) * 100 - } + createPublisher(for: coreViewModel.taskCompletion) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] completion in + self?.taskCompletion = completion + self?.taskCompletionPercentage = (Double(completion.finishedTasks) / Double(completion.totalTasks)) * 100 } + .store(in: &cancellables) } } diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift index f2f32b1e0..f3931f99d 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift @@ -13,11 +13,11 @@ // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct TaskDetailsView: View { - @StateObject var viewModel: TaskDetailsViewModel + @StateObject private var viewModel: TaskDetailsViewModel @State private var scrollViewContentSize: CGSize = .zero @@ -28,6 +28,10 @@ struct TaskDetailsView: View { private let navigationStrings = "Navigation" private let errorStrings = "Errors" + init(scheduleId: String) { + _viewModel = StateObject(wrappedValue: TaskDetailsViewModel(scheduleId: scheduleId)) + } + var body: some View { MoreMainBackgroundView(contentPadding: 0) { VStack { @@ -53,7 +57,7 @@ struct TaskDetailsView: View { ObservationDetailsData(dateRange: viewModel.getDateRangeString(), timeframe: viewModel.getTimeRangeString()) HStack { - AccordionItem(title: String.localize(forKey: "Participant Information", withComment: "Participant Information of specific task.", inTable: stringTable), info: viewModel.taskDetailsModel?.participantInformation ?? "") + AccordionItem(title: "Participant Information", info: viewModel.taskDetailsModel?.participantInformation ?? "") } if let detailsModel = viewModel.taskDetailsModel, !detailsModel.state.completed() { Spacer() @@ -61,19 +65,26 @@ struct TaskDetailsView: View { DatapointsCollection(datapoints: $viewModel.dataCount, running: detailsModel.state == .running) } Spacer() - + VStack { ObservationErrorListView(taskObservationErrors: viewModel.taskObservationErrors, taskObservationErrorActions: viewModel.taskObservationErrorAction) .background( - GeometryReader { geo -> Color in - DispatchQueue.main.async { - scrollViewContentSize = geo.size - } - return Color.clear + GeometryReader { geo in + Color.clear + .onAppear { + DispatchQueue.main.async { + scrollViewContentSize = geo.size + } + } + .onChange(of: geo.size) { newSize in + DispatchQueue.main.async { + scrollViewContentSize = newSize + } + } } ) .frame(maxWidth: .infinity, maxHeight: 100) - + if !detailsModel.hidden { if let scheduleId = navigationModalState.navigationState(for: .taskDetails)?.scheduleId { Divider() @@ -82,16 +93,15 @@ struct TaskDetailsView: View { scheduleId: scheduleId, observationType: detailsModel.observationType, state: detailsModel.state, - disabled: !detailsModel.state.active() || !viewModel.taskObservationErrors.isEmpty) + disabled: !detailsModel.state.active() || !viewModel.taskObservationErrors.isEmpty + ) .padding(.bottom) } } } - } - } - .customNavigationTitle(with: NavigationScreen.taskDetails.localize(useTable: navigationStrings, withComment: "Task Detail")) + .customNavigationTitle(with: NavigationScreen.taskDetails.localize()) .onAppear { viewModel.viewDidAppear() } @@ -103,8 +113,10 @@ struct TaskDetailsView: View { } struct TaskDetailsViewPreview_Provider: PreviewProvider { + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + static let repos = MainRepositoryImpl(appDatabase: database) static var previews: some View { - TaskDetailsView(viewModel: TaskDetailsViewModel(dataRecorder: AppDelegate.shared.dataRecorder)) - .environmentObject(NavigationModalState()) + TaskDetailsView(scheduleId: "preview-schedule-id") + .environmentObject(NavigationModalState(repos: repos)) } } diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift index 9c8af5ac6..40bf881dc 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift @@ -7,38 +7,41 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import Combine +import KMPNativeCoroutinesCombine import shared import SwiftUI class TaskDetailsViewModel: ObservableObject { private let coreModel: CoreTaskDetailsViewModel - + @Published var taskDetailsModel: TaskDetailsModel? { didSet { updateTaskObservationErrors() } } + @Published var dataCount: Int64 = 0 @Published var taskObservationErrors: [String] = [] @Published var taskObservationErrorAction: [String] = [] - - private var observationErrors: [String : Set] = [:] { + + private var observationErrors: [String: Set] = [:] { didSet { updateTaskObservationErrors() } } - - var simpleQuestionObservationVM: SimpleQuestionObservationViewModel - - init(dataRecorder: DataRecorder) { - self.coreModel = CoreTaskDetailsViewModel(dataRecorder: dataRecorder) - self.simpleQuestionObservationVM = SimpleQuestionObservationViewModel() + + + private var cancellables = Set() + + init(scheduleId: String) { + coreModel = CoreTaskDetailsViewModel(repository: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, scheduleId: scheduleId) coreModel.onLoadTaskDetails { [weak self] taskDetails in if let self { if let taskDetails { @@ -46,30 +49,26 @@ class TaskDetailsViewModel: ObservableObject { } } } - + coreModel.onNewDataCount { [weak self] count in if let self { self.dataCount = count?.int64Value ?? 0 } } - - AppDelegate.shared.observationFactory.observationErrorsAsClosure { [weak self] errors in - if let self { - DispatchQueue.main.async { - self.observationErrors = errors - } - } + + createPublisher(for: ObservationStates.shared.observationErrors) + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink(receiveCompletion: { _ in }) { [weak self] errors in + self?.observationErrors = errors } + .store(in: &cancellables) } - - func setSchedule(scheduleId: String) { - coreModel.setSchedule(scheduleId: scheduleId) - } - + func viewDidAppear() { coreModel.viewDidAppear() } - + func viewDidDisappear() { coreModel.viewDidDisappear() } @@ -77,22 +76,26 @@ class TaskDetailsViewModel: ObservableObject { func getDateRangeString() -> String { let startDate = taskDetailsModel?.start.toDateString(dateFormat: "dd.MM.yyyy") ?? "" let endDate = taskDetailsModel?.end.toDateString(dateFormat: "dd.MM.yyyy") ?? "" - if(startDate != endDate) { + if startDate != endDate { return startDate + " - " + endDate } return startDate } - + func getTimeRangeString() -> String { return (taskDetailsModel?.start.toDateString(dateFormat: "HH:mm") ?? "") + " - " + (taskDetailsModel?.end.toDateString(dateFormat: "HH:mm") ?? "") } - + private func updateTaskObservationErrors() { if let taskDetailsModel { - self.taskObservationErrors = Array(observationErrors[taskDetailsModel.observationType]?.filter { $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED} ?? []) - self.taskObservationErrorAction = Array(observationErrors[taskDetailsModel.observationType]?.filter { $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED} ?? []) + taskObservationErrors = Array(observationErrors[taskDetailsModel.observationType]?.filter { + $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED + } ?? []) + taskObservationErrorAction = Array(observationErrors[taskDetailsModel.observationType]?.filter { + $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED + } ?? []) } else { - self.taskObservationErrors = [] + taskObservationErrors = [] } } } @@ -101,11 +104,11 @@ extension TaskDetailsViewModel: ObservationActionDelegate { func start(scheduleId: String) { coreModel.startObservation() } - + func pause(scheduleId: String) { coreModel.pauseObservation() } - + func stop(scheduleId: String) { coreModel.stopObservation() } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 4812da88c..3b5490bb1 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,46 +1,51 @@ // + +import BackgroundTasks +import SwiftUI +import shared + // Copyright © 2023 Ludwig Boltzmann Institute for // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // -import SwiftUI -import shared -import BackgroundTasks + @main struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.scenePhase) var scenePhase @StateObject var contentViewModel = ContentViewModel() - - var body: some Scene { - WindowGroup { - ContentView(viewModel: contentViewModel) - .onAppear{ + var body: some Scene { + WindowGroup { + ContentView(viewModel: contentViewModel) + .onAppear { } .onChange(of: scenePhase) { newPhase in switch newPhase { case .background: - if AppDelegate.shared.observationManager.hasRunningTasks() { + ViewManager.shared.appIsInForeground(state: false) + AppDelegate.shared.updateData(appInForeground: false) + if AppDelegate.shared.credentialRepository.hasCredentialsValue { appDelegate.scheduleTasks() } - AppDelegate.shared.appInForeground(boolean: false) case .inactive: break case .active: + ViewManager.shared.appIsInForeground(state: true) + PermissionManager.resetPermissionAlertFlag() + AppDelegate.shared.updateData(appInForeground: true) appDelegate.cancelBackgroundTasks() - AppDelegate.shared.appInForeground(boolean: true) break default: break } } - } - } + } + } } diff --git a/iosApp/iosApp/iosApp.entitlements b/iosApp/iosApp/iosApp.entitlements index c686016d9..57c50cc4c 100644 --- a/iosApp/iosApp/iosApp.entitlements +++ b/iosApp/iosApp/iosApp.entitlements @@ -12,6 +12,7 @@ com.apple.security.application-groups + group.$(PRODUCT_BUNDLE_IDENTIFIER) group.ac.at.lbg.dhp.more.group diff --git a/openapi/MobileAppAPI.yaml b/openapi/MobileAppAPI.yaml index 59a7e243b..34ba4fa5b 100644 --- a/openapi/MobileAppAPI.yaml +++ b/openapi/MobileAppAPI.yaml @@ -16,6 +16,11 @@ tags: - name: Data description: | Endpoints to **send** observation-data + - name: Signup + description: | + Provides information about the study signup + - name: GarminRegistration + description: Endpoints to register for Garmin Connect using OAuth externalDocs: url: https://github.com/MORE-Platform @@ -24,6 +29,28 @@ security: - apiKey: [ ] paths: + /signup: + get: + operationId: getSignupInfo + description: Returns simple HTML study information identified by the required participant registration token + tags: + - Signup + parameters: + - $ref: '#/components/parameters/RegistrationTokenQuery' + responses: + '200': + description: Simple study information in HTML + content: + text/html: + schema: + $ref: '#/components/schemas/HtmlPage' + '400': + description: bad request, token missing or invalid + '404': + $ref: '#/components/responses/NoSuchRegistrationToken' + '410': + $ref: '#/components/responses/RegistrationTokenExpired' + /registration: get: operationId: getStudyRegistrationInfo @@ -80,6 +107,59 @@ paths: '204': description: Participant left the study. + /registration/garmin: + get: + operationId: getGarminOauthUrl + description: redirect to the Garmin OAuth page + tags: + - GarminRegistration + responses: + '302': + description: redirect to Garmin OAuth + headers: + Location: + schema: + type: string + description: URL to the Garmin OAuth page + '401': + $ref: '#/components/responses/UnauthorizedApiKey' + + /registration/garmin/callback: + get: + operationId: handleGarminCallback + description: handle the Garmin OAuth callback with code and state parameters + tags: + - GarminRegistration + security: [ ] + parameters: + - name: code + in: query + required: true + schema: + type: string + description: OAuth authorization code from Garmin + - name: state + in: query + required: true + schema: + type: string + description: State parameter for CSRF protection + responses: + '200': + description: OAuth callback handled successfully + '302': + description: redirect after successful OAuth + headers: + Location: + schema: + type: string + description: URL to redirect the user after successful OAuth + '400': + description: invalid or missing parameters + '401': + $ref: '#/components/responses/UnauthorizedApiKey' + + /config/study: get: operationId: getStudyConfiguration @@ -148,7 +228,37 @@ paths: $ref: '#/components/responses/UnauthorizedApiKey' '404': description: unknown notification service + /notifications: + get: + operationId: listPushNotifications + tags: + - Notifications + responses: + '200': + description: return a list of all notifications for current participant + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PushNotification' + /notifications/{msgId}: + delete: + operationId: deleteNotification + tags: + - Notifications + parameters: + - name: msgId + in: path + schema: + type: string + required: true + responses: + '200': + description: deleted successfully + '400': + description: not found /data/bulk: post: operationId: storeBulk @@ -171,7 +281,6 @@ paths: $ref: '#/components/schemas/Id' '401': $ref: '#/components/responses/UnauthorizedApiKey' - components: schemas: Study: @@ -185,12 +294,24 @@ components: The current study-state. Mainly used during the registration process. type: boolean default: true + studyState: + type: string + enum: + - active + - paused + - closed + participant: + $ref: '#/components/schemas/SimpleParticipant' studyTitle: type: string participantInfo: type: string consentInfo: type: string + finishText: + type: string + contact: + $ref: '#/components/schemas/ContactInfo' start: type: string format: date @@ -202,14 +323,6 @@ components: items: $ref: '#/components/schemas/Observation' minItems: 1 - contactInstitute: - type: string - contactPerson: - type: string - contactEmail: - type: string - contactPhoneNumber: - type: string version: $ref: '#/components/schemas/VersionTag' required: @@ -221,7 +334,16 @@ components: - observations - version - Contact: + SimpleParticipant: + type: object + properties: + id: + type: integer + alias: + type: string + + ContactInfo: + description: Contact-Information type: object properties: institute: @@ -232,9 +354,6 @@ components: type: string phoneNumber: type: string - required: - - contactPerson - - contactEmail Observation: description: The configuration of an observation for the study. @@ -268,6 +387,12 @@ components: hidden: type: boolean default: false + noSchedule: + type: boolean + default: false + reminder: + type: boolean + default: false version: $ref: '#/components/schemas/VersionTag' required: @@ -322,6 +447,29 @@ components: - consentInfoMD5 - observations + PushNotification: + description: a push notification + type: object + properties: + type: + type: string + enum: + - 'text' + - 'data' + msgId: + type: string + title: + type: string + body: + type: string + deepLink: + type: string + data: + type: object + timestamp: + type: string + format: date-time + AppConfiguration: description: | The configuration settings for the App while participating on a study @@ -445,6 +593,17 @@ components: - dataValue - timestamp + GarminRedirect: + type: object + properties: + code: + type: string + state: + type: string + required: + - code + - state + Id: type: string @@ -457,6 +616,9 @@ components: msg: type: string + HtmlPage: + description: HtmlRenderedPage + parameters: RegistrationToken: name: More-Registration-Token @@ -465,6 +627,13 @@ components: schema: type: string description: The token to register for a study + RegistrationTokenQuery: + name: token + in: query + required: true + schema: + type: string + description: The registration token of a participant responses: StudyInfoResponse: @@ -502,4 +671,4 @@ components: description: | Login with `apiId` as username and `apiKey` as password type: http - scheme: basic \ No newline at end of file + scheme: basic diff --git a/settings.gradle.kts b/settings.gradle.kts index 36637d762..526943c5a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,12 +5,15 @@ pluginManagement { mavenCentral() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url = uri("https://www.jitpack.io" )} + maven { url = uri("https://www.jitpack.io") } } } diff --git a/setup_google_services.sh b/setup_google_services.sh new file mode 100755 index 000000000..89b44b0c7 --- /dev/null +++ b/setup_google_services.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# This script runs the fastlane setup_google_services lane for both iOS and Android. + +# Use absolute path to project root +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "Setting up Google Services for iOS..." +(cd "$PROJECT_ROOT/iosApp" && fastlane setup_google_services) + +echo "Setting up Google Services for Android..." +(cd "$PROJECT_ROOT/androidApp" && fastlane setup_google_services) + +echo "Google Services setup completed." diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a3fc65b33..02881cd60 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,9 +1,28 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.openapitools.generator.gradle.plugin.tasks.GenerateTask + plugins { kotlin("multiplatform") kotlin("plugin.serialization") id("com.android.library") - id("io.realm.kotlin") version "1.14.1" + id("androidx.room") + id("com.google.devtools.ksp") + id("com.rickclephas.kmp.nativecoroutines") + id("org.openapi.generator").version("7.17.0").apply(true) + id("dev.icerock.mobile.multiplatform-resources") } val generated = "$rootDir/shared/build/generated" @@ -11,21 +30,24 @@ val openApiInputDir = "$rootDir/openapi" val openApiOutputDir = "$generated/open_api" val mobileAppApiInput = "$openApiInputDir/MobileAppAPI.yaml" val mobileAppApiOutputDir = "$openApiOutputDir/mobile_app_api" -val mobileAppApiPackage = "io.redlink.more.more_app_multiplatform.openapi" +val mobileAppApiPackage = "io.redlink.more.services.network.openapi" val openapiIgnore = "$openApiInputDir/openapi-ignore" -val coroutinesVersion = "1.8.1" -val ktorVersion = "2.3.12" +val coroutinesVersion = "1.10.2" +val ktorVersion = "3.4.0" val napierVersion = "2.7.1" -val serializationVersion = "1.6.0" -val gsonVersion = "2.10.1" +val serializationVersion = "1.9.0" +val gsonVersion = "2.13.2" +val roomVersion = "2.8.4" +val sqliteVersion = "2.5.2" + +val mokoResVersion = "0.25.2" +val mokoGraphicsVersion = "0.10.1" kotlin { androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = "11" - } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } publishLibraryVariants("release") } @@ -37,9 +59,10 @@ kotlin { ).forEach { it.binaries.framework { baseName = "shared" + export("dev.icerock.moko:resources:$mokoResVersion") + export("dev.icerock.moko:graphics:$mokoGraphicsVersion") } } - sourceSets { commonMain.dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") @@ -47,20 +70,27 @@ kotlin { implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") - implementation("io.realm.kotlin:library-base:1.13.0") implementation("io.github.aakira:napier:$napierVersion") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") implementation("io.ktor:ktor-client-auth:$ktorVersion") implementation("io.ktor:ktor-client-logging:$ktorVersion") - implementation("dev.tmapps:konnection:1.4.1") + implementation("dev.tmapps:konnection:1.4.5") + + // Room common dependencies + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.sqlite:sqlite-bundled:$sqliteVersion") + + implementation(project.dependencies.platform("org.kotlincrypto.hash:bom:0.7.1")) + implementation("org.kotlincrypto.hash:md") } commonTest.dependencies { implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") } androidMain.dependencies { - implementation("androidx.security:security-crypto-ktx:1.1.0-alpha06") + implementation("androidx.security:security-crypto-ktx:1.1.0") implementation("io.ktor:ktor-client-android:$ktorVersion") implementation("com.google.code.gson:gson:$gsonVersion") } @@ -68,22 +98,119 @@ kotlin { iosMain.dependencies { implementation("io.ktor:ktor-client-darwin:$ktorVersion") } + + all { + languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") + } + sourceSets["commonMain"].kotlin.srcDirs("$mobileAppApiOutputDir/src/commonMain/kotlin") } } android { - namespace = "io.redlink.more.more_app_multiplatform" - compileSdk = 34 + namespace = "io.redlink.more" + compileSdk = 36 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { minSdk = 29 } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +tasks.withType { + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = FULL + } +} + +room { + schemaDirectory("$projectDir/schemas") +} + +dependencies { + add("kspAndroid", "androidx.room:room-compiler:$roomVersion") + add("kspIosArm64", "androidx.room:room-compiler:$roomVersion") + add("kspIosSimulatorArm64", "androidx.room:room-compiler:$roomVersion") + add("kspIosX64", "androidx.room:room-compiler:$roomVersion") + + commonMainApi("dev.icerock.moko:resources:$mokoResVersion") + commonMainApi("dev.icerock.moko:graphics:$mokoGraphicsVersion") + + commonTestImplementation("dev.icerock.moko:resources-test:$mokoResVersion") +} + +multiplatformResources { + resourcesPackage.set("io.redlink.more") + resourcesClassName.set("SharedRes") + iosBaseLocalizationRegion.set("en") + iosMinimalDeploymentTarget.set("16.2") +} + +tasks.register( + "generateOpenApiClasses", +) { + generatorName.set("kotlin") + library.set("multiplatform") + + inputSpec.set(mobileAppApiInput) + outputDir.set(mobileAppApiOutputDir) + + packageName.set(mobileAppApiPackage) + modelPackage.set("$mobileAppApiPackage.model") + apiPackage.set("$mobileAppApiPackage.api") + + globalProperties.set( + mapOf( + "models" to "", + "apis" to "", + "supportingFiles" to "", + "modelDocs" to "false", + "apiDocs" to "false" + ) + ) + + configOptions.set( + mapOf( + "dateLibrary" to "kotlinx-datetime", + "enumPropertyNaming" to "UPPERCASE" + ) + ) + + typeMappings.putAll( + mapOf( + "object" to "kotlinx.serialization.json.JsonObject" + ) + ) + + importMappings.putAll( + mapOf( + "Instant" to "kotlinx.datetime.Instant", + "kotlinx.serialization.json.JsonObject" to "kotlinx.serialization.json.JsonObject" + ) + ) + + // Let Gradle cache this so it only runs when the YAML changes + inputs.file(mobileAppApiInput) + outputs.dir(mobileAppApiOutputDir) +} + +tasks.withType().configureEach { + dependsOn("generateOpenApiClasses") +} + +tasks.withType().configureEach { + dependsOn("generateOpenApiClasses") } -task("testClasses").doLast { - println("This is a dummy testClasses task") +tasks.withType().configureEach { + dependsOn("generateOpenApiClasses") } \ No newline at end of file diff --git a/shared/schemas/io.redlink.more.more_app_mutliplatform.database.AppDatabase/1.json b/shared/schemas/io.redlink.more.more_app_mutliplatform.database.AppDatabase/1.json new file mode 100644 index 000000000..378668760 --- /dev/null +++ b/shared/schemas/io.redlink.more.more_app_mutliplatform.database.AppDatabase/1.json @@ -0,0 +1,419 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "ce90fa890992fa7a2cc9060db22eda42", + "entities": [ + { + "tableName": "studies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`studyId` TEXT NOT NULL, `studyTitle` TEXT NOT NULL, `participantId` INTEGER, `participantAlias` TEXT, `participantInfo` TEXT NOT NULL, `consentInfo` TEXT NOT NULL, `start` INTEGER, `end` INTEGER, `contactInstitute` TEXT, `contactPerson` TEXT, `contactEmail` TEXT, `contactPhoneNumber` TEXT, `version` INTEGER NOT NULL, `active` INTEGER NOT NULL, `state` TEXT NOT NULL, `finishText` TEXT, PRIMARY KEY(`studyId`))", + "fields": [ + { + "fieldPath": "studyId", + "columnName": "studyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studyTitle", + "columnName": "studyTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantId", + "columnName": "participantId", + "affinity": "INTEGER" + }, + { + "fieldPath": "participantAlias", + "columnName": "participantAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "participantInfo", + "columnName": "participantInfo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consentInfo", + "columnName": "consentInfo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "contactInstitute", + "columnName": "contactInstitute", + "affinity": "TEXT" + }, + { + "fieldPath": "contactPerson", + "columnName": "contactPerson", + "affinity": "TEXT" + }, + { + "fieldPath": "contactEmail", + "columnName": "contactEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "contactPhoneNumber", + "columnName": "contactPhoneNumber", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "finishText", + "columnName": "finishText", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "studyId" + ] + } + }, + { + "tableName": "schedules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scheduleId` TEXT NOT NULL, `observationId` TEXT NOT NULL, `observationType` TEXT NOT NULL, `observationTitle` TEXT NOT NULL, `start` INTEGER, `end` INTEGER, `done` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, `state` TEXT NOT NULL, PRIMARY KEY(`scheduleId`))", + "fields": [ + { + "fieldPath": "scheduleId", + "columnName": "scheduleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observationId", + "columnName": "observationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observationType", + "columnName": "observationType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observationTitle", + "columnName": "observationTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "done", + "columnName": "done", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "scheduleId" + ] + } + }, + { + "tableName": "observations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`observationId` TEXT NOT NULL, `observationType` TEXT NOT NULL, `observationTitle` TEXT NOT NULL, `participantInfo` TEXT NOT NULL, `configuration` TEXT, `hidden` INTEGER, `scheduleLess` INTEGER NOT NULL, `version` INTEGER NOT NULL, `required` INTEGER NOT NULL, `collectionTimestamp` INTEGER NOT NULL, PRIMARY KEY(`observationId`))", + "fields": [ + { + "fieldPath": "observationId", + "columnName": "observationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observationType", + "columnName": "observationType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observationTitle", + "columnName": "observationTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantInfo", + "columnName": "participantInfo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configuration", + "columnName": "configuration", + "affinity": "TEXT" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduleLess", + "columnName": "scheduleLess", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "required", + "columnName": "required", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionTimestamp", + "columnName": "collectionTimestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "observationId" + ] + } + }, + { + "tableName": "observation_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`dataId` TEXT NOT NULL, `observationId` TEXT NOT NULL, `observationType` TEXT NOT NULL, `dataValue` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`dataId`))", + "fields": [ + { + "fieldPath": "dataId", + "columnName": "dataId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observationId", + "columnName": "observationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observationType", + "columnName": "observationType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataValue", + "columnName": "dataValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "dataId" + ] + } + }, + { + "tableName": "notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `channelId` TEXT, `title` TEXT, `notificationBody` TEXT, `timestamp` INTEGER, `priority` INTEGER NOT NULL, `read` INTEGER NOT NULL, `completed` INTEGER NOT NULL, `userFacing` INTEGER NOT NULL, `deepLink` TEXT, `notificationData` TEXT NOT NULL, PRIMARY KEY(`notificationId`))", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationBody", + "columnName": "notificationBody", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completed", + "columnName": "completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userFacing", + "columnName": "userFacing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deepLink", + "columnName": "deepLink", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationData", + "columnName": "notificationData", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "notificationId" + ] + } + }, + { + "tableName": "BluetoothDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `deviceName` TEXT, `address` TEXT, PRIMARY KEY(`deviceId`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "deviceName", + "affinity": "TEXT" + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId" + ] + } + }, + { + "tableName": "data_points", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` TEXT NOT NULL, `count` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleId", + "columnName": "scheduleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce90fa890992fa7a2cc9060db22eda42')" + ] + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt b/shared/src/androidMain/kotlin/io/redlink/more/Platform.kt similarity index 78% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt rename to shared/src/androidMain/kotlin/io/redlink/more/Platform.kt index 452032f3a..42ec26465 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/Platform.kt @@ -8,9 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform +package io.redlink.more + +import android.os.Build actual fun getPlatform(): Platform = Platform( - name = "Android ${android.os.Build.VERSION.SDK_INT}", - productName = android.os.Build.PRODUCT + name = "Android ${Build.VERSION.SDK_INT}", + productName = Build.PRODUCT ) \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/database/DatabaseManager.android.kt b/shared/src/androidMain/kotlin/io/redlink/more/database/DatabaseManager.android.kt new file mode 100644 index 000000000..7c7569eb5 --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/database/DatabaseManager.android.kt @@ -0,0 +1,14 @@ +package io.redlink.more.database + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase + +fun getDatabaseBuilder(context: Context): RoomDatabase.Builder { + val appContext = context.applicationContext + val dbFile = appContext.getDatabasePath("more_app.db") + return Room.databaseBuilder( + context = appContext, + name = dbFile.absolutePath + ) +} diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/asString.kt b/shared/src/androidMain/kotlin/io/redlink/more/extensions/asString.kt similarity index 90% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/asString.kt rename to shared/src/androidMain/kotlin/io/redlink/more/extensions/asString.kt index e6cdbf2a0..07d7065d6 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/asString.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/extensions/asString.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions +package io.redlink.more.extensions import com.google.gson.Gson diff --git a/shared/src/androidMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt b/shared/src/androidMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt new file mode 100644 index 000000000..9fbf00b89 --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt @@ -0,0 +1,30 @@ +package io.redlink.more.models + +import android.content.Context +import dev.icerock.moko.resources.desc.StringDesc +import java.lang.ref.WeakReference + +actual object NotificationTextLocalization { + private var contextRef: WeakReference? = null + + fun init(context: Context) { + contextRef = WeakReference(context.applicationContext) + } + + actual fun localize(raw: String, fallback: String?): String { + val context = contextRef?.get() + return if (context != null) { + localizeToStringDesc(raw)?.toString(context) + } else { + fallback + } ?: raw + } + + actual fun localizeToStringDesc(raw: String): StringDesc? { + return NotificationTextKey.fromRaw(raw)?.asStringDesc() + } + + fun localize(context: Context, raw: String): String { + return localizeToStringDesc(raw)?.toString(context) ?: raw + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt deleted file mode 100644 index 5d24dd0db..000000000 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network - -import android.util.Log -import io.ktor.client.* -import io.ktor.client.engine.android.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.logging.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* - -actual fun getHttpClient(customLogger: Logger): HttpClient = HttpClient(Android) { - install(ContentNegotiation) { - json() - defaultRequest { - contentType(ContentType.Application.Json) - } - Logging { - logger = Logger.DEFAULT - level = LogLevel.ALL - } - } -} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EncryptedSharedPreferences.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EncryptedSharedPreferences.kt deleted file mode 100644 index cdc38a876..000000000 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EncryptedSharedPreferences.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.store - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey - -private const val ENCRYPT_SHARED_PREF_FILENAME = "credentials_file" -class EncryptedSharedPreferences { - companion object { - fun create(context: Context): SharedPreferences { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - return EncryptedSharedPreferences.create(context, ENCRYPT_SHARED_PREF_FILENAME, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) - } - } -} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt new file mode 100644 index 000000000..3c0f4d66d --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt @@ -0,0 +1,40 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.services.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType + +actual fun getHttpClient(customLogger: Logger): HttpClient = HttpClient(Android) { + + defaultRequest { + contentType(ContentType.Application.Json) + headers.append("Accept", "application/json") + } + + install(Logging) { + logger = customLogger + level = LogLevel.INFO + sanitizeHeader { header -> header == HttpHeaders.Authorization } + } + + engine { + connectTimeout = 15_000 + socketTimeout = 30_000 + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/AndroidContext.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/store/AndroidContext.kt similarity index 90% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/AndroidContext.kt rename to shared/src/androidMain/kotlin/io/redlink/more/services/store/AndroidContext.kt index 59f8d13a3..30ab85fca 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/AndroidContext.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/store/AndroidContext.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.services.store import android.content.Context diff --git a/shared/src/androidMain/kotlin/io/redlink/more/services/store/PrivateSharedPreferences.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/store/PrivateSharedPreferences.kt new file mode 100644 index 000000000..235928e07 --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/store/PrivateSharedPreferences.kt @@ -0,0 +1,21 @@ +package io.redlink.more.services.store + +import android.content.Context +import android.content.SharedPreferences + +private const val PRIVATE_SHARED_PREF_FILENAME = "more_app_preferences" + +class PrivateSharedPreferences { + companion object { + fun create(context: Context): SharedPreferences { + return createPrivateSharedPreferences(context) + } + + private fun createPrivateSharedPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences( + PRIVATE_SHARED_PREF_FILENAME, + Context.MODE_PRIVATE + ) + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedPreferencesRepository.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/store/SharedPreferencesRepository.kt similarity index 93% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedPreferencesRepository.kt rename to shared/src/androidMain/kotlin/io/redlink/more/services/store/SharedPreferencesRepository.kt index 079329ab7..fe366953b 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedPreferencesRepository.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/store/SharedPreferencesRepository.kt @@ -8,13 +8,13 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.services.store import android.content.Context import android.content.SharedPreferences class SharedPreferencesRepository(context: Context) : SharedStorageRepository { - private var sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create(context) + private var sharedPreferences: SharedPreferences = PrivateSharedPreferences.create(context) override fun store(key: String, value: String) { sharedPreferences @@ -85,4 +85,4 @@ class SharedPreferencesRepository(context: Context) : SharedStorageRepository { override fun remove(key: String) { sharedPreferences.edit()?.remove(key)?.apply() } -} \ No newline at end of file +} diff --git a/shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.kt b/shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.kt new file mode 100644 index 000000000..dc8fd6fbe --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.kt @@ -0,0 +1,18 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.util + +import io.redlink.more.viewModels.ViewManager + +actual fun openSystemSettings() { + ViewManager.showSettingsView(true) +} diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt b/shared/src/androidMain/kotlin/io/redlink/more/util/UUID.kt similarity index 91% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt rename to shared/src/androidMain/kotlin/io/redlink/more/util/UUID.kt index 98162dca2..54f25b458 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/util/UUID.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.util +package io.redlink.more.util import java.util.UUID diff --git a/shared/src/androidUnitTest/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AsStringTest.kt b/shared/src/androidUnitTest/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AsStringTest.kt deleted file mode 100644 index 16332618c..000000000 --- a/shared/src/androidUnitTest/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AsStringTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.extensions - -import com.google.gson.Gson -import kotlin.reflect.KClass -import kotlin.test.* - -class AsStringTest { - - @Test - fun testPrimitives() { - fun testPrimitive(value: T, type: KClass) { - assertEquals(value, Gson().fromJson(value.asString(), type.java), - "Checking $type: $value") - } - - testPrimitive(5, Int::class) - testPrimitive(Int.MIN_VALUE, Int::class) - testPrimitive(Int.MAX_VALUE, Int::class) - - testPrimitive(123L, Long::class) - testPrimitive(5, Long::class) - testPrimitive(Long.MIN_VALUE, Long::class) - testPrimitive(Long.MAX_VALUE, Long::class) - - testPrimitive(1.2F, Float::class) - testPrimitive(1F, Float::class) - testPrimitive(-1.002F, Float::class) - - testPrimitive(1.2, Double::class) - testPrimitive(0.0, Double::class) - testPrimitive(-1.002, Double::class) - - testPrimitive(true, Boolean::class) - testPrimitive(false, Boolean::class) - - testPrimitive("This is a String", String::class) - } - - @Test - fun testArray() { - fun test(array: Array, type: KClass>) { - assertContentEquals(array, Gson().fromJson(array.asString(), type.java), - "Checking $array") - } - - test(arrayOf(1,2,3), Array::class) - test(arrayOf(1.1,2.0,3.0), Array::class) - test(arrayOf("Foo", "Bar"), Array::class) - } - - @Test - fun testMap() { - fun testEntry(map: Map, key: String, value: Any?) { - assertContains(map, key, "Expecting entry for '$key'") - assertEquals(value, map[key], "Checking value for entry '$key'") - } - - val map = mapOf( - "key" to "value", - "number" to 123.0, - "boolean" to true, - "decimal" to 234.123, - ) - - val parsed = Gson().fromJson>(map.asString(), Map::class.java) - - map.keys.forEach { - testEntry(parsed, it, map[it]) - } - } - -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt b/shared/src/commonMain/kotlin/io/redlink/more/Platform.kt similarity index 90% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt rename to shared/src/commonMain/kotlin/io/redlink/more/Platform.kt index ee938358f..f0d82d03e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Platform.kt @@ -8,12 +8,12 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform +package io.redlink.more import kotlinx.serialization.Serializable @Serializable -data class Platform ( +data class Platform( val name: String, val productName: String ) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt new file mode 100644 index 000000000..6b93d699b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -0,0 +1,431 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more + +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.StringDesc +import dev.tmapps.konnection.Konnection +import io.github.aakira.napier.Napier +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.toStudyState +import io.redlink.more.logging.EventCollection +import io.redlink.more.logging.EventObserver +import io.redlink.more.models.StudyState +import io.redlink.more.navigation.DeeplinkManager +import io.redlink.more.navigation.DeeplinkManagerImpl +import io.redlink.more.observations.DataRecorder +import io.redlink.more.observations.ObservationDataManager +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.ObservationManager +import io.redlink.more.observations.ObservationStates +import io.redlink.more.observations.observationTypes.GarminType +import io.redlink.more.scopes.Scope +import io.redlink.more.scopes.StudyScope +import io.redlink.more.services.ObservationService +import io.redlink.more.services.bluetooth.BluetoothConnector +import io.redlink.more.services.network.NetworkService +import io.redlink.more.services.network.NetworkServiceImpl +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.services.notification.LocalNotificationListener +import io.redlink.more.services.notification.NotificationActionObserver +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.services.store.CredentialRepository +import io.redlink.more.services.store.CredentialRepositoryImpl +import io.redlink.more.services.store.EndpointRepository +import io.redlink.more.services.store.EndpointRepositoryImpl +import io.redlink.more.services.store.SharedStorageRepository +import io.redlink.more.viewModels.ViewManager +import io.redlink.more.viewModels.bluetoothConnection.BluetoothController +import io.redlink.more.viewModels.garminConnectOAuth.CoreGarminConnectViewModel +import io.redlink.more.viewModels.settings.ExitStudyListener +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +open class Shared( + localNotificationListener: LocalNotificationListener, + val repositories: MainRepository, + val sharedStorageRepository: SharedStorageRepository, + val observationDataManager: ObservationDataManager, + mainBluetoothConnector: BluetoothConnector, + val observationFactory: ObservationFactory, + val dataRecorder: DataRecorder, + reminderNotificationSchedulingLimit: Int? = null, + val connectionStatusFlow: Flow = + konnectionInstance().observeHasConnection() +) : NotificationActionObserver, ExitStudyListener, AutoCloseable { + val deeplinkManager: DeeplinkManager = DeeplinkManagerImpl(repositories, observationFactory) + val endpointRepository: EndpointRepository = EndpointRepositoryImpl(sharedStorageRepository) + val credentialRepository: CredentialRepository = + CredentialRepositoryImpl(sharedStorageRepository).also { + observationFactory.setCredentialsRepository(it) + } + val networkService: NetworkService = + NetworkServiceImpl(endpointRepository, credentialRepository) + + val observationManager = ObservationManager( + repositories, + observationFactory, + dataRecorder + ) + val bluetoothController = + BluetoothController( + repositories.bluetoothDevice, + mainBluetoothConnector, + observationFactory = observationFactory + ) + val notificationManager = + NotificationManager( + repositories, + localNotificationListener, + networkService, + deeplinkManager, + sharedStorageRepository + ) + .also { it.setActionObserver(this) } + .also { observationFactory.setNotificationManager(it) } + + val observationService = + ObservationService(repositories, notificationManager, reminderNotificationSchedulingLimit) + + private val mutex = Mutex() + private var mainJob: Job? = null + + init { + observationFactory.observationsWithInterface(EventObserver::class) + .forEach { EventCollection.addObserver(it) } + val handler = CoroutineExceptionHandler { _, t -> + Napier.e(tag = "Shared::init") { "Init watcher crashed: ${t.stackTraceToString()}" } + } + + mainJob?.cancel() + mainJob = Scope.launch(handler) { + while (isActive) { + try { + combine( + credentialRepository.hasCredentials, + repositories.study.studyState + ) { cred, state -> cred && state.isActive() } + .catch { e -> + Napier.e(tag = "Shared::init") { "Foreground/study watcher failed: ${e.stackTraceToString()}" } + if (e is CancellationException) throw e + } + .distinctUntilChanged() + .collect { state -> + ViewManager.currentStudyActive(state) + if (state) { + updateData(ViewManager.appInForeground.value) + } else { + stopObservations() + ViewManager.showBLEView(false) + ObservationStates.resetAll() + } + } + + // If collect ever returns normally, we restart the loop. + Napier.w(tag = "Shared::init") { "Foreground/study watcher completed unexpectedly; restarting" } + delay(500L) + } catch (e: CancellationException) { + // Normal shutdown/cancel. + Napier.i(tag = "Shared::init") { "Foreground/study watcher cancelled" } + throw e + } catch (t: Throwable) { + // Any exception inside the collector would previously cancel the coroutine silently. + Napier.e(tag = "Shared::init") { "Foreground/study watcher crashed; restarting: ${t.stackTraceToString()}" } + delay(1000L) + } + } + }.second + } + + fun updateData(appInForeground: Boolean) { + Scope.launch { + Napier.d(tag = "Shared:updateData") { "Updating data, with app in foreground: $appInForeground" } + if (appInForeground) { + notificationManager.createNewFCMIfNecessary() + updateStudy() + if (repositories.study.studyState.value == StudyState.ACTIVE) { + notificationManager.createNewFCMIfNecessary() + updateSchedules() + withContext(Dispatchers.Main) { + observationDataManager.listenToDatapointCountChanges() + observationManager.activateScheduleUpdate() + dataRecorder.restartAll() + } + garminLogin() + notificationManager.clearAllNotifications() + } else { + ViewManager.showBLEView(false) + } + notificationManager.clearAllNotifications() + } else { + ViewManager.showBLEView(false) + if (repositories.study.studyState.value == StudyState.ACTIVE) { + observationDataManager.store() + observationDataManager.sendData(true) + } + } + } + } + + suspend fun updateSchedules() { + observationFactory.observationsWithInterface(EventObserver::class) + .forEach { EventCollection.addObserver(it) } + observationManager.updateTaskStates() + observationService.scheduleObservationReminder() + notificationManager.downloadMissedNotifications() + } + + override fun updateStudy( + oldStudyState: StudyState?, + newStudyState: StudyState? + ) { + if (!credentialRepository.hasCredentials.value || mutex.isLocked) { + return + } + Scope.launch { + mutex.withLock { + updateStudyInternal(oldStudyState, newStudyState) + } + } + } + + private suspend fun updateStudyInternal( + oldStudyState: StudyState? = null, + newStudyState: StudyState? = null + ) { + if (oldStudyState != null || newStudyState != null) { + Napier.d(tag = "Shared::updateStudy") { "Updating study with oldState: $oldStudyState and new state: $newStudyState" } + } else { + Napier.d(tag = "Shared::updateStudy") { "Updating study..." } + } + + if (newStudyState != null && (newStudyState == StudyState.CLOSED || newStudyState == StudyState.PAUSED)) { + Napier.d(tag = "Shared::updateStudy") { "New study State is $newStudyState" } + repositories.study.updateStudyState(newStudyState) + StudyScope.cancel() + observationService.clearReminders() + notificationManager.clearAllNotifications() + return + } + + val currentStudyBeforeFetch = repositories.study.getStudy().firstOrNull() + + if (connectionStatusFlow.firstOrNull() == false) { + Napier.d(tag = "Shared::updateStudy") { "No network connection, skipping study update" } + if (newStudyState != null) { + repositories.study.updateStudyState(newStudyState) + } + if (currentStudyBeforeFetch == null) { + ViewManager.studyError(true) + } + return + } + + // Fetch study config with timeout + small retry/backoff to smooth over flaky networks. + // NOTE: We intentionally keep error typed as Any? to avoid coupling to the concrete error type. + var fetchedStudy: Any? = null + var fetchedError: Any? = null + val maxAttempts = 3 + var attempt = 0 + + while (attempt < maxAttempts) { + attempt++ + try { + val (study, error) = networkService.getStudyConfig() + + fetchedStudy = study + fetchedError = error + + if (error == null && study != null) { + break + } + + val msg = when (error) { + null -> "Study is null" + else -> error.toString() + } + Napier.e(tag = "Shared::updateStudy") { "Study fetch attempt $attempt/$maxAttempts failed: $msg" } + } catch (t: Throwable) { + fetchedError = t + Napier.e(tag = "Shared::updateStudy") { "Study fetch attempt $attempt/$maxAttempts threw: ${t.message ?: t}" } + } + + if (attempt < maxAttempts) { + delay(300L * attempt) + } + } + + val study = fetchedStudy + val error = fetchedError + + if (error != null) { + if (currentStudyBeforeFetch == null) { + ViewManager.studyError(true) + } + return + } + + if (study == null) { + Napier.d(tag = "Shared::updateStudy") { "Study is null" } + if (currentStudyBeforeFetch == null) { + ViewManager.studyError(true) + } + return + } + + ViewManager.studyError(false) + + + var studyHasChanged = false + var stateChanged = false + var activeStatusChanged = false + var versionChanged = false + + currentStudyBeforeFetch?.let { current -> + val s = study as? Study + val newState = s?.studyState?.toStudyState() + val currentState = current.getState() + + if (newState != null && newState != currentState) { + stateChanged = true + studyHasChanged = true + Napier.d(tag = "Shared::updateStudy") { "Study state changed: $currentState -> $newState" } + } + + if (s != null && current.active != s.active) { + activeStatusChanged = true + studyHasChanged = true + Napier.d(tag = "Shared::updateStudy") { "Study active status changed: ${current.active} -> ${s.active}" } + } + + if (s != null && current.version != s.version) { + versionChanged = true + studyHasChanged = true + Napier.d(tag = "Shared::updateStudy") { "Study version changed: ${current.version} -> ${s.version}" } + } + } + + val hasNoCurrentStudy = currentStudyBeforeFetch == null + val shouldUpdate = studyHasChanged || hasNoCurrentStudy + + if (shouldUpdate) { + Napier.d(tag = "Shared::updateStudy") { + "Study update required - hasNoCurrentStudy: $hasNoCurrentStudy, stateChanged: $stateChanged, activeStatusChanged: $activeStatusChanged, versionChanged: $versionChanged" + } + + ViewManager.studyIsUpdating(true) + try { + StudyScope.cancel() + observationFactory.clearNeededObservationTypes() + observationService.clearReminders() + notificationManager.clearAllNotifications() + repositories.notification.deleteAll() + + val s = study as Study + withContext(Dispatchers.Main) { + repositories.study.upsert(s) + } + updateSchedules() + } catch (e: Exception) { + Napier.e(tag = "Shared::updateStudy") { "Exception during updating study: $e" } + if (repositories.study.study.value == null) { + ViewManager.studyError(true) + } + } finally { + ViewManager.studyIsUpdating(false) + } + } else { + Napier.d(tag = "Shared::updateStudy") { "No study update needed - study data is unchanged" } + } + } + + fun newLogin() { + notificationManager.newFCMToken() + garminLogin() + } + + private fun garminLogin() { + Scope.launch { + Napier.d(tag = "Shared::garminLogin") { "Checking Garmin login" } + val garminType = GarminType() + if (garminType.matchesAny(observationFactory.studyObservationTypes.value) + && !sharedStorageRepository.load( + CoreGarminConnectViewModel.GARMIN_CONNECT_SUCCESSFUL_LOGIN, + false + ) + ) { + ViewManager.requestGarminConnectView(true) + } + } + } + + override fun exitStudy(onComplete: () -> Unit) { + StudyScope.cancel() + bluetoothController.resetAll() + Scope.launch { + networkService.deleteParticipation() + notificationManager.deleteFCMToken() + observationService.clearReminders() + notificationManager.clearAllNotifications() + removeStudyData() + observationFactory.onStudyExit() + onComplete() + ViewManager.resetAll() + clearSharedStorage() + EventCollection.clearQueue() + } + } + + private fun stopObservations() { + dataRecorder.stopAll() + observationDataManager.stopListeningToCountChanges() + } + + private fun clearSharedStorage() { + credentialRepository.remove() + sharedStorageRepository.remove(CoreGarminConnectViewModel.GARMIN_CONNECT_SUCCESSFUL_LOGIN) + } + + suspend fun removeStudyData() { + repositories.deleteAll() + observationFactory.clearNeededObservationTypes() + } + + private fun shutdown() { + notificationManager.setActionObserver(null) + } + + override fun close() { + shutdown() + } + + companion object { + val PROTOCOL = StringDesc.Resource(SharedRes.strings.deeplink_protocol) + val HOST = StringDesc.Resource(SharedRes.strings.deeplink_host) + + fun konnectionInstance() = Konnection.instance + + fun getSharedResource(id: StringResource): StringDesc = StringDesc.Resource(id) + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt new file mode 100644 index 000000000..63f54302f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt @@ -0,0 +1,57 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.database + +import androidx.room.ConstructedBy +import androidx.room.Database +import androidx.room.RoomDatabase +import io.redlink.more.database.dao.AggregatedObservationDataDao +import io.redlink.more.database.dao.BluetoothDeviceDao +import io.redlink.more.database.dao.DataPointDao +import io.redlink.more.database.dao.NotificationDao +import io.redlink.more.database.dao.ObservationDao +import io.redlink.more.database.dao.ObservationDataDao +import io.redlink.more.database.dao.ScheduleDao +import io.redlink.more.database.dao.StudyDao +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.DataPointEntity +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.entities.StudyEntity + +@Database( + entities = [ + StudyEntity::class, + ScheduleEntity::class, + ObservationEntity::class, + ObservationDataEntity::class, + NotificationEntity::class, + BluetoothDeviceEntity::class, + DataPointEntity::class, + AggregatedObservationDataEntity::class + ], + version = 3 +) +@ConstructedBy(AppDatabaseConstructor::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun studyDao(): StudyDao + abstract fun scheduleDao(): ScheduleDao + abstract fun observationDao(): ObservationDao + abstract fun observationDataDao(): ObservationDataDao + abstract fun notificationDao(): NotificationDao + abstract fun bluetoothDeviceDao(): BluetoothDeviceDao + abstract fun dataPointDao(): DataPointDao + abstract fun aggregatedObservationDataDao(): AggregatedObservationDataDao +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt new file mode 100644 index 000000000..c09ffe75e --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt @@ -0,0 +1,31 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database + +import androidx.room.RoomDatabase +import androidx.room.RoomDatabaseConstructor +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import io.redlink.more.database.migrations.MIGRATION_1_2 +import io.redlink.more.database.migrations.MIGRATION_2_3 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +@Suppress("KotlinNoActualForExpect") +expect object AppDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): AppDatabase +} + +fun getRoomDatabase(builder: RoomDatabase.Builder): AppDatabase = + builder + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .build() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt new file mode 100644 index 000000000..5f5abad70 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt @@ -0,0 +1,59 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AggregatedObservationDataDao : BaseDao { + + @Query("SELECT * FROM aggregated_observation_data WHERE id = :id") + suspend fun getById(id: String): AggregatedObservationDataEntity? + + @Query("SELECT * FROM aggregated_observation_data WHERE id = :id") + fun getByIdFlow(id: String): Flow + + @Query("SELECT * FROM aggregated_observation_data WHERE observationId = :observationId") + suspend fun getByObservationId(observationId: String): List + + @Query("SELECT * FROM aggregated_observation_data WHERE observationId = :observationId") + fun getByObservationIdFlow(observationId: String): Flow> + + @Query("SELECT * FROM aggregated_observation_data WHERE observationType = :observationType") + suspend fun getByObservationType(observationType: String): List + + @Query("SELECT * FROM aggregated_observation_data WHERE observationType = :observationType") + fun getByObservationTypeFlow(observationType: String): Flow> + + @Query("SELECT * FROM aggregated_observation_data") + suspend fun getAll(): List + + @Query("SELECT * FROM aggregated_observation_data") + fun getAllFlow(): Flow> + + @Query("DELETE FROM aggregated_observation_data WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM aggregated_observation_data WHERE observationId = :observationId") + suspend fun deleteByObservationId(observationId: String) + + @Query("DELETE FROM aggregated_observation_data") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM aggregated_observation_data") + suspend fun getCount(): Int + + @Query("SELECT COUNT(*) FROM aggregated_observation_data WHERE observationId = :observationId") + suspend fun getCountByObservationId(observationId: String): Int +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BaseDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BaseDao.kt new file mode 100644 index 000000000..ae3092541 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BaseDao.kt @@ -0,0 +1,52 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.dao + +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Update + +/** + * Base DAO interface containing standard CRUD operations that are common across all DAOs. + * All entity-specific DAO interfaces should extend this interface to inherit standard operations. + * + * @param T The entity type this DAO operates on + */ +interface BaseDao { + + /** + * Insert a single entity into the database + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: T) + + /** + * Insert a list of entities into the database + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + /** + * Update an existing entity in the database + */ + @Update + suspend fun update(entity: T) + + @Update + suspend fun updateAll(entities: List) + + /** + * Delete a single entity from the database + */ + @Delete + suspend fun delete(entity: T) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt new file mode 100644 index 000000000..23b660f7d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt @@ -0,0 +1,37 @@ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.BluetoothDeviceEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface BluetoothDeviceDao : BaseDao { + + @Query("SELECT * FROM BluetoothDeviceEntity") + suspend fun getAll(): List + + @Query("SELECT * FROM BluetoothDeviceEntity") + fun getAllFlow(): Flow> + + @Query("SELECT COUNT(*) FROM BluetoothDeviceEntity") + suspend fun getCount(): Int + + @Query("DELETE FROM BluetoothDeviceEntity") + suspend fun deleteAll() + + @Query("SELECT * FROM BluetoothDeviceEntity WHERE address = :address LIMIT 1") + suspend fun getByAddress(address: String): BluetoothDeviceEntity? + + @Query("SELECT * FROM BluetoothDeviceEntity WHERE address = :address") + fun getByAddressFlow(address: String): Flow + + @Query("SELECT address FROM BluetoothDeviceEntity") + suspend fun getAllAddresses(): List + + @Query("SELECT address FROM BluetoothDeviceEntity") + fun getAllAddressesFlow(): Flow> + + @Query("DELETE FROM BluetoothDeviceEntity WHERE address = :address") + suspend fun deleteByAddress(address: String) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt new file mode 100644 index 000000000..750ebd08b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt @@ -0,0 +1,30 @@ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.DataPointEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface DataPointDao : BaseDao { + @Query("SELECT COUNT(*) FROM data_points") + suspend fun getCount(): Int + + @Query("SELECT COUNT(*) FROM data_points") + fun getCountFlow(): Flow + + @Query("SELECT * FROM data_points WHERE scheduleId = :scheduleId LIMIT 1") + fun getByScheduleId(scheduleId: String): Flow + + @Query("DELETE FROM data_points WHERE scheduleId = :scheduleId") + suspend fun deleteByScheduleId(scheduleId: String) + + @Query("SELECT COUNT(*) FROM data_points WHERE scheduleId IN (:scheduleIds)") + suspend fun getCountByScheduleIds(scheduleIds: Set): Long + + @Query("DELETE FROM data_points") + suspend fun deleteAll() + + @Query("SELECT * FROM data_points") + suspend fun getAll(): List +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/NotificationDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/NotificationDao.kt new file mode 100644 index 000000000..1855fe241 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/NotificationDao.kt @@ -0,0 +1,172 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.NotificationEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface NotificationDao : BaseDao { + + @Query("DELETE FROM notifications WHERE notificationId = :notificationId") + suspend fun deleteById(notificationId: String) + + @Query("DELETE FROM notifications WHERE channelId = :channelId") + suspend fun deleteByChannelId(channelId: String) + + @Query("DELETE FROM notifications") + suspend fun deleteAll() + + @Query("SELECT * FROM notifications WHERE notificationId = :notificationId") + suspend fun getById(notificationId: String): NotificationEntity? + + @Query("SELECT * FROM notifications WHERE notificationId = :notificationId") + fun getByIdFlow(notificationId: String): Flow + + @Query("SELECT * FROM notifications") + suspend fun getAll(): List + + @Query("SELECT * FROM notifications") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM notifications WHERE channelId = :channelId") + suspend fun getByChannelId(channelId: String): List + + @Query("SELECT * FROM notifications WHERE channelId = :channelId") + fun getByChannelIdFlow(channelId: String): Flow> + + @Query("SELECT * FROM notifications WHERE read = :read") + suspend fun getByReadStatus(read: Boolean): List + + @Query("SELECT * FROM notifications WHERE read = :read") + fun getByReadStatusFlow(read: Boolean): Flow> + + @Query("SELECT * FROM notifications WHERE completed = :completed") + suspend fun getByCompletedStatus(completed: Boolean): List + + @Query("SELECT * FROM notifications WHERE completed = :completed") + fun getByCompletedStatusFlow(completed: Boolean): Flow> + + @Query("SELECT * FROM notifications WHERE userFacing = :userFacing") + suspend fun getByUserFacing(userFacing: Boolean): List + + @Query("SELECT * FROM notifications WHERE userFacing = :userFacing") + fun getByUserFacingFlow(userFacing: Boolean): Flow> + + @Query("SELECT * FROM notifications WHERE priority = :priority") + suspend fun getByPriority(priority: Long): List + + @Query("SELECT * FROM notifications WHERE priority = :priority") + fun getByPriorityFlow(priority: Long): Flow> + + @Query("SELECT * FROM notifications WHERE priority >= :minPriority") + suspend fun getByMinPriority(minPriority: Long): List + + @Query("SELECT * FROM notifications WHERE priority >= :minPriority") + fun getByMinPriorityFlow(minPriority: Long): Flow> + + @Query("SELECT * FROM notifications WHERE timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + suspend fun getByTimeRange(fromTimestamp: Long, toTimestamp: Long): List + + @Query("SELECT * FROM notifications WHERE timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + fun getByTimeRangeFlow(fromTimestamp: Long, toTimestamp: Long): Flow> + + @Query("SELECT * FROM notifications WHERE deepLink IS NOT NULL AND deepLink != ''") + suspend fun getWithDeepLink(): List + + @Query("SELECT * FROM notifications WHERE deepLink IS NOT NULL AND deepLink != ''") + fun getWithDeepLinkFlow(): Flow> + + @Query("SELECT * FROM notifications WHERE title LIKE '%' || :searchTerm || '%' OR notificationBody LIKE '%' || :searchTerm || '%'") + suspend fun searchByContent(searchTerm: String): List + + @Query("SELECT * FROM notifications WHERE title LIKE '%' || :searchTerm || '%' OR notificationBody LIKE '%' || :searchTerm || '%'") + fun searchByContentFlow(searchTerm: String): Flow> + + @Query("SELECT * FROM notifications ORDER BY timestamp DESC LIMIT :limit") + suspend fun getLatest(limit: Int): List + + @Query("SELECT * FROM notifications WHERE userFacing = :userFacing") + fun getByPastUserFacingFlow( + userFacing: Boolean + ): Flow> + + @Query("SELECT COUNT(*) FROM notifications WHERE read = 0 AND userFacing = 1") + fun getUnreadUserFacingFromPastFlow(): Flow + + @Query("SELECT COUNT(*) FROM notifications WHERE timestamp > :currentTimestamp") + suspend fun getScheduledNotificationCount(currentTimestamp: Long): Int + + @Query("SELECT * FROM notifications WHERE timestamp > :currentTimestamp") + suspend fun getScheduledNotifications(currentTimestamp: Long): List + + @Query("SELECT * FROM notifications ORDER BY timestamp DESC LIMIT :limit") + fun getLatestFlow(limit: Int): Flow> + + @Query("SELECT * FROM notifications WHERE userFacing = 1 ORDER BY timestamp DESC LIMIT :limit") + suspend fun getLatestUserFacing(limit: Int): List + + @Query("SELECT * FROM notifications WHERE userFacing = 1 ORDER BY timestamp DESC LIMIT :limit") + fun getLatestUserFacingFlow(limit: Int): Flow> + + @Query("SELECT * FROM notifications WHERE read = 0 AND userFacing = 1 ORDER BY priority DESC, timestamp DESC") + suspend fun getUnreadUserFacing(): List + + @Query("SELECT * FROM notifications WHERE read = 0 AND userFacing = 1 ORDER BY priority DESC, timestamp DESC") + fun getUnreadUserFacingFlow(): Flow> + + @Query("SELECT * FROM notifications ORDER BY priority DESC, timestamp DESC") + suspend fun getAllOrderedByPriorityAndTime(): List + + @Query("SELECT * FROM notifications ORDER BY priority DESC, timestamp DESC") + fun getAllOrderedByPriorityAndTimeFlow(): Flow> + + @Query("SELECT COUNT(*) FROM notifications") + fun getCount(): Flow + + @Query("SELECT COUNT(*) FROM notifications WHERE read = :read") + suspend fun getCountByReadStatus(read: Boolean): Int + + @Query("SELECT COUNT(*) FROM notifications WHERE completed = :completed") + suspend fun getCountByCompletedStatus(completed: Boolean): Int + + @Query("SELECT COUNT(*) FROM notifications WHERE userFacing = :userFacing") + fun getCountByUserFacing(userFacing: Boolean): Flow + + @Query("SELECT COUNT(*) FROM notifications WHERE read = 0 AND userFacing = 1") + suspend fun getUnreadUserFacingCount(): Int + + @Query("SELECT COUNT(*) FROM notifications WHERE read = 0 AND userFacing = 1") + fun getUnreadUserFacingCountFlow(): Flow + + @Query("SELECT DISTINCT channelId FROM notifications WHERE channelId IS NOT NULL") + suspend fun getAllChannelIds(): List + + @Query("UPDATE notifications SET read = :read WHERE notificationId = :notificationId") + suspend fun updateReadStatus(notificationId: String, read: Boolean) + + @Query("UPDATE notifications SET completed = :completed WHERE notificationId = :notificationId") + suspend fun updateCompletedStatus(notificationId: String, completed: Boolean) + + @Query("UPDATE notifications SET read = 1 WHERE channelId = :channelId") + suspend fun markAllAsReadByChannelId(channelId: String) + + @Query("UPDATE notifications SET read = 1") + suspend fun markAllAsRead() + + @Query("DELETE FROM notifications WHERE timestamp < :timestamp") + suspend fun deleteOlderThan(timestamp: Long): Int + + @Query("DELETE FROM notifications WHERE read = 1 AND completed = 1 AND timestamp < :timestamp") + suspend fun deleteOldReadAndCompleted(timestamp: Long): Int +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDao.kt new file mode 100644 index 000000000..59b566f94 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDao.kt @@ -0,0 +1,98 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.ObservationEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ObservationDao : BaseDao { + + @Query("DELETE FROM observations WHERE observationId = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM observations WHERE observationId = :observationId") + suspend fun deleteByObservationId(observationId: String) + + @Query("DELETE FROM observations") + suspend fun deleteAll() + + @Query("SELECT * FROM observations WHERE observationId = :observationId") + suspend fun getByObservationId(observationId: String): ObservationEntity? + + @Query("SELECT * FROM observations WHERE observationId = :observationId") + fun getByObservationIdFlow(observationId: String): Flow + + @Query("SELECT * FROM observations") + suspend fun getAll(): List + + @Query("SELECT * FROM observations") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM observations WHERE observationType = :observationType") + suspend fun getByObservationType(observationType: String): List + + @Query("SELECT * FROM observations WHERE observationType = :observationType") + fun getByObservationTypeFlow(observationType: String): Flow> + + @Query("SELECT * FROM observations WHERE hidden = :hidden") + suspend fun getByHidden(hidden: Boolean): List + + @Query("SELECT * FROM observations WHERE hidden = :hidden") + fun getByHiddenFlow(hidden: Boolean): Flow> + + @Query("SELECT * FROM observations WHERE scheduleLess = :scheduleLess") + suspend fun getByScheduleLess(scheduleLess: Boolean): List + + @Query("SELECT * FROM observations WHERE scheduleLess = :scheduleLess") + fun getByScheduleLessFlow(scheduleLess: Boolean): Flow> + + @Query("SELECT * FROM observations WHERE required = :required") + suspend fun getByRequired(required: Boolean): List + + @Query("SELECT * FROM observations WHERE required = :required") + fun getByRequiredFlow(required: Boolean): Flow> + + @Query("SELECT * FROM observations WHERE version = :version") + suspend fun getByVersion(version: Long): List + + @Query("SELECT * FROM observations WHERE collectionTimestamp >= :fromTimestamp AND collectionTimestamp <= :toTimestamp") + suspend fun getByTimeRange(fromTimestamp: Long, toTimestamp: Long): List + + @Query("SELECT * FROM observations WHERE collectionTimestamp >= :fromTimestamp AND collectionTimestamp <= :toTimestamp") + fun getByTimeRangeFlow(fromTimestamp: Long, toTimestamp: Long): Flow> + + @Query("SELECT * FROM observations WHERE observationTitle LIKE '%' || :searchTerm || '%'") + suspend fun searchByTitle(searchTerm: String): List + + @Query("SELECT * FROM observations WHERE participantInfo LIKE '%' || :searchTerm || '%'") + suspend fun searchByParticipantInfo(searchTerm: String): List + + @Query("SELECT COUNT(*) FROM observations") + suspend fun getCount(): Int + + @Query("SELECT COUNT(*) FROM observations WHERE observationType = :observationType") + suspend fun getCountByType(observationType: String): Int + + @Query("SELECT COUNT(*) FROM observations WHERE required = :required") + suspend fun getCountByRequired(required: Boolean): Int + + @Query("SELECT DISTINCT observationType FROM observations") + suspend fun getAllObservationTypes(): List + + @Query("UPDATE observations SET version = :version WHERE observationId = :observationId") + suspend fun updateVersion(observationId: String, version: Long) + + @Query("UPDATE observations SET hidden = :hidden WHERE observationId = :observationId") + suspend fun updateHidden(observationId: String, hidden: Boolean) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDataDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDataDao.kt new file mode 100644 index 000000000..43ed04095 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDataDao.kt @@ -0,0 +1,165 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.ObservationDataEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ObservationDataDao : BaseDao { + + @Query("DELETE FROM observation_data WHERE dataId = :dataId") + suspend fun deleteById(dataId: String) + + @Query("DELETE FROM observation_data WHERE observationId = :observationId") + suspend fun deleteByObservationId(observationId: String) + + @Query("DELETE FROM observation_data WHERE observationType = :observationType") + suspend fun deleteByObservationType(observationType: String) + + @Query("DELETE FROM observation_data") + suspend fun deleteAll() + + @Query("SELECT * FROM observation_data WHERE dataId = :dataId") + suspend fun getById(dataId: String): ObservationDataEntity? + + @Query("SELECT * FROM observation_data WHERE dataId = :dataId") + fun getByIdFlow(dataId: String): Flow + + @Query("SELECT * FROM observation_data") + suspend fun getAll(): List + + @Query("SELECT * FROM observation_data") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId") + suspend fun getByObservationId(observationId: String): List + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId") + fun getByObservationIdFlow(observationId: String): Flow> + + @Query("SELECT * FROM observation_data WHERE observationType = :observationType") + suspend fun getByObservationType(observationType: String): List + + @Query("SELECT * FROM observation_data WHERE observationType = :observationType") + fun getByObservationTypeFlow(observationType: String): Flow> + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId AND observationType = :observationType") + suspend fun getByObservationIdAndType( + observationId: String, + observationType: String + ): List + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId AND observationType = :observationType") + fun getByObservationIdAndTypeFlow( + observationId: String, + observationType: String + ): Flow> + + @Query("SELECT * FROM observation_data WHERE timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + suspend fun getByTimeRange(fromTimestamp: Long, toTimestamp: Long): List + + @Query("SELECT * FROM observation_data WHERE timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + fun getByTimeRangeFlow( + fromTimestamp: Long, + toTimestamp: Long + ): Flow> + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId AND timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + suspend fun getByObservationIdAndTimeRange( + observationId: String, + fromTimestamp: Long, + toTimestamp: Long + ): List + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId AND timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + fun getByObservationIdAndTimeRangeFlow( + observationId: String, + fromTimestamp: Long, + toTimestamp: Long + ): Flow> + + @Query("SELECT * FROM observation_data WHERE observationType = :observationType AND timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + suspend fun getByObservationTypeAndTimeRange( + observationType: String, + fromTimestamp: Long, + toTimestamp: Long + ): List + + @Query("SELECT * FROM observation_data WHERE observationType = :observationType AND timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + fun getByObservationTypeAndTimeRangeFlow( + observationType: String, + fromTimestamp: Long, + toTimestamp: Long + ): Flow> + + @Query("SELECT * FROM observation_data WHERE timestamp >= :timestamp ORDER BY timestamp ASC") + suspend fun getFromTimestamp(timestamp: Long): List + + @Query("SELECT * FROM observation_data WHERE timestamp >= :timestamp ORDER BY timestamp ASC") + fun getFromTimestampFlow(timestamp: Long): Flow> + + @Query("SELECT * FROM observation_data WHERE timestamp <= :timestamp ORDER BY timestamp DESC") + suspend fun getUpToTimestamp(timestamp: Long): List + + @Query("SELECT * FROM observation_data WHERE timestamp <= :timestamp ORDER BY timestamp DESC") + fun getUpToTimestampFlow(timestamp: Long): Flow> + + @Query("SELECT * FROM observation_data ORDER BY timestamp DESC LIMIT :limit") + suspend fun getLatest(limit: Int): List + + @Query("SELECT * FROM observation_data ORDER BY timestamp DESC LIMIT :limit") + fun getLatestFlow(limit: Int): Flow> + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId ORDER BY timestamp DESC LIMIT :limit") + suspend fun getLatestByObservationId( + observationId: String, + limit: Int + ): List + + @Query("SELECT * FROM observation_data WHERE observationId = :observationId ORDER BY timestamp DESC LIMIT :limit") + fun getLatestByObservationIdFlow( + observationId: String, + limit: Int + ): Flow> + + @Query("SELECT COUNT(*) FROM observation_data") + suspend fun getCount(): Int + + @Query("SELECT COUNT(*) FROM observation_data WHERE observationId = :observationId") + suspend fun getCountByObservationId(observationId: String): Int + + @Query("SELECT COUNT(*) FROM observation_data WHERE observationType = :observationType") + suspend fun getCountByObservationType(observationType: String): Int + + @Query("SELECT COUNT(*) FROM observation_data WHERE timestamp >= :fromTimestamp AND timestamp <= :toTimestamp") + suspend fun getCountByTimeRange(fromTimestamp: Long, toTimestamp: Long): Int + + @Query("SELECT DISTINCT observationType FROM observation_data") + suspend fun getAllObservationTypes(): List + + @Query("SELECT DISTINCT observationId FROM observation_data") + suspend fun getAllObservationIds(): List + + @Query("SELECT MIN(timestamp) FROM observation_data") + suspend fun getEarliestTimestamp(): Long? + + @Query("SELECT MAX(timestamp) FROM observation_data") + suspend fun getLatestTimestamp(): Long? + + @Query("DELETE FROM observation_data WHERE timestamp < :timestamp") + suspend fun deleteOlderThan(timestamp: Long): Int + + @Query("DELETE FROM observation_data WHERE observationId = :observationId AND timestamp < :timestamp") + suspend fun deleteOlderThanByObservationId(observationId: String, timestamp: Long): Int +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt new file mode 100644 index 000000000..54078c0a9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt @@ -0,0 +1,124 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.ScheduleEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ScheduleDao : BaseDao { + + @Query("DELETE FROM schedules WHERE scheduleId = :scheduleId") + suspend fun deleteById(scheduleId: String) + + @Query("DELETE FROM schedules WHERE observationId = :observationId") + suspend fun deleteByObservationId(observationId: String) + + @Query("DELETE FROM schedules") + suspend fun deleteAll() + + @Query("SELECT * FROM schedules WHERE scheduleId = :scheduleId") + fun getById(scheduleId: String): Flow + + @Query("SELECT * FROM schedules WHERE scheduleId = :scheduleId") + fun getByIdFlow(scheduleId: String): Flow + + @Query("SELECT * FROM schedules") + suspend fun getAll(): List + + @Query("SELECT * FROM schedules") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM schedules WHERE observationId = :observationId") + suspend fun getByObservationId(observationId: String): List + + @Query("SELECT * FROM schedules WHERE observationId = :observationId") + fun getByObservationIdFlow(observationId: String): Flow> + + @Query("SELECT * FROM schedules WHERE observationType = :observationType") + suspend fun getByObservationType(observationType: String): List + + @Query("SELECT * FROM schedules WHERE observationType = :observationType") + fun getByObservationTypeFlow(observationType: String): Flow> + + @Query("SELECT * FROM schedules WHERE done = :done") + suspend fun getByDone(done: Boolean): List + + @Query("SELECT * FROM schedules WHERE done = :done") + fun getByDoneFlow(done: Boolean): Flow> + + @Query("SELECT * FROM schedules WHERE state IN (:states)") + fun getByStatesFlow(states: List): Flow> + + @Query("SELECT * FROM schedules WHERE hidden = :hidden") + suspend fun getByHidden(hidden: Boolean): List + + @Query("SELECT * FROM schedules WHERE hidden = :hidden") + fun getByHiddenFlow(hidden: Boolean): Flow> + + @Query("SELECT * FROM schedules WHERE state = :state") + suspend fun getByState(state: String): List + + @Query("SELECT * FROM schedules WHERE state = :state") + fun getByStateFlow(state: String): Flow> + + @Query( + "SELECT * " + + "FROM schedules " + + "WHERE state IN (:states) " + + "AND reminder = 1" + + " AND start >= :minTimestamp" + + " AND start <= :maxTimestamp " + + "ORDER BY start ASC" + + " LIMIT :limit" + ) + fun getSchedulesWithReminder( + states: List, + minTimestamp: Long, + maxTimestamp: Long, + limit: Int + ): Flow> + + @Query("SELECT * FROM schedules WHERE start <= :timestamp AND `end` >= :timestamp") + suspend fun getActiveSchedulesAtTime(timestamp: Long): List + + @Query("SELECT * FROM schedules WHERE start <= :currentTime AND done = 0 AND hidden = 0") + suspend fun getAvailableSchedules(currentTime: Long): List + + @Query("SELECT * FROM schedules WHERE start <= :currentTime AND done = 0 AND hidden = 0") + fun getAvailableSchedulesFlow(currentTime: Long): Flow> + + @Query("SELECT * FROM schedules WHERE `end` < :timestamp AND done = 0") + suspend fun getExpiredSchedules(timestamp: Long): List + + @Query("SELECT COUNT(*) FROM schedules") + suspend fun getCount(): Int + + @Query("SELECT COUNT(*) FROM schedules") + fun countAsFlow(): Flow + + @Query("SELECT COUNT(*) FROM schedules WHERE done = :done") + suspend fun getCountByDone(done: Boolean): Int + + @Query("SELECT COUNT(*) FROM schedules WHERE observationId = :observationId") + suspend fun getCountByObservationId(observationId: String): Int + + @Query("SELECT DISTINCT observationType FROM schedules WHERE scheduleId IN (:scheduleIds)") + fun getObservationTypesForScheduleIds(scheduleIds: Set): Flow> + + @Query("UPDATE schedules SET done = :done WHERE scheduleId = :scheduleId") + suspend fun updateDoneStatus(scheduleId: String, done: Boolean) + + @Query("UPDATE schedules SET state = :state WHERE scheduleId = :scheduleId") + suspend fun updateState(scheduleId: String, state: String) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/StudyDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/StudyDao.kt new file mode 100644 index 000000000..66bbc6528 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/StudyDao.kt @@ -0,0 +1,65 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.database.entities.StudyEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface StudyDao : BaseDao { + + @Query("DELETE FROM studies WHERE studyId = :studyId") + suspend fun deleteById(studyId: String) + + @Query("DELETE FROM studies") + suspend fun deleteAll() + + @Query("SELECT * FROM studies WHERE studyId = :studyId") + suspend fun getById(studyId: String): StudyEntity? + + @Query("SELECT * FROM studies WHERE studyId = :studyId") + fun getByIdFlow(studyId: String): Flow + + @Query("SELECT * FROM studies LIMIT 1") + suspend fun get(): StudyEntity? + + @Query("SELECT * FROM studies LIMIT 1") + fun getFlow(): Flow + + @Query("SELECT * FROM studies WHERE active = :active") + suspend fun getByActive(active: Boolean): List + + @Query("SELECT * FROM studies WHERE active = :active") + fun getByActiveFlow(active: Boolean): Flow> + + @Query("SELECT * FROM studies WHERE state = :state") + suspend fun getByState(state: String): List + + @Query("SELECT * FROM studies WHERE state = :state") + fun getByStateFlow(state: String): Flow> + + @Query("SELECT * FROM studies WHERE participantId = :participantId") + suspend fun getByParticipantId(participantId: Int): List + + @Query("SELECT * FROM studies WHERE start <= :timestamp AND `end` >= :timestamp") + suspend fun getActiveStudiesAtTime(timestamp: Long): List + + @Query("SELECT COUNT(*) FROM studies") + fun getCount(): Flow + + @Query("SELECT COUNT(*) FROM studies WHERE active = :active") + suspend fun getCountByActive(active: Boolean): Int + + @Query("UPDATE studies SET state = :state WHERE studyId = :studyId") + suspend fun updateStudyState(studyId: String, state: String) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt new file mode 100644 index 000000000..c9d4597d9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt @@ -0,0 +1,123 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.redlink.more.util.createUUID +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +@Entity(tableName = "aggregated_observation_data") +data class AggregatedObservationDataEntity( + @PrimaryKey val id: String = createUUID(), + val observationId: String = "", + val observationType: String = "", + val startTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + val endTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + val data: String? = null, + val metadata: String = "" +) { + @Serializable + data class TimestampedData( + val timestamp: Long, + val data: T + ) + + @Ignore + inline fun getDataAs(): T? { + return data?.let { + Json.decodeFromJsonElement(Json.parseToJsonElement(it)) + } + } + + @Ignore + inline fun update( + data: T, + metadata: String = this.metadata, + timestamp: Long = Clock.System.now().toEpochMilliseconds() + ): AggregatedObservationDataEntity { + val currentData: List> = + getDataAs>>() ?: emptyList() + val newData = currentData + TimestampedData(timestamp, data) + return this.copy( + data = Json.encodeToString(Json.encodeToJsonElement(newData)), + metadata = metadata, + endTimestamp = timestamp + ) + } + + @Ignore + inline fun update(data: AggregatedObservationDataEntity): AggregatedObservationDataEntity? { + return data.getDataAs()?.let { + return this.update(it, data.metadata, data.endTimestamp) + } + } + + @Serializable + data class PendingObservationPayload( + val startTimestamp: Long, + val endTimestamp: Long, + val metadata: String, + val payload: T? + ) + + @Ignore + inline fun toObservationDataEntity(): ObservationDataEntity { + return ObservationDataEntity( + observationId = observationId, + observationType = observationType, + dataValue = Json.encodeToString( + PendingObservationPayload( + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + metadata = metadata, + payload = getDataAs() + ) + ), + timestamp = endTimestamp + ) + } + + companion object { + inline fun fromData( + observationId: String, + observationType: String, + data: T, + startTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + endTimestamp: Long = Clock.System.now().toEpochMilliseconds(), + metadata: String = "" + ): AggregatedObservationDataEntity { + return AggregatedObservationDataEntity( + observationId = observationId, + observationType = observationType, + data = Json.encodeToString( + Json.encodeToJsonElement( + listOf( + TimestampedData( + startTimestamp, + data + ) + ) + ) + ), + startTimestamp = startTimestamp, + endTimestamp = endTimestamp, + metadata = metadata + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt new file mode 100644 index 000000000..fcc547a06 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt @@ -0,0 +1,27 @@ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.redlink.more.util.createUUID + +@Entity +data class BluetoothDeviceEntity( + @PrimaryKey + val deviceId: String = createUUID(), + val deviceName: String? = null, + val address: String? = null, +) { + override fun toString(): String { + return "BluetoothDevice {deviceId: $deviceId, name: $deviceName, address: $address}" + } + + companion object { + fun create( + deviceId: String, + deviceName: String, + address: String, + ): BluetoothDeviceEntity { + return BluetoothDeviceEntity(deviceId, deviceName, address) + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/DataPointEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/DataPointEntity.kt new file mode 100644 index 000000000..014242ea9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/DataPointEntity.kt @@ -0,0 +1,13 @@ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "data_points") +data class DataPointEntity( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val scheduleId: String = "", + val count: Long = 0L, +) { +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/NotificationEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/NotificationEntity.kt new file mode 100644 index 000000000..e133809e7 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/NotificationEntity.kt @@ -0,0 +1,139 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.redlink.more.getPlatform +import io.redlink.more.services.network.openapi.model.PushNotification +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.util.createUUID +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json + +@Entity(tableName = "notifications") +data class NotificationEntity( + @PrimaryKey + val notificationId: String = createUUID(), + val channelId: String? = "", + val title: String? = "", + val notificationBody: String? = "", + val timestamp: Long? = Clock.System.now().epochSeconds, + val priority: Long = 0, + val read: Boolean = false, + val completed: Boolean = false, + val userFacing: Boolean = true, + val deepLink: String? = null, + val notificationData: String = "{}" // JSON string representation +) { + @Ignore + fun timestampInstant() = timestamp?.let { Instant.fromEpochSeconds(it) } + + @Ignore + fun getNotificationDataMap(): Map { + return try { + Json.decodeFromString>(notificationData) + } catch (e: Exception) { + emptyMap() + } + } + + @Ignore + fun deepLink(): String? = deepLink?.let { + if (!it.contains("notificationId=")) { + if (it.contains("?")) { + "$it¬ificationId=$notificationId" + } else { + "$it?notificationId=$notificationId" + } + } else { + it + } + } + + override fun toString(): String { + return "NotificationEntity(notificationId='$notificationId', channelId=$channelId, title=$title, notificationBody=$notificationBody, timestamp=${timestamp.toString()}, priority=$priority, read=$read, userFacing=$userFacing, deepLink=$deepLink, notificationData=$notificationData)" + } + + companion object { + fun build(title: String, notificationBody: String): NotificationEntity = + NotificationEntity( + notificationId = createUUID(), + title = title, + notificationBody = notificationBody, + priority = if (getPlatform().name.contains("Android")) 2 else 1 + ) + + fun toEntity( + notificationId: String, + channelId: String?, + title: String?, + notificationBody: String?, + timestamp: Long? = null, + priority: Long, + read: Boolean, + completed: Boolean, + userFacing: Boolean, + notificationData: Map?, + deepLink: String? = null + ): NotificationEntity { + val dataJson = try { + notificationData?.mapKeys { it.key.replace(".", "_") }?.let { + Json.encodeToString(it) + } ?: "{}" + } catch (e: Exception) { + "{}" + } + + val extractedDeepLink = deepLink ?: extractDeepLink(notificationData ?: emptyMap()) + val finalPriority = if (extractedDeepLink != null) 2 else priority + val finalTimestamp = timestamp ?: Clock.System.now().epochSeconds + + return NotificationEntity( + notificationId = notificationId, + channelId = channelId, + title = title, + notificationBody = notificationBody, + read = read, + completed = completed, + userFacing = userFacing, + notificationData = dataJson, + deepLink = extractedDeepLink, + priority = finalPriority, + timestamp = finalTimestamp + ) + } + + fun toEntity(notification: PushNotification): NotificationEntity { + return toEntity( + notificationId = notification.msgId ?: createUUID(), + channelId = null, + title = notification.title, + notificationBody = notification.body, + timestamp = notification.timestamp?.epochSeconds, + priority = 1, + read = false, + completed = false, + userFacing = notification.type == PushNotification.Type.TEXT, + notificationData = notification.data?.mapValues { it.value.toString() }, + deepLink = notification.deepLink + ) + } + + fun toEntityList(notifications: List): List = + notifications.map { toEntity(it) } + + private fun extractDeepLink(data: Map) = + data[NotificationManager.DEEP_LINK] + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt new file mode 100644 index 000000000..e6c4be7e7 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt @@ -0,0 +1,103 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.github.aakira.napier.Napier +import io.redlink.more.extensions.asString +import io.redlink.more.observations.ObservationBulkModel +import io.redlink.more.services.network.openapi.model.ObservationData +import io.redlink.more.util.createUUID +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject + +@Entity(tableName = "observation_data") +data class ObservationDataEntity( + @PrimaryKey + val dataId: String = createUUID(), + val observationId: String = "", + var observationType: String = "", + val dataValue: String = "", + val timestamp: Long = Clock.System.now().toEpochMilliseconds() +) { + @Ignore + fun timestampInstant() = Instant.fromEpochMilliseconds(timestamp) + + @Ignore + fun asObservationData(): ObservationData = + ObservationData( + dataId = this.dataId, + observationId = this.observationId, + observationType = this.observationType, + dataValue = try { + Json.parseToJsonElement(dataValue).jsonObject + } catch (e: Exception) { + Napier.e(tag = this::class.asString()) { e.stackTraceToString() } + JsonObject(emptyMap()) + }, + timestamp = timestampInstant() + ) + + override fun toString(): String { + return "dataId: $dataId; observationId: $observationId; observationType: $observationType, timestamp: $timestamp, data: $dataValue;" + } + + companion object { + fun fromObservationData(observationData: ObservationData): ObservationDataEntity { + return ObservationDataEntity( + dataId = observationData.dataId, + observationId = observationData.observationId, + observationType = observationData.observationType, + dataValue = observationData.dataValue?.let { Json.encodeToString(it) } ?: "", + timestamp = observationData.timestamp.toEpochMilliseconds() + ) + } + + inline fun fromData(data: T, timestamp: Long = -1): ObservationDataEntity { + val finalTimestamp = if (timestamp > 0) { + if (timestamp < 100_000_000_000L) { + timestamp * 1000 // Convert to milliseconds + } else { + timestamp + } + } else { + Clock.System.now().toEpochMilliseconds() + } + + return ObservationDataEntity( + timestamp = finalTimestamp, + dataValue = data?.asString() ?: "{}" + ) + } + + fun fromData(data: ObservationBulkModel): ObservationDataEntity { + return fromData(data.data, data.timestamp) + } + + fun fromData(data: Collection): List { + return data.map { fromData(it) } + } + + fun fromData( + observationIdSet: Set, + data: Collection + ): List { + return observationIdSet.flatMap { id -> + fromData(data).map { it.copy(observationId = id) } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationEntity.kt new file mode 100644 index 000000000..d4c5b4ca4 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationEntity.kt @@ -0,0 +1,67 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.github.aakira.napier.Napier +import io.redlink.more.services.network.openapi.model.Observation +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +@Entity(tableName = "observations") +data class ObservationEntity( + @PrimaryKey + val observationId: String = "", + val observationType: String = "", + val observationTitle: String = "", + val participantInfo: String = "", + val configuration: String? = null, + val hidden: Boolean? = null, + val scheduleLess: Boolean = false, + val reminder: Boolean = false, + val version: Long = 0, + val required: Boolean = false, + val collectionTimestamp: Long = Clock.System.now().toEpochMilliseconds() +) { + @Ignore + fun collectionTimestampInstant() = Instant.fromEpochMilliseconds(collectionTimestamp) + + @Ignore + fun configAsMap(): Map = configuration?.let { config -> + try { + Json.decodeFromString(config).toMap() + } catch (e: Exception) { + Napier.e { e.stackTraceToString() } + emptyMap() + } + } ?: emptyMap() + + companion object { + fun toEntity(observation: Observation): ObservationEntity { + return ObservationEntity( + observationId = observation.observationId, + observationTitle = observation.observationTitle, + observationType = observation.observationType, + participantInfo = observation.participantInfo, + configuration = observation.configuration.toString(), + hidden = observation.hidden, + scheduleLess = observation.noSchedule ?: false, + reminder = observation.reminder ?: false, + required = observation.required, + version = observation.version + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt new file mode 100644 index 000000000..7fc989c2a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt @@ -0,0 +1,88 @@ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.redlink.more.models.ScheduleState +import io.redlink.more.services.network.openapi.model.ObservationSchedule +import io.redlink.more.util.createUUID +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +@Entity(tableName = "schedules") +data class ScheduleEntity( + @PrimaryKey + val scheduleId: String = createUUID(), + val observationId: String = "", + val observationType: String = "", + val observationTitle: String = "", + val start: Long? = null, + val end: Long? = null, + val done: Boolean = false, + val hidden: Boolean = false, + val reminder: Boolean = false, + val state: String = ScheduleState.DEACTIVATED.name +) { + fun getState() = ScheduleState.getState(state) + fun startInstant() = start?.let { Instant.fromEpochSeconds(it) } + fun endInstant() = end?.let { Instant.fromEpochSeconds(it) } + + fun updateState(specificState: ScheduleState? = null): ScheduleState { + return if (specificState != null) { + specificState + } else { + val now = Clock.System.now().epochSeconds + start?.let { startTime -> + end?.let { endTime -> + when { + endTime <= now -> { + if (getState().running()) { + ScheduleState.DONE + } else { + ScheduleState.ENDED + } + } + + now < startTime -> ScheduleState.DEACTIVATED + startTime <= now && !getState().active() -> ScheduleState.ACTIVE + else -> ScheduleState.getState(state) + } + } + } ?: ScheduleState.getState(state) + } + } + + override fun toString(): String { + return """ScheduleEntity: {"scheduleId": "$scheduleId", "observationId": "$observationId","observationType": "$observationType","observationTitle": "$observationTitle","start": "$start","end": "$end","done": $done,"hidden": $hidden,"state": "$state"}""" + } + + companion object { + fun fromObservationSchedule( + schedule: ObservationSchedule, + observationId: String, + observationType: String, + observationTitle: String, + hidden: Boolean, + reminder: Boolean + ): ScheduleEntity? { + return if (schedule.start != null && schedule.end != null) { + val now = Clock.System.now().epochSeconds + val scheduleState = when { + schedule.start.epochSeconds < now && schedule.end.epochSeconds > now -> ScheduleState.ACTIVE + schedule.start.epochSeconds > now -> ScheduleState.DEACTIVATED + else -> ScheduleState.ENDED + } + + ScheduleEntity( + observationId = observationId, + observationType = observationType, + observationTitle = observationTitle, + start = schedule.start.epochSeconds, + end = schedule.end.epochSeconds, + hidden = hidden, + state = scheduleState.name, + reminder = reminder + ) + } else null + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt new file mode 100644 index 000000000..feebbb925 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt @@ -0,0 +1,68 @@ +package io.redlink.more.database.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.redlink.more.extensions.toStudyState +import io.redlink.more.models.StudyState +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.util.createUUID +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn + +@Entity(tableName = "studies") +data class StudyEntity( + @PrimaryKey + val studyId: String = createUUID(), + val studyTitle: String = "", + val participantId: Int? = null, + val participantAlias: String? = "", + val participantInfo: String = "", + val consentInfo: String = "", + val start: Long? = null, + val end: Long? = null, + val contactInstitute: String? = null, + val contactPerson: String? = null, + val contactEmail: String? = null, + val contactPhoneNumber: String? = null, + val version: Long = 0, + val active: Boolean = false, + val state: String = (if (active) StudyState.ACTIVE else StudyState.PAUSED).descr, + val finishText: String? = null +) { + @Ignore + fun getState() = StudyState.getState(state) + + @Ignore + fun startInstant() = start?.let { Instant.fromEpochSeconds(it) } + + @Ignore + fun endInstant() = end?.let { Instant.fromEpochSeconds(it) } + + companion object { + fun fromStudy(study: Study): StudyEntity { + val active = study.active ?: false + return StudyEntity( + studyTitle = study.studyTitle, + consentInfo = study.consentInfo, + participantInfo = study.participantInfo, + participantId = study.participant?.id, + participantAlias = study.participant?.alias, + start = study.start.atStartOfDayIn(TimeZone.currentSystemDefault()) + .epochSeconds, + end = study.end.atStartOfDayIn(TimeZone.currentSystemDefault()) + .epochSeconds, + contactInstitute = study.contact?.institute, + contactPerson = study.contact?.person, + contactEmail = study.contact?.email, + contactPhoneNumber = study.contact?.phoneNumber, + version = study.version, + active = active, + state = (study.studyState?.toStudyState() + ?: if (active) StudyState.ACTIVE else StudyState.PAUSED).descr, + finishText = study.finishText + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_1_2.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_1_2.kt new file mode 100644 index 000000000..7d3552989 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_1_2.kt @@ -0,0 +1,12 @@ +package io.redlink.more.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE schedules ADD COLUMN reminder INTEGER NOT NULL DEFAULT 0") + connection.execSQL("ALTER TABLE observations ADD COLUMN reminder INTEGER NOT NULL DEFAULT 0") + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt new file mode 100644 index 000000000..bb775e984 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt @@ -0,0 +1,22 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("CREATE TABLE IF NOT EXISTS `aggregated_observation_data` (`id` TEXT NOT NULL, `observationId` TEXT NOT NULL, `observationType` TEXT NOT NULL, `startTimestamp` INTEGER NOT NULL, `endTimestamp` INTEGER NOT NULL, `data` TEXT, `metadata` TEXT NOT NULL, PRIMARY KEY(`id`))") + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt new file mode 100644 index 000000000..7b01da8cb --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import kotlinx.coroutines.flow.Flow + +interface AggregatedObservationDataRepository { + suspend fun insert(entity: AggregatedObservationDataEntity) + suspend fun insertAll(entities: List) + suspend fun update(entity: AggregatedObservationDataEntity) + suspend fun delete(entity: AggregatedObservationDataEntity) + suspend fun deleteById(id: String) + suspend fun deleteByObservationId(observationId: String) + suspend fun deleteAll() + + suspend fun getById(id: String): AggregatedObservationDataEntity? + fun getByIdFlow(id: String): Flow + + suspend fun getByObservationId(observationId: String): List + fun getByObservationIdFlow(observationId: String): Flow> + + suspend fun getByObservationType(observationType: String): List + fun getByObservationTypeFlow(observationType: String): Flow> + + suspend fun getAll(): List + fun getAllFlow(): Flow> + + suspend fun getCount(): Int + suspend fun getCountByObservationId(observationId: String): Int +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt new file mode 100644 index 000000000..67fd0d776 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt @@ -0,0 +1,70 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import kotlinx.coroutines.flow.Flow + +class AggregatedObservationDataRepositoryImpl(private val appDatabase: AppDatabase) : + AggregatedObservationDataRepository { + + override suspend fun insert(entity: AggregatedObservationDataEntity) = + appDatabase.aggregatedObservationDataDao().insert(entity) + + override suspend fun insertAll(entities: List) = + appDatabase.aggregatedObservationDataDao().insertAll(entities) + + override suspend fun update(entity: AggregatedObservationDataEntity) = + appDatabase.aggregatedObservationDataDao().update(entity) + + override suspend fun delete(entity: AggregatedObservationDataEntity) = + appDatabase.aggregatedObservationDataDao().delete(entity) + + override suspend fun deleteById(id: String) = + appDatabase.aggregatedObservationDataDao().deleteById(id) + + override suspend fun deleteByObservationId(observationId: String) = + appDatabase.aggregatedObservationDataDao().deleteByObservationId(observationId) + + override suspend fun deleteAll() = + appDatabase.aggregatedObservationDataDao().deleteAll() + + override suspend fun getById(id: String): AggregatedObservationDataEntity? = + appDatabase.aggregatedObservationDataDao().getById(id) + + override fun getByIdFlow(id: String): Flow = + appDatabase.aggregatedObservationDataDao().getByIdFlow(id) + + override suspend fun getByObservationId(observationId: String): List = + appDatabase.aggregatedObservationDataDao().getByObservationId(observationId) + + override fun getByObservationIdFlow(observationId: String): Flow> = + appDatabase.aggregatedObservationDataDao().getByObservationIdFlow(observationId) + + override suspend fun getByObservationType(observationType: String): List = + appDatabase.aggregatedObservationDataDao().getByObservationType(observationType) + + override fun getByObservationTypeFlow(observationType: String): Flow> = + appDatabase.aggregatedObservationDataDao().getByObservationTypeFlow(observationType) + + override suspend fun getAll(): List = + appDatabase.aggregatedObservationDataDao().getAll() + + override fun getAllFlow(): Flow> = + appDatabase.aggregatedObservationDataDao().getAllFlow() + + override suspend fun getCount(): Int = + appDatabase.aggregatedObservationDataDao().getCount() + + override suspend fun getCountByObservationId(observationId: String): Int = + appDatabase.aggregatedObservationDataDao().getCountByObservationId(observationId) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManagerModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt similarity index 56% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManagerModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt index 151a23194..7ee5ee17e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManagerModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt @@ -8,11 +8,15 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations +package io.redlink.more.database.repository -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema +import io.redlink.more.database.entities.BluetoothDeviceEntity +import kotlinx.coroutines.flow.Flow -data class ObservationManagerModel( - val schedule: ScheduleSchema, - val config: Map, -) +interface BluetoothDeviceRepository { + fun storePairedDevice(bluetoothDevice: BluetoothDeviceEntity) + + fun unpairDevice(bluetoothDevice: BluetoothDeviceEntity) + + fun pairedDevices(): Flow> +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepositoryImpl.kt new file mode 100644 index 000000000..5fb7581ae --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepositoryImpl.kt @@ -0,0 +1,41 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.scopes.Scope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow + +class BluetoothDeviceRepositoryImpl( + private val database: AppDatabase +) : BluetoothDeviceRepository { + override fun storePairedDevice(bluetoothDevice: BluetoothDeviceEntity) { + if (bluetoothDevice.address != null) { + Scope.launch(Dispatchers.IO) { + database.bluetoothDeviceDao().insert(bluetoothDevice) + } + } + } + + override fun unpairDevice(bluetoothDevice: BluetoothDeviceEntity) { + bluetoothDevice.address?.let { + Scope.launch(Dispatchers.IO) { + database.bluetoothDeviceDao().deleteByAddress(it) + } + } + } + + override fun pairedDevices(): Flow> = + database.bluetoothDeviceDao().getAllFlow() +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/Authentication.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt similarity index 56% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/Authentication.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt index 4b6bc4ffe..fcffb51a6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/Authentication.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt @@ -8,16 +8,17 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.auth +package io.redlink.more.database.repository -interface Authentication { +import io.redlink.more.database.entities.DataPointEntity +import kotlinx.coroutines.flow.Flow - /** - * Apply authentication settings to header and query params. - * - * @param query Query parameters. - * @param headers Header parameters. - */ - fun apply(query: MutableMap>, headers: MutableMap) +interface DataPointCountRepository { + fun count(): Flow -} + fun incrementCount(scheduleIdSet: Set, addCount: Long = 1) + + fun get(scheduleId: String): Flow + + fun delete(scheduleId: String) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepositoryImpl.kt new file mode 100644 index 000000000..6300753ac --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepositoryImpl.kt @@ -0,0 +1,97 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.DataPointEntity +import io.redlink.more.scopes.Scope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class DataPointCountRepositoryImpl(private val appDatabase: AppDatabase) : + DataPointCountRepository { + private val mutex = Mutex() + private val countQueue = mutableMapOf() + private var storeJob: Job? = null + + override fun count(): Flow { + return appDatabase.dataPointDao().getCountFlow() + } + + override fun incrementCount(scheduleIdSet: Set, addCount: Long) { + if (scheduleIdSet.isNotEmpty()) { + Scope.launch(Dispatchers.IO) { + mutex.withLock { + scheduleIdSet.forEach { scheduleId -> + countQueue[scheduleId] = countQueue.getOrElse(scheduleId) { 0 } + addCount + } + } + if (storeJob == null || storeJob?.isActive == false) { + withContext(Dispatchers.Main) { + storeJob = Scope.repeatedLaunch(5000L, Dispatchers.IO) { + storeCounts() + }.second + storeJob?.invokeOnCompletion { + storeJob = null + } + } + } + } + } + } + + private suspend fun storeCounts() { + val countsToStore: Map + mutex.withLock { + countsToStore = countQueue.toMap() + countQueue.clear() + } + if (countsToStore.isNotEmpty()) { + Scope.launch(Dispatchers.IO) { + val allDataPoints = appDatabase.dataPointDao().getAll() + val dataPointScheduleIds = allDataPoints.map { it.scheduleId }.toSet() + val (existing, nonExisting) = countsToStore.keys.partition { it in dataPointScheduleIds } + + existing.forEach { id -> + val existingEntity = allDataPoints.firstOrNull { it.scheduleId == id } + existingEntity?.let { entity -> + val updatedEntity = + entity.copy(count = entity.count + (countsToStore[id] ?: 0)) + appDatabase.dataPointDao().update(updatedEntity) + } + } + + nonExisting.forEach { id -> + val newEntity = DataPointEntity( + scheduleId = id, + count = countsToStore[id] ?: 0 + ) + appDatabase.dataPointDao().insert(newEntity) + } + } + } + } + + override fun get(scheduleId: String): Flow { + return appDatabase.dataPointDao().getByScheduleId(scheduleId) + } + + override fun delete(scheduleId: String) { + Scope.launch { + appDatabase.dataPointDao().deleteByScheduleId(scheduleId) + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationConfig.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt similarity index 50% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationConfig.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt index 333bd454a..f0cf9f485 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationConfig.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt @@ -8,29 +8,19 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model +package io.redlink.more.database.repository +interface MainRepository { + val study: StudyRepository + val observation: ObservationRepository + val observationData: ObservationDataRepository + val dataPointCount: DataPointCountRepository + val schedule: ScheduleRepository -import kotlinx.serialization.* -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* - -/** - * - * - * @param service - */ - - -interface PushNotificationConfig { - - @SerialName(value = "service") @Required val service: PushNotificationServiceType -} + val notification: NotificationRepository + val bluetoothDevice: BluetoothDeviceRepository + val aggregatedObservationData: AggregatedObservationDataRepository + suspend fun deleteAll() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt new file mode 100644 index 000000000..257999397 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt @@ -0,0 +1,36 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.database.repository + +import io.redlink.more.database.AppDatabase + +class MainRepositoryImpl(appDatabase: AppDatabase) : MainRepository { + override val study: StudyRepository = StudyRepositoryImpl(appDatabase) + override val observation: ObservationRepository = ObservationRepositoryImpl(appDatabase) + override val observationData: ObservationDataRepository = + ObservationDataRepositoryImpl(appDatabase) + override val dataPointCount: DataPointCountRepository = + DataPointCountRepositoryImpl(appDatabase) + override val schedule: ScheduleRepository = ScheduleRepositoryImpl(appDatabase) + + override val notification: NotificationRepository = NotificationRepositoryImpl(appDatabase) + override val bluetoothDevice: BluetoothDeviceRepository = + BluetoothDeviceRepositoryImpl(appDatabase) + override val aggregatedObservationData: AggregatedObservationDataRepository = + AggregatedObservationDataRepositoryImpl(appDatabase) + + override suspend fun deleteAll() { + study.deleteStudy() + notification.deleteAll() + aggregatedObservationData.deleteAll() + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepository.kt new file mode 100644 index 000000000..b68784f25 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepository.kt @@ -0,0 +1,38 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.entities.NotificationEntity +import kotlinx.coroutines.flow.Flow + +interface NotificationRepository { + suspend fun storeNotification(notification: NotificationEntity) + + suspend fun storeNotifications(notifications: List) + + suspend fun getNotification(notificationId: String): NotificationEntity? + + fun getAllUserFacingNotifications(): Flow> + + suspend fun update(notificationId: String, read: Boolean? = null, priority: Long? = null) + + fun setNotificationReadStatus(key: String, read: Boolean = true) + + fun setNotificationCompletedStatus(key: String, completed: Boolean = true) + + suspend fun scheduledNotificationCount(): Int + + suspend fun scheduledNotifications(): List + + fun deleteNotification(notificationId: String) + + suspend fun deleteAll() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepositoryImpl.kt new file mode 100644 index 000000000..37fdf300c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepositoryImpl.kt @@ -0,0 +1,159 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.github.aakira.napier.Napier +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.scopes.Scope +import io.redlink.more.util.alignedNowFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock + +class NotificationRepositoryImpl(private val appDatabase: AppDatabase) : NotificationRepository { + private val readNotificationIds = mutableSetOf() + private val completedNotificationIds = mutableSetOf() + private val deletedNotificationIds = mutableSetOf() + private val mutex = Mutex() + + override suspend fun storeNotification(notification: NotificationEntity) { + mutex.withLock { + val notificationId = notification.notificationId + if (notificationId !in deletedNotificationIds) { + Napier.i { "Storing notification: $notification" } + appDatabase.notificationDao().insert(notification.copyAndModify()) + } else { + Napier.i { "Notification marked for deletion: $notification" } + deletedNotificationIds.remove(notificationId) + } + } + } + + override suspend fun storeNotifications(notifications: List) { + mutex.withLock { + val (notificationsToStore, notificationsToDelete) = notifications.partition { it.notificationId !in deletedNotificationIds } + Napier.i { "Storing ${notificationsToStore.size} notifications and deleting ${notificationsToDelete.size} notifications" } + + appDatabase.notificationDao().insertAll(notificationsToStore.map { it.copyAndModify() }) + notificationsToDelete.forEach { deletedNotificationIds.remove(it.notificationId) } + } + } + + override suspend fun getNotification(notificationId: String): NotificationEntity? { + return appDatabase.notificationDao().getById(notificationId) + } + + override fun getAllUserFacingNotifications(): Flow> { + val dbFlow = appDatabase.notificationDao().getByPastUserFacingFlow(true) + + return combine( + dbFlow, + alignedNowFlow(periodMs = 30_000L) + ) { list, now -> + list.filter { it.timestamp != null && it.timestamp <= now } + }.distinctUntilChanged() + } + + override suspend fun update(notificationId: String, read: Boolean?, priority: Long?) { + mutex.withLock { + val notification = appDatabase.notificationDao().getById(notificationId) + notification?.let { + val updatedNotification = it.copy( + read = read ?: it.read, + priority = priority ?: it.priority + ) + appDatabase.notificationDao().update(updatedNotification) + } + } + } + + override fun setNotificationReadStatus(key: String, read: Boolean) { + if (read) { + readNotificationIds.add(key) + } else { + readNotificationIds.remove(key) + } + + Scope.launch { + mutex.withLock { + val notification = appDatabase.notificationDao().getById(key) + if (notification != null) { + appDatabase.notificationDao().updateReadStatus(key, read) + } + readNotificationIds.remove(key) + } + } + } + + override fun setNotificationCompletedStatus(key: String, completed: Boolean) { + if (completed) { + readNotificationIds.add(key) + completedNotificationIds.add(key) + } else { + readNotificationIds.remove(key) + completedNotificationIds.remove(key) + } + + Scope.launch { + mutex.withLock { + val notification = appDatabase.notificationDao().getById(key) + if (notification != null) { + appDatabase.notificationDao().updateCompletedStatus(key, completed) + if (completed) { + appDatabase.notificationDao().updateReadStatus(key, true) + } + } + completedNotificationIds.remove(key) + } + } + } + + override suspend fun scheduledNotificationCount(): Int { + return appDatabase.notificationDao() + .getScheduledNotificationCount(Clock.System.now().epochSeconds) + } + + override suspend fun scheduledNotifications(): List { + return appDatabase.notificationDao() + .getScheduledNotifications(Clock.System.now().epochSeconds) + } + + override fun deleteNotification(notificationId: String) { + Scope.launch { + deletedNotificationIds.add(notificationId) + mutex.withLock { + Napier.i { "Delete Notification: $deletedNotificationIds" } + appDatabase.notificationDao().deleteById(notificationId) + deletedNotificationIds.remove(notificationId) + Napier.i { "Deleted Notification: $deletedNotificationIds" } + } + } + } + + override suspend fun deleteAll() { + appDatabase.notificationDao().deleteAll() + } + + private fun NotificationEntity.copyAndModify(): NotificationEntity { + val updatedNotification = copy( + read = if (notificationId in readNotificationIds) true else read, + completed = if (notificationId in completedNotificationIds) true else completed + ) + readNotificationIds.remove(notificationId) + completedNotificationIds.remove(notificationId) + return updatedNotification + } + +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepository.kt new file mode 100644 index 000000000..b7085a11f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepository.kt @@ -0,0 +1,26 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.services.network.openapi.model.DataBulk + +interface ObservationDataRepository { + fun addData(dataList: List) + + suspend fun store() + + suspend fun getCount(): Int + + suspend fun allAsBulk(): DataBulk? + + suspend fun deleteAllWithId(idSet: Set) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepositoryImpl.kt new file mode 100644 index 000000000..8b42863d0 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepositoryImpl.kt @@ -0,0 +1,77 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.github.aakira.napier.Napier +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.extensions.mapAsBulkData +import io.redlink.more.scopes.Scope +import io.redlink.more.services.network.openapi.model.DataBulk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ObservationDataRepositoryImpl(private val appDatabase: AppDatabase) : + ObservationDataRepository { + private var queue = mutableSetOf() + private val mutex = Mutex() + + init { + Scope.repeatedLaunch(10000L, Dispatchers.IO) { + if (queue.isNotEmpty()) { + store() + } + } + } + + override fun addData(dataList: List) { + Scope.launch { + mutex.withLock { + queue.addAll(dataList) + } + } + } + + override suspend fun store() { + if (queue.isNotEmpty()) { + val queueCopy = mutex.withLock { + val queueCopy = queue.toSet() + queue.clear() + queueCopy + } + appDatabase.observationDataDao().insertAll(queueCopy.toList()) + } + } + + override suspend fun getCount(): Int = appDatabase.observationDataDao().getCount() + + override suspend fun allAsBulk(): DataBulk? { + return mutex.withLock { + val observationDataEntities = appDatabase.observationDataDao().getLatest(5000) + if (observationDataEntities.isNotEmpty()) { + observationDataEntities.mapAsBulkData() + } else { + null + } + } + } + + override suspend fun deleteAllWithId(idSet: Set) { + Napier.i { "Deleting ${idSet.size} elements..." } + mutex.withLock { + idSet.forEach { dataId -> + appDatabase.observationDataDao().deleteById(dataId) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt new file mode 100644 index 000000000..c3e4efad4 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt @@ -0,0 +1,47 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import kotlinx.coroutines.flow.Flow + +interface ObservationRepository { + + suspend fun getCount(): Int + + fun observations(): Flow> + + fun observationWithUndoneSchedules(): Flow>> + + suspend fun updateLastCollection(type: String, timestamp: Long) + + suspend fun updateLastCollection(types: Set, timestamp: Long) + + fun collectionTimestamp(type: String): Flow + + fun collectAllTimestamps(): Flow> + + fun collectTimestampForObservationIds(observationIds: Set): Flow + + fun collectTimestampOfType(type: String, newState: (Long?) -> Unit): Closeable + + fun collectAllTimestamps(newState: (Map) -> Unit): Closeable + + fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable + + fun observationTypes(): Flow> + + fun observationById(observationId: String): Flow + + suspend fun getObservationByObservationId(observationId: String): ObservationEntity? +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt new file mode 100644 index 000000000..1aa0be4d5 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt @@ -0,0 +1,101 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.extensions.asClosure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.transform +import kotlinx.datetime.Clock + +class ObservationRepositoryImpl(private val appDatabase: AppDatabase) : ObservationRepository { + + override suspend fun getCount(): Int = appDatabase.observationDao().getCount() + + override fun observations(): Flow> = + appDatabase.observationDao().getAllFlow() + + override fun observationWithUndoneSchedules(): Flow>> { + return appDatabase.scheduleDao().getByDoneFlow(false) + .combine(observations()) { schedules: List, observations: List -> + observations.associateWith { observation -> + schedules.filter { schedule -> schedule.observationId == observation.observationId } + } + } + } + + override suspend fun updateLastCollection(type: String, timestamp: Long) { + val observations = appDatabase.observationDao().getByObservationType(type) + observations.forEach { observation -> + val updatedObservation = observation.copy(collectionTimestamp = timestamp) + appDatabase.observationDao().update(updatedObservation) + } + } + + override suspend fun updateLastCollection(types: Set, timestamp: Long) { + types.forEach { type -> + val observations = appDatabase.observationDao().getByObservationType(type) + observations.forEach { observation -> + val updatedObservation = observation.copy(collectionTimestamp = timestamp) + appDatabase.observationDao().update(updatedObservation) + } + } + } + + override fun collectionTimestamp(type: String): Flow = + appDatabase.observationDao().getByObservationTypeFlow(type) + .transform { observationList -> + emit(observationList.firstOrNull()?.collectionTimestamp) + } + + override fun collectAllTimestamps(): Flow> = + observations().transform { observationList -> + emit(observationList.associate { it.observationType to it.collectionTimestamp }) + } + + override fun collectTimestampForObservationIds(observationIds: Set): Flow = + observations().transform { observationList -> + val filteredObservations = + observationList.filter { it.observationType in observationIds } + val maxTimestamp = + filteredObservations.maxByOrNull { it.collectionTimestamp }?.collectionTimestamp + ?: Clock.System.now().toEpochMilliseconds() + emit(maxTimestamp) + } + + override fun collectTimestampOfType(type: String, newState: (Long?) -> Unit): Closeable { + return collectionTimestamp(type).asClosure(newState) + } + + override fun collectAllTimestamps(newState: (Map) -> Unit): Closeable { + return collectAllTimestamps().asClosure(newState) + } + + override fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable { + return observationWithUndoneSchedules().asClosure(newState) + } + + override fun observationTypes(): Flow> = + observations().transform { observationList -> + emit(observationList.map { it.observationType }.toSet()) + } + + override fun observationById(observationId: String): Flow = + appDatabase.observationDao().getByObservationIdFlow(observationId) + + override suspend fun getObservationByObservationId(observationId: String): ObservationEntity? { + return appDatabase.observationDao().getByObservationId(observationId) + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt new file mode 100644 index 000000000..da2e7a913 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt @@ -0,0 +1,58 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.DataRecorder +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.observationTypes.ObservationType +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant + +interface ScheduleRepository { + + fun count(): Flow + + fun allSchedulesWithStatus(done: Boolean = false): Flow> + + fun allSchedulesWithStates(states: Set): Flow> + + fun getSchedulesWithReminder( + states: Set, + minTimestamp: Instant, + maxTimestamp: Instant, + limit: Int = 100 + ): Flow> + + fun allScheduleWithRunningState(scheduleState: ScheduleState = ScheduleState.RUNNING): Flow> + + fun firstScheduleAvailableForObservationId(observationId: String): Flow + + fun allSchedulesToday(observationType: ObservationType): Flow> + + fun firstScheduleIdAvailableForObservationId(observationId: String): Flow + + fun observationTypesForScheduleIds(scheduleIds: Set): Flow> + + fun getFirstAndLastDate(observationId: String): Flow> + + suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) + + suspend fun setCompletionStateFor(id: String, wasDone: Boolean) + + fun scheduleWithId(id: String): Flow + + suspend fun updateTaskStates( + observationFactory: ObservationFactory, + dataRecorder: DataRecorder + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt new file mode 100644 index 000000000..ae237ba52 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt @@ -0,0 +1,199 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.github.aakira.napier.Napier +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.DataRecorder +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.observationTypes.ObservationType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +class ScheduleRepositoryImpl(private val appDatabase: AppDatabase) : ScheduleRepository { + + private val mutex = Mutex() + + override fun count(): Flow = appDatabase.scheduleDao().countAsFlow() + + override fun allSchedulesWithStatus(done: Boolean): Flow> { + return appDatabase.scheduleDao().getByDoneFlow(done) + } + + override fun allSchedulesWithStates(states: Set): Flow> { + if (states.isEmpty()) { + return flowOf(emptyList()) + } + return appDatabase.scheduleDao().getByStatesFlow(states.map { it.name }) + } + + override fun getSchedulesWithReminder( + states: Set, + minTimestamp: Instant, + maxTimestamp: Instant, + limit: Int + ): Flow> { + if (states.isEmpty()) { + return flowOf(emptyList()) + } + return appDatabase.scheduleDao().getSchedulesWithReminder( + states.map { it.name }, + minTimestamp.epochSeconds, + maxTimestamp.epochSeconds, + limit + ) + } + + override fun allScheduleWithRunningState(scheduleState: ScheduleState): Flow> = + appDatabase.scheduleDao().getByStateFlow(scheduleState.name) + + override fun firstScheduleAvailableForObservationId(observationId: String): Flow { + return appDatabase.scheduleDao().getByObservationIdFlow(observationId) + .distinctUntilChanged() + .transform { scheduleList -> + val observation = appDatabase.observationDao().getByObservationId(observationId) + if (observation?.scheduleLess == true) { + emit(scheduleList.sortedBy { it.end }.lastOrNull()) + } else { + val now = Clock.System.now().epochSeconds + val filtered = scheduleList.filter { + !it.getState().completed() + && it.start != null + && it.end != null + && it.end > now + }.sortedBy { it.start }.firstOrNull() + emit(filtered) + } + } + } + + override fun allSchedulesToday(observationType: ObservationType): Flow> { + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + return appDatabase.scheduleDao().getByObservationTypeFlow(observationType.observationType) + .transform { list -> + emit(list.filter { + !it.getState().completed() + && (it.startInstant() + ?.toLocalDateTime(TimeZone.currentSystemDefault())?.date == today + || it.endInstant() + ?.toLocalDateTime(TimeZone.currentSystemDefault())?.date == today) + }) + } + } + + override fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = + firstScheduleAvailableForObservationId(observationId).transform { it?.scheduleId } + + override fun observationTypesForScheduleIds(scheduleIds: Set): Flow> { + return appDatabase.scheduleDao().getObservationTypesForScheduleIds(scheduleIds) + .map { it.toSet() } + } + + override fun getFirstAndLastDate(observationId: String): Flow> { + return appDatabase.scheduleDao().getByObservationIdFlow(observationId).transform { + val start = it.sortedBy { it.start }.firstOrNull() + val end = it.sortedBy { it.end }.lastOrNull() + emit(Pair(start, end)) + } + } + + override suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) { + appDatabase.scheduleDao().updateState(id, scheduleState.name) + } + + override suspend fun setCompletionStateFor(id: String, wasDone: Boolean) { + val newState = if (wasDone) ScheduleState.DONE else ScheduleState.ENDED + appDatabase.scheduleDao().updateState(id, newState.name) + appDatabase.scheduleDao().updateDoneStatus(id, wasDone) + } + + override fun scheduleWithId(id: String): Flow { + return appDatabase.scheduleDao().getById(id) + } + + override suspend fun updateTaskStates( + observationFactory: ObservationFactory, + dataRecorder: DataRecorder + ) { + if (mutex.isLocked) { + return + } + mutex.withLock { + val autoStartingObservations = observationFactory.autoStartableObservations() + Napier.i { "Updating Schedule states..." } + + try { + val schedules = appDatabase + .scheduleDao() + .getByStatesFlow(ScheduleState.presentScheduleStates.map { it.name }) + .firstOrNull() + ?: emptyList() + + val stateUpdates = mutableListOf>() + val activeIds = mutableSetOf() + val pausingIds = mutableSetOf() + + schedules.forEach { scheduleEntity -> + val newState = scheduleEntity.updateState() + + if (scheduleEntity.getState() != newState) { + stateUpdates.add(scheduleEntity.scheduleId to newState) + Napier.i { "State update for Entity: $scheduleEntity; ${scheduleEntity.getState()} -> $newState" } + } + + if (newState == ScheduleState.RUNNING + || scheduleEntity.hidden + && newState.active() + && scheduleEntity.observationType in autoStartingObservations + ) { + observationFactory.observation(scheduleEntity.observationType) + ?.let { observation -> + if (observation.observerAccessible()) { + activeIds.add(scheduleEntity.scheduleId) + } else { + pausingIds.add(scheduleEntity.scheduleId) + } + } + } + } + + stateUpdates.forEach { (scheduleId, newState) -> + Napier.d { "Updating the schedule with $scheduleId to state: $newState" } + appDatabase.scheduleDao().updateState(scheduleId, newState.name) + } + + if (activeIds.isNotEmpty()) { + dataRecorder.startMultiple(activeIds) + } + if (pausingIds.isNotEmpty()) { + pausingIds.forEach { scheduleId -> + dataRecorder.pause(scheduleId) + } + } + } catch (e: Exception) { + Napier.e("Error updating schedule states", e) + } + } + + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepository.kt new file mode 100644 index 000000000..09852b4c5 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepository.kt @@ -0,0 +1,37 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.models.StudyState +import io.redlink.more.services.network.openapi.model.Study +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface StudyRepository { + @NativeCoroutines + val study: StateFlow + + @NativeCoroutines + val studyState: StateFlow + + @NativeCoroutines + val finishText: StateFlow + + suspend fun upsert(study: Study) + + fun getStudy(): Flow + + suspend fun updateStudyState(state: StudyState) + + suspend fun deleteStudy() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepositoryImpl.kt new file mode 100644 index 000000000..fb7cf45ba --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepositoryImpl.kt @@ -0,0 +1,105 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.database.repository + +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.extensions.mapState +import io.redlink.more.models.StudyState +import io.redlink.more.scopes.Scope +import io.redlink.more.services.network.openapi.model.Study +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withContext + +class StudyRepositoryImpl(private val appDatabase: AppDatabase) : StudyRepository { + private val _study = MutableStateFlow(null) + + override val study: StateFlow = _study + + override val studyState: StateFlow = + study.mapState(CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) { + it?.let { StudyState.getState(it.state) } ?: StudyState.NONE + } + private val _finishText = MutableStateFlow(null) + + override val finishText: StateFlow = _finishText + + init { + Scope.launch { + getStudy().collectLatest { + withContext(Dispatchers.Main) { + _study.value = it + it?.let { + _finishText.value = it.finishText + } + } + } + } + } + + override suspend fun upsert(study: Study) { + deleteStudy() + Scope.launch(Dispatchers.IO) { + val studyEntity = StudyEntity.fromStudy(study) + appDatabase.studyDao().insert(studyEntity) + _finishText.value = study.finishText + } + + Scope.launch(Dispatchers.IO) { + val observationEntities = + study.observations.map { ObservationEntity.toEntity(it) } + appDatabase.observationDao().insertAll(observationEntities) + } + + Scope.launch(Dispatchers.IO) { + val scheduleEntities = study.observations.flatMap { observation -> + observation.schedule.mapNotNull { + ScheduleEntity.fromObservationSchedule( + it, + observation.observationId, + observation.observationType, + observation.observationTitle, + observation.hidden ?: observation.noSchedule ?: false, + observation.reminder ?: false + ) + } + } + appDatabase.scheduleDao().insertAll(scheduleEntities) + } + } + + override fun getStudy(): Flow { + return appDatabase.studyDao().getFlow() + } + + override suspend fun updateStudyState(state: StudyState) { + study.value?.let { + appDatabase.studyDao().updateStudyState(it.studyId, state.descr) + } + } + + override suspend fun deleteStudy() { + appDatabase.studyDao().deleteAll() + appDatabase.observationDao().deleteAll() + appDatabase.observationDataDao().deleteAll() + appDatabase.scheduleDao().deleteAll() + appDatabase.dataPointDao().deleteAll() + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt new file mode 100644 index 000000000..4e67ebd0f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt @@ -0,0 +1,43 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.dialog + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.extensions.then +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object AlertController { + private val _alertDialogModel = MutableStateFlow(null) + + @NativeCoroutines + val alertDialogModel: StateFlow = _alertDialogModel + private var alertDialogQueue = mutableListOf() + + fun openAlertDialog(model: AlertDialogModel) { + model.onConfirm = composeWithClose(model.onConfirm) + model.onDecline = composeWithClose(model.onDecline) + + if (this.alertDialogQueue.isEmpty() && this.alertDialogModel.value == null) { + this._alertDialogModel.value = model + } else if (!this.alertDialogQueue.contains(model) && this.alertDialogModel.value != model) { + this.alertDialogQueue.add(model) + } + } + + private fun composeWithClose(action: (() -> Unit)?): () -> Unit = + (action ?: {}) then { closeAlertDialog() } + + fun closeAlertDialog() { + this._alertDialogModel.value = alertDialogQueue.removeFirstOrNull() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt new file mode 100644 index 000000000..957e2a37c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt @@ -0,0 +1,66 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.dialog + +import dev.icerock.moko.resources.desc.Raw +import dev.icerock.moko.resources.desc.StringDesc + +data class AlertDialogModel( + var title: StringDesc, + var message: StringDesc, + var confirmLabel: StringDesc, + var cancelLabel: StringDesc? = null, + var onConfirm: (() -> Unit)? = null, + var onDecline: (() -> Unit)? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as AlertDialogModel + + if (title != other.title) return false + if (message != other.message) return false + if (confirmLabel != other.confirmLabel) return false + if (cancelLabel != other.cancelLabel) return false + + return true + } + + override fun hashCode(): Int { + var result = title.hashCode() + result = 31 * result + message.hashCode() + result = 31 * result + confirmLabel.hashCode() + result = 31 * result + (cancelLabel?.hashCode() ?: 0) + return result + } + + companion object { + fun fromStrings( + title: String, + message: String, + confirmLabel: String, + cancelLabel: String? = null, + onConfirm: (() -> Unit)? = null, + onDecline: (() -> Unit)? = null + ): AlertDialogModel { + return AlertDialogModel( + StringDesc.Raw(title), + StringDesc.Raw(message), + StringDesc.Raw(confirmLabel), + cancelLabel?.let { StringDesc.Raw(it) }, + onConfirm, + onDecline + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AnyExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/AnyExtension.kt similarity index 89% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AnyExtension.kt rename to shared/src/commonMain/kotlin/io/redlink/more/extensions/AnyExtension.kt index 5814bb8b7..38552bfd6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AnyExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/AnyExtension.kt @@ -8,7 +8,6 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions - +package io.redlink.more.extensions expect fun Any.asString(): String? \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CollectionExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/CollectionExtension.kt similarity index 68% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CollectionExtension.kt rename to shared/src/commonMain/kotlin/io/redlink/more/extensions/CollectionExtension.kt index df2fc04c7..8140fb807 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CollectionExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/CollectionExtension.kt @@ -8,15 +8,15 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions +package io.redlink.more.extensions import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk -import io.redlink.more.more_app_mutliplatform.util.createUUID +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.util.createUUID -fun Collection.mapAsBulkData(): DataBulk? { +fun Collection.mapAsBulkData(): DataBulk? { val dataPoints = this.map { it.asObservationData() } val bulkId = createUUID() if (dataPoints.isEmpty() || dataPoints.firstOrNull() == null) { @@ -31,10 +31,10 @@ fun Collection.mapAsBulkData(): DataBulk? { fun Collection.isSubsetOf(other: Collection): Boolean = this.all { it in other } -fun Set.areAllNamesIn(items: Set): Boolean = +fun Set.areAllNamesIn(items: Set): Boolean = this.all { name -> items.any { item -> item.deviceName?.contains(name) ?: false } } -fun Set.anyNameIn(items: Set): Boolean = +fun Set.anyNameIn(items: Set): Boolean = this.any { name -> items.any { item -> item.deviceName?.lowercase()?.contains(name.lowercase()) == true diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/CoroutineExtension.kt similarity index 62% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt rename to shared/src/commonMain/kotlin/io/redlink/more/extensions/CoroutineExtension.kt index 4e7269277..59f959ed7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/CoroutineExtension.kt @@ -8,16 +8,26 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions +package io.redlink.more.extensions import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext -fun CoroutineScope.repeatEveryFewSeconds(intervalMillis: Long, action: suspend CoroutineScope.() -> Unit): Job { - return launch { +fun CoroutineScope.repeatEveryFewSeconds( + intervalMillis: Long, + initialDelay: Long = 0, + coroutineContext: CoroutineContext = Dispatchers.Default, + action: suspend CoroutineScope.() -> Unit +): Job { + return launch(coroutineContext) { + if (initialDelay > 0) { + delay(initialDelay) + } while (isActive) { action() delay(intervalMillis) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/DateTimeConverter.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/DateTimeConverter.kt similarity index 60% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/DateTimeConverter.kt rename to shared/src/commonMain/kotlin/io/redlink/more/extensions/DateTimeConverter.kt index 91a336a1d..421ebe96c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/DateTimeConverter.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/DateTimeConverter.kt @@ -8,9 +8,8 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions +package io.redlink.more.extensions -import io.realm.kotlin.types.RealmInstant import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime @@ -19,26 +18,6 @@ import kotlinx.datetime.atTime import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime -fun RealmInstant.toInstant(): Instant { - val sec: Long = this.epochSeconds - val nano: Int = this.nanosecondsOfSecond - return if (sec >= 0) { - Instant.fromEpochSeconds(sec, nano.toLong()) - } else { - Instant.fromEpochSeconds(sec - 1, 1_000_000 + nano.toLong()) - } -} - -fun Instant.toRealmInstant(): RealmInstant { - val sec: Long = this.epochSeconds - val nano: Int = this.nanosecondsOfSecond - return if (sec >= 0) { - RealmInstant.from(sec, nano) - } else { - RealmInstant.from(sec + 1, -1_000_000 + nano) - } -} - fun Instant.fromUTCtoCurrent(): Instant { val currentZone = TimeZone.currentSystemDefault() return this.toLocalDateTime(currentZone).toInstant(currentZone) @@ -49,6 +28,6 @@ fun Instant.localDateTime(): LocalDateTime = this.toLocalDateTime(TimeZone.curre fun LocalDate.time(): Long = this.atTime(0, 0).toInstant(TimeZone.currentSystemDefault()).epochSeconds -fun Long.toLocalDateTime(): LocalDateTime = Instant.fromEpochMilliseconds(this).localDateTime() +fun Long.toLocalDateTime(): LocalDateTime = Instant.fromEpochSeconds(this).localDateTime() fun Long.toLocalDate() = toLocalDateTime().date diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/FlowExtension.kt similarity index 52% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt rename to shared/src/commonMain/kotlin/io/redlink/more/extensions/FlowExtension.kt index 2b8651b23..0746d21f6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/FlowExtension.kt @@ -8,17 +8,21 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions +package io.redlink.more.extensions import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.scopes.Scope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn fun Flow.asClosure(provideNewState: ((T) -> Unit)): Closeable { val job = Scope.create() @@ -46,18 +50,6 @@ fun MutableStateFlow.asClosure(provideNewState: ((T) -> Unit)): Cl } } -fun MutableStateFlow.asNullableClosure(provideNewState: ((T?) -> Unit)): Closeable { - val job = Scope.create() - this.onEach { - provideNewState(it) - }.launchIn(CoroutineScope(Dispatchers.Main + job.second)) - return object : Closeable { - override fun close() { - job.second.cancel() - } - } -} - fun StateFlow.asNullableClosure(provideNewState: ((T?) -> Unit)): Closeable { val job = Scope.create() this.onEach { @@ -70,56 +62,6 @@ fun StateFlow.asNullableClosure(provideNewState: ((T?) -> Unit)): } } -fun MutableStateFlow>.append(value: T?) { - val mutableCollection = this.value.toMutableSet() - if (mutableCollection.add(value ?: return)) { - this.set(mutableCollection) - } -} - -fun MutableStateFlow>.appendIfNotContains(value: T, includes: (T) -> Boolean) { - val mutableCollection = this.value.toMutableSet() - if (mutableCollection.firstOrNull(includes) == null) { - if (mutableCollection.add(value)) { - this.set(mutableCollection) - } - } -} - -fun MutableStateFlow>.appendAll(value: Collection) { - val mutableCollection = this.value.toMutableSet() - if (mutableCollection.addAll(value)) { - this.set(mutableCollection) - } -} - -fun MutableStateFlow>.remove(value: T?) { - val mutableCollection = this.value.toMutableSet() - if (mutableCollection.remove(value ?: return)) { - this.set(mutableCollection) - } -} - -fun MutableStateFlow>.removeWhere(includes: (T) -> Boolean) { - val mutableCollection = this.value.toMutableSet() - if (mutableCollection.removeAll(includes)) { - this.set(mutableCollection) - } -} - -fun MutableStateFlow>.removeAll(values: Collection) { - val mutableCollection = this.value.toMutableSet() - if (mutableCollection.removeAll(values.toSet())) { - this.set(mutableCollection) - } -} - -fun MutableStateFlow>.clear() { - if (this.value.isNotEmpty()) { - this.set(emptySet()) - } -} - fun MutableStateFlow.set(value: T?) { value?.let { Scope.launch { @@ -134,4 +76,27 @@ fun MutableStateFlow.setNullable(value: T?) { } } +@OptIn(ExperimentalCoroutinesApi::class) +fun StateFlow.mapState( + scope: CoroutineScope, + transform: (data: T) -> K +): StateFlow { + return mapLatest { + transform(it) + } + .stateIn(scope, SharingStarted.Eagerly, transform(value)) +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun StateFlow.mapState( + scope: CoroutineScope, + initialValue: K, + transform: suspend (data: T) -> K +): StateFlow { + return mapLatest { + transform(it) + } + .stateIn(scope, SharingStarted.Eagerly, initialValue) +} + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt new file mode 100644 index 000000000..b82f894d5 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt @@ -0,0 +1,3 @@ +package io.redlink.more.extensions + +infix fun (() -> Unit).then(after: () -> Unit): () -> Unit = { this(); after() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt new file mode 100644 index 000000000..616d03526 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt @@ -0,0 +1,19 @@ +package io.redlink.more.extensions + +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.models.NotificationTextKey + +fun ScheduleEntity.toNotificationEntity( + userFacing: Boolean, + deepLink: String? = null +): NotificationEntity { + return NotificationEntity( + notificationId = "reminder_$scheduleId", + title = observationTitle, + notificationBody = NotificationTextKey.OBSERVATION_ACTIVATED.raw, + timestamp = start, + deepLink = deepLink, + userFacing = userFacing + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/StringExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringExtension.kt similarity index 79% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/StringExtension.kt rename to shared/src/commonMain/kotlin/io/redlink/more/extensions/StringExtension.kt index 5dcf015fb..e94793dee 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/StringExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringExtension.kt @@ -1,4 +1,4 @@ -package io.redlink.more.more_app_mutliplatform.extensions +package io.redlink.more.extensions fun String.extractRouteFromDeepLink(): String? { val regexPattern = "app://[^/]+/([a-zA-Z0-9-]+)(?:/\\d+)?/?.*".toRegex() @@ -22,3 +22,6 @@ fun String.mapQueryParams(): Map> { return queryParams.mapValues { it.value.toSet() } } + +fun String.overlaps(other: String?, ignoreCase: Boolean = false): Boolean = + other?.let { this.contains(other, ignoreCase) || other.contains(this, ignoreCase) } ?: false diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt new file mode 100644 index 000000000..1797311fa --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt @@ -0,0 +1,7 @@ +package io.redlink.more.extensions + +import io.redlink.more.models.StudyState +import io.redlink.more.services.network.openapi.model.Study + +fun Study.StudyState.toStudyState() = + StudyState.getState(this.value) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt b/shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt new file mode 100644 index 000000000..82093ceec --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt @@ -0,0 +1,52 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.logging + +import io.redlink.more.observations.appUsage.model.LogEvent + +interface EventObserver { + fun onEvent(event: LogEvent, message: String?) +} + +private data class LogEventQueueItem(val event: LogEvent, val message: String?) + +object EventCollection { + private val observers = mutableListOf() + + private val eventQueue = mutableListOf() + + fun addObserver(observer: EventObserver) { + if (!observers.contains(observer)) { + observers.add(observer) + eventQueue.forEach { + logEvent(it.event, it.message) + } + eventQueue.clear() + } + } + + fun removeObserver(observer: EventObserver) { + observers.remove(observer) + } + + fun logEvent(event: LogEvent, message: String? = null) { + if (observers.isEmpty()) { + eventQueue.add(LogEventQueueItem(event, message)) + } else { + observers.forEach { it.onEvent(event, message) } + } + } + + fun clearQueue() { + eventQueue.clear() + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt b/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt new file mode 100644 index 000000000..71c156db6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt @@ -0,0 +1,44 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.logging + +import io.github.aakira.napier.Napier +import io.redlink.more.observations.appUsage.model.LogEvent + +object KMMLogger { + const val EVENT_TAG = "EVENT" + fun d(tag: String? = null, message: String) { + Napier.d(message, tag = tag) + } + + fun i(tag: String? = null, message: String) { + Napier.i(message, tag = tag) + } + + fun w(tag: String? = null, message: String) { + Napier.w(message, tag = tag) + } + + fun e(tag: String? = null, message: String) { + Napier.e(message, tag = tag) + } + + fun event(event: LogEvent, message: String? = null) { + val logMessage = "[EVENT: ${event.key}] ${message ?: ""}".trim() + Napier.i(logMessage, tag = EVENT_TAG) + EventCollection.logEvent(event, message) + } +} + +fun Napier.event(event: LogEvent, message: String? = null) { + KMMLogger.event(event, message) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/NapierProxy.kt b/shared/src/commonMain/kotlin/io/redlink/more/logging/NapierProxy.kt similarity index 86% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/NapierProxy.kt rename to shared/src/commonMain/kotlin/io/redlink/more/logging/NapierProxy.kt index b8abb0ce1..010389118 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/NapierProxy.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/logging/NapierProxy.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform +package io.redlink.more.logging import io.github.aakira.napier.Antilog import io.github.aakira.napier.DebugAntilog @@ -16,4 +16,8 @@ import io.github.aakira.napier.Napier fun napierDebugBuild(antilog: Antilog? = null) { Napier.base(antilog ?: DebugAntilog()) +} + +fun addNapierLogChannel(antilog: Antilog) { + Napier.base(antilog) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/CredentialModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/CredentialModel.kt similarity index 71% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/CredentialModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/CredentialModel.kt index 6ed478439..18e61e64c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/CredentialModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/CredentialModel.kt @@ -8,6 +8,14 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models -data class CredentialModel(val apiId: String, val apiKey: String) +import io.ktor.util.encodeBase64 + +data class CredentialModel(val apiId: String, val apiKey: String) { + fun basicAuthHeader(): String { + val raw = "$apiId:$apiKey" + val encoded = raw.encodeBase64() + return "Basic $encoded" + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/DateFilterModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/DateFilterModel.kt similarity index 82% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/DateFilterModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/DateFilterModel.kt index fac56d1ce..fb1ac60b2 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/DateFilterModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/DateFilterModel.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models import kotlinx.datetime.DateTimeUnit @@ -20,7 +20,7 @@ data class DateFilter( var selected: Boolean = false ) { fun toEnum(): DateFilterModel? { - return DateFilterModel.values() + return DateFilterModel.entries .firstOrNull { describing == it.toString() } } } @@ -35,9 +35,9 @@ enum class DateFilterModel( ONE_WEEK(1, DateTimeUnit.WEEK, 2), ONE_MONTH(1, DateTimeUnit.MONTH, 3); - fun asDataClass() = DateFilter(toString() ,number, dateBased, sortIndex) + fun asDataClass() = DateFilter(toString(), number, dateBased, sortIndex) companion object { - fun asDataClassList() = DateFilterModel.values().map { it.asDataClass() } + fun asDataClassList() = values().map { it.asDataClass() } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/FilterModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/FilterModel.kt similarity index 91% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/FilterModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/FilterModel.kt index ac56a62c7..d13583cfe 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/FilterModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/FilterModel.kt @@ -8,10 +8,10 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models data class FilterModel( - var dateFilter: Map = DateFilterModel.values() + var dateFilter: Map = DateFilterModel.entries .associateWith { it == DateFilterModel.ENTIRE_TIME }, var typeFilter: Map = emptyMap() ) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt new file mode 100644 index 000000000..7c570ebc5 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt @@ -0,0 +1,13 @@ +package io.redlink.more.models + +import io.redlink.more.util.validateAndNormalizeUrl + +class LoginModel( + token: String, + endpoint: String +) { + val token: String = token.trim().uppercase() + val endpoint: String? = endpoint.validateAndNormalizeUrl() + + fun valid(): Boolean = token.isNotBlank() && endpoint != null +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt new file mode 100644 index 000000000..06101d625 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt @@ -0,0 +1,18 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.models + +enum class NotificationFilterTypeModel(val type: String, val sortIndex: Int) { + ALL("All", 0), + UNREAD("Unread", 1), + IMPORTANT("Important", 2); + +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationModel.kt new file mode 100644 index 000000000..041175fd2 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationModel.kt @@ -0,0 +1,56 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.models + +import io.redlink.more.database.entities.NotificationEntity + +data class NotificationModel( + var notificationId: String, + var channelId: String?, + var title: String, + var notificationBody: String, + var timestamp: Long, + var priority: Long, + var read: Boolean, + var completed: Boolean, + var userFacing: Boolean, + var deepLink: String?, + var notificationData: Map +) { + + companion object { + fun createModelFrom(entity: NotificationEntity): NotificationModel? { + val channelId = entity.channelId + val title = entity.title ?: return null + val notificationBody = entity.notificationBody ?: return null + val timestamp = entity.timestamp ?: return null + return NotificationModel( + notificationId = entity.notificationId, + channelId = channelId, + title = title, + notificationBody = notificationBody, + timestamp = timestamp, + priority = entity.priority, + read = entity.read, + completed = entity.completed, + userFacing = entity.userFacing, + deepLink = entity.deepLink(), + notificationData = entity.getNotificationDataMap() + ) + } + + fun createModelsFrom(notifications: List): List { + return notifications.mapNotNull { + it?.let { createModelFrom(it) } + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt new file mode 100644 index 000000000..ec595770f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt @@ -0,0 +1,6 @@ +package io.redlink.more.models + +enum class NotificationStatusType { + READ, + COMPLETED; +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt new file mode 100644 index 000000000..d1ada6db9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt @@ -0,0 +1,14 @@ +package io.redlink.more.models + +import dev.icerock.moko.resources.desc.StringDesc + +expect object NotificationTextLocalization { + fun localize(raw: String, fallback: String? = null): String + fun localizeToStringDesc(raw: String): StringDesc? +} + +fun String.localize(fallback: String? = null): String = + NotificationTextLocalization.localize(this, fallback ?: this) + +fun String.localizeToStringDesc(): StringDesc? = + NotificationTextLocalization.localizeToStringDesc(this) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt new file mode 100644 index 000000000..d176ed88d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt @@ -0,0 +1,21 @@ +package io.redlink.more.models + +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.StringDesc +import io.redlink.more.SharedRes + +enum class NotificationTextKey(val raw: String) { + OBSERVATION_ACTIVATED("observation_is_active"); + + fun asStringDesc(): StringDesc = when (this) { + OBSERVATION_ACTIVATED -> StringDesc.Resource(SharedRes.strings.observation_is_active) + } + + fun localize(): StringDesc = asStringDesc() + + companion object { + fun fromRaw(raw: String?): NotificationTextKey? = + raw?.let { value -> entries.firstOrNull { it.raw == value } } + } +} + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ObservationDetailsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/ObservationDetailsModel.kt similarity index 65% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ObservationDetailsModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/ObservationDetailsModel.kt index d0a0abbf0..2a9f2f1ca 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ObservationDetailsModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/ObservationDetailsModel.kt @@ -8,11 +8,10 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.extensions.toInstant +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity data class ObservationDetailsModel( val observationTitle: String, @@ -23,13 +22,17 @@ data class ObservationDetailsModel( val participantInformation: String ) { companion object { - fun createModelFrom(observation: ObservationSchema, start: ScheduleSchema?, stop: ScheduleSchema?): ObservationDetailsModel { + fun createModelFrom( + observation: ObservationEntity, + start: ScheduleEntity?, + stop: ScheduleEntity? + ): ObservationDetailsModel { return ObservationDetailsModel( observationTitle = observation.observationTitle, observationType = observation.observationType, observationId = observation.observationId, - start = start?.start?.toInstant()?.epochSeconds?: 0, - end = stop?.end?.toInstant()?.epochSeconds?: 0, + start = start?.start ?: 0, + end = stop?.end ?: 0, participantInformation = observation.participantInfo, ) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionConsentModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/PermissionConsentModel.kt similarity index 91% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionConsentModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/PermissionConsentModel.kt index d86f666df..52061e63d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionConsentModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/PermissionConsentModel.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models data class PermissionConsentModel( val title: String, diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/PermissionModel.kt similarity index 51% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/PermissionModel.kt index aae6e0228..419db940d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/PermissionModel.kt @@ -8,12 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models - -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study +package io.redlink.more.models +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.services.network.openapi.model.Study data class PermissionModel( val studyTitle: String, @@ -29,13 +28,37 @@ data class PermissionModel( study.observations.sortedBy { it.observationTitle } .map { PermissionConsentModel(it.observationTitle, it.participantInfo) } ) - return PermissionModel(study.studyTitle, study.participantInfo, study.consentInfo, observationConsent) + return PermissionModel( + study.studyTitle, + study.participantInfo, + study.consentInfo, + observationConsent + ) } - fun createFromSchema(studySchema: StudySchema, observations: List): PermissionModel { + + fun createFromSchema( + studySchema: StudyEntity, + observations: List + ): PermissionModel { val observationConsent = mutableListOf() - observationConsent.add(PermissionConsentModel(studySchema.studyTitle, studySchema.consentInfo)) - observationConsent.addAll(observations.map { PermissionConsentModel(it.observationTitle, it.participantInfo) }) - return PermissionModel(studySchema.studyTitle, studySchema.participantInfo, studySchema.consentInfo, observationConsent) + observationConsent.add( + PermissionConsentModel( + studySchema.studyTitle, + studySchema.consentInfo + ) + ) + observationConsent.addAll(observations.map { + PermissionConsentModel( + it.observationTitle, + it.participantInfo + ) + }) + return PermissionModel( + studySchema.studyTitle, + studySchema.participantInfo, + studySchema.consentInfo, + observationConsent + ) } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SimpleQuestionModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionModel.kt similarity index 70% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SimpleQuestionModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/QuestionModel.kt index a7eb21870..47feffee3 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SimpleQuestionModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionModel.kt @@ -8,39 +8,47 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema +import io.redlink.more.database.entities.ObservationEntity import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray -class SimpleQuestionModel( +class QuestionModel( + var type: QuestionType, var question: String = "", - val answers: MutableSet = mutableSetOf(""), + val answers: List = listOf(), var participantInfo: String = "", var observationId: String = "", var observationTitle: String = "", var scheduleId: String = "" ) { + fun isValidModel() = type != QuestionType.NON + companion object { - fun createModelFrom(observationSchema: ObservationSchema, scheduleId: String): SimpleQuestionModel { + fun createModelFrom( + observationSchema: ObservationEntity, + scheduleId: String + ): QuestionModel { val config: Map = observationSchema.configuration?.let { config -> Json.decodeFromString(config).toMap() } ?: emptyMap() - return SimpleQuestionModel( + val questionType = + QuestionType.questionTypeForObservationType(observationSchema.observationType) + return QuestionModel( + questionType, config["question"]?.toString()?.trim('\"') ?: "", config["answers"]?.jsonArray?.map { it.toString().trim('\"') - }?.toMutableSet() ?: mutableSetOf(), + } ?: emptyList(), observationSchema.participantInfo, observationSchema.observationId, observationSchema.observationTitle, scheduleId ) - } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt new file mode 100644 index 000000000..fb82da8d6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt @@ -0,0 +1,12 @@ +package io.redlink.more.models + +enum class QuestionType(val observationType: String, val observationDataResponseKey: String) { + NON("", ""), + SINGLE_CHOICE("question-observation", "answer"), + MULTIPLE_CHOICE("multiple-choice-question-observation", "answers"); + + companion object { + fun questionTypeForObservationType(observationType: String) = + entries.firstOrNull { it.observationType == observationType } ?: NON + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleListType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleListType.kt similarity index 91% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleListType.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleListType.kt index 0a737690e..1157225e6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleListType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleListType.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models enum class ScheduleListType { ALL, diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleModel.kt similarity index 82% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleModel.kt index 8a891e5a2..73c8e3875 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleModel.kt @@ -8,9 +8,9 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema +import io.redlink.more.database.entities.ScheduleEntity data class ScheduleModel( val scheduleId: String, @@ -23,7 +23,6 @@ data class ScheduleModel( val hidden: Boolean, var scheduleState: ScheduleState = ScheduleState.DEACTIVATED ) { - fun isSameAs(other: ScheduleModel) = this.scheduleId == other.scheduleId fun hasSameContentAs(other: ScheduleModel): Boolean { @@ -33,19 +32,18 @@ data class ScheduleModel( && this.scheduleState == other.scheduleState } - companion object { - fun createModel(schedule: ScheduleSchema): ScheduleModel? { + fun createModel(schedule: ScheduleEntity): ScheduleModel? { val start = schedule.start ?: return null val end = schedule.end ?: return null return ScheduleModel( - scheduleId = schedule.scheduleId.toHexString(), + scheduleId = schedule.scheduleId, observationId = schedule.observationId, observationType = schedule.observationType, observationTitle = schedule.observationTitle, done = schedule.done, - start = start.epochSeconds, - end = end.epochSeconds, + start = start, + end = end, hidden = schedule.hidden, scheduleState = schedule.getState() ) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleState.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt similarity index 89% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleState.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt index 0b5ca3fe2..7255cedb9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleState.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models enum class ScheduleState { DEACTIVATED, @@ -29,5 +29,6 @@ enum class ScheduleState { companion object { fun getState(name: String) = entries.firstOrNull { it.name == name } ?: DEACTIVATED + val presentScheduleStates = listOf(DEACTIVATED, ACTIVE, RUNNING, PAUSED) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyDetailsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/StudyDetailsModel.kt similarity index 60% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyDetailsModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/StudyDetailsModel.kt index fe5befc90..08f70a5dd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyDetailsModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/StudyDetailsModel.kt @@ -8,22 +8,26 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models -import io.realm.kotlin.ext.copyFromRealm -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.StudyEntity data class StudyDetailsModel( - val study: StudySchema, - val observations: List, + val study: StudyEntity, + val observations: List, var totalTasks: Long, var finishedTasks: Long ) { companion object { - fun createModelFrom(study: StudySchema, observations: List, totalTasks: Long, finishedTasks: Long): StudyDetailsModel { + fun createModelFrom( + study: StudyEntity, + observations: List, + totalTasks: Long, + finishedTasks: Long + ): StudyDetailsModel { return StudyDetailsModel( - study = study.copyFromRealm(), + study = study, observations = observations, totalTasks = totalTasks, finishedTasks = finishedTasks diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/StudyState.kt similarity index 80% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/StudyState.kt index cc54a86a4..4dcdd1b72 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/StudyState.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models enum class StudyState(val descr: String) { NONE("none"), @@ -16,7 +16,10 @@ enum class StudyState(val descr: String) { PAUSED("paused"), CLOSED("closed"); + fun isActive() = this == ACTIVE + companion object { - fun getState(name: String) = StudyState.values().firstOrNull { it.descr == name } ?: NONE + fun getState(name: String) = entries.firstOrNull { it.descr == name } ?: NONE } -} \ No newline at end of file +} + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskCompletion.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/TaskCompletion.kt similarity index 91% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskCompletion.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/TaskCompletion.kt index ac2d1af09..86098897d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskCompletion.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/TaskCompletion.kt @@ -8,6 +8,6 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models data class TaskCompletion(var finishedTasks: Int = 0, var totalTasks: Int = 0) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskDetailsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/TaskDetailsModel.kt similarity index 70% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskDetailsModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/TaskDetailsModel.kt index 9d45513a5..66d525370 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskDetailsModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/TaskDetailsModel.kt @@ -8,10 +8,10 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.models -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity data class TaskDetailsModel( val observationTitle: String, @@ -25,14 +25,17 @@ data class TaskDetailsModel( val state: ScheduleState, ) { companion object { - fun createModelFrom(observation: ObservationSchema, schedule: ScheduleSchema): TaskDetailsModel { + fun createModelFrom( + observation: ObservationEntity, + schedule: ScheduleEntity + ): TaskDetailsModel { return TaskDetailsModel( observationTitle = observation.observationTitle, observationType = observation.observationType, observationId = observation.observationId, - scheduleId = schedule.scheduleId.toHexString(), - start = schedule.start?.epochSeconds ?: 0, - end = schedule.end?.epochSeconds ?: 0, + scheduleId = schedule.scheduleId, + start = schedule.start ?: 0, + end = schedule.end ?: 0, participantInformation = observation.participantInfo, hidden = schedule.hidden, state = schedule.getState() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt deleted file mode 100644 index cee3d10c2..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.redlink.more.more_app_mutliplatform - -import io.redlink.more.more_app_mutliplatform.extensions.asNullableClosure -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.extensions.setNullable -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -object AlertController { - private val _alertDialogModel = MutableStateFlow(null) - - val alertDialogModel: StateFlow = _alertDialogModel - private var alertDialogQueue = mutableListOf() - - fun openAlertDialog(model: AlertDialogModel) { - if (model.onPositive == {}) { - model.onPositive = { - closeAlertDialog() - } - } - if (model.onNegative == {}) { - model.onNegative = { - closeAlertDialog() - } - } - if (this.alertDialogQueue.isEmpty() && this.alertDialogModel.value == null) { - this._alertDialogModel.set(model) - } else if (!this.alertDialogQueue.contains(model) && this.alertDialogModel.value != model) { - this.alertDialogQueue.add(model) - } - } - - fun closeAlertDialog() { - this._alertDialogModel.setNullable(alertDialogQueue.removeFirstOrNull()) - } - - fun onNewAlertDialogModel(provideNewState: ((AlertDialogModel?) -> Unit)) = - alertDialogModel.asNullableClosure(provideNewState) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt deleted file mode 100644 index 3c740bf49..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform - -import io.github.aakira.napier.Napier -import io.github.aakira.napier.log -import io.redlink.more.more_app_mutliplatform.database.DatabaseManager -import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.DataPointCountSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.StudyState -import io.redlink.more.more_app_mutliplatform.navigation.DeeplinkManager -import io.redlink.more.more_app_mutliplatform.observations.DataRecorder -import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManager -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.observations.ObservationManager -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnector -import io.redlink.more.more_app_mutliplatform.services.network.NetworkService -import io.redlink.more.more_app_mutliplatform.services.notification.LocalNotificationListener -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.services.store.CredentialRepository -import io.redlink.more.more_app_mutliplatform.services.store.EndpointRepository -import io.redlink.more.more_app_mutliplatform.services.store.SharedStorageRepository -import io.redlink.more.more_app_mutliplatform.services.store.StudyStateRepository -import io.redlink.more.more_app_mutliplatform.util.Scope -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager -import io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection.BluetoothController -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class Shared( - localNotificationListener: LocalNotificationListener, - private val sharedStorageRepository: SharedStorageRepository, - val observationDataManager: ObservationDataManager, - val mainBluetoothConnector: BluetoothConnector, - val observationFactory: ObservationFactory, - val dataRecorder: DataRecorder -) { - private val viewManager = ViewManager - val deeplinkManager = DeeplinkManager(observationFactory) - val endpointRepository: EndpointRepository = EndpointRepository(sharedStorageRepository) - val credentialRepository: CredentialRepository = CredentialRepository(sharedStorageRepository) - private val studyStateRepository: StudyStateRepository = - StudyStateRepository(sharedStorageRepository) - val networkService: NetworkService = NetworkService(endpointRepository, credentialRepository) - val observationManager = ObservationManager(observationFactory, dataRecorder) - val bluetoothController = BluetoothController(mainBluetoothConnector) - val notificationManager = - NotificationManager( - localNotificationListener, - networkService, - deeplinkManager, - sharedStorageRepository - ) - - var appIsInForeGround = false - - val unreadNotificationCount = notificationManager.unreadUserCount - - val currentStudyState = studyStateRepository.currentStudyState - var finishText: String? = null - private var bluetoothListener: Job? = null - - private val mutex = Mutex() - - init { - onApplicationStart() - observationFactory.setCredentialsRepository(credentialRepository) - observationFactory.setNotificationManager(notificationManager) - } - - private fun onApplicationStart() { - if (credentialRepository.hasCredentials()) { - activateObservationWatcher() - } - } - - fun appInForeground(boolean: Boolean) { - Napier.i { "App is in foreground: $boolean" } - appIsInForeGround = boolean - if (appIsInForeGround) { - notificationManager.clearAllNotifications() - if (credentialRepository.hasCredentials()) { - updateStudyBlocking() - notificationManager.createNewFCMIfNecessary() - bluetoothListener?.cancel() - bluetoothListener = StudyScope.launch { - bluetoothController.listenToConnectionChanges( - observationFactory - ) - }.second - observationFactory.updateObservationErrors() - updateTaskStates() - } - } else { - ViewManager.showBLEView(false) - } - } - - fun updateTaskStates() { - if (appIsInForeGround && credentialRepository.hasCredentials()) { - observationManager.updateTaskStates() - notificationManager.downloadMissedNotifications() - bluetoothController.startScanningForDevices(observationFactory.bleDevicesNeeded()) - } - } - - private fun activateObservationWatcher(overwriteCheck: Boolean = false) { - StudyScope.launch { - if (overwriteCheck || StudyRepository().getStudy().firstOrNull()?.active == true) { - observationDataManager.listenToDatapointCountChanges() - updateTaskStates() - observationManager.activateScheduleUpdate() - } - } - } - - fun resetFirstStartUp() { - log { "Resetting first login to true..." } - sharedStorageRepository.store(FIRST_OPEN_AFTER_LOGIN_KEY, true) - log { - "Reset! First login is ${ - sharedStorageRepository.load( - FIRST_OPEN_AFTER_LOGIN_KEY, - true - ) - }" - } - } - - private fun firstStartUp(): Boolean { - return if (sharedStorageRepository.load(FIRST_OPEN_AFTER_LOGIN_KEY, true)) { - log { "Setting first startup to false..." } - sharedStorageRepository.store(FIRST_OPEN_AFTER_LOGIN_KEY, false) - true - } else false - } - - private fun updateStudyBlocking( - oldStudyState: StudyState? = null, - newStudyState: StudyState? = null - ) { - Scope.launch(Dispatchers.IO) { - updateStudy(oldStudyState, newStudyState) - } - } - - suspend fun updateStudy( - oldStudyState: StudyState? = null, - newStudyState: StudyState? = null - ) { - mutex.withLock { - if (oldStudyState != null || newStudyState != null) { - Napier.d(tag = "Shared::updateStudy") { "Updating study with oldState: $oldStudyState and new state: $newStudyState" } - } else { - Napier.d(tag = "Shared::updateStudy") { "Updating study..." } - } - val studyRepository = StudyRepository() - val currentStudy = studyRepository.getStudy().firstOrNull() - if (currentStudy != null) { - Napier.d(tag = "Shared::updateStudy") { "Has current study: $currentStudy with study state: ${currentStudy.getState()} is active: ${currentStudy.active}" } - if (currentStudyState.firstOrNull() == StudyState.NONE) { - studyStateRepository.storeState(currentStudy.getState()) - } - currentStudy.finishText?.let { - finishText = it - } - } - if (newStudyState == StudyState.CLOSED || newStudyState == StudyState.PAUSED) { - Napier.d(tag = "Shared::updateStudy") { "New study State is $newStudyState" } - studyStateRepository.storeState(newStudyState) - viewManager.studyIsUpdating(true) - StudyScope.cancel() - stopObservations() - removeStudyData() - notificationManager.clearAllNotifications() - viewManager.studyIsUpdating(false) - } else { - val (study, error) = networkService.getStudyConfig() - if (error != null) { - Napier.e { error.message } - return - } - if (study == null) { - Napier.d { "Study is null" } - return - } - var studyHasChanged = false - currentStudy?.let { - if ((study.studyState?.let { StudyState.getState(it) } != it.getState() || it.active != study.active) || it.version != study.version) { - studyHasChanged = true - } - } - if (studyHasChanged || currentStudy == null) { - viewManager.studyIsUpdating(true) - StudyScope.cancel() - stopObservations() - removeStudyData() - if (study.studyState?.let { StudyState.getState(it) } != StudyState.CLOSED) { - studyRepository.storeStudy(study) - resetFirstStartUp() - observationFactory.updateObservationErrors() - } - if (newStudyState == null) { - studyStateRepository.storeState(study.studyState?.let { - StudyState.getState( - it - ) - } - ?: if (study.active == true) StudyState.ACTIVE else StudyState.PAUSED) - } - if (study.active == true) { - activateObservationWatcher(true) - } - viewManager.studyIsUpdating(false) - } - if (newStudyState != null) { - studyStateRepository.storeState(newStudyState) - } - } - } - } - - fun newLogin() { - notificationManager.newFCMToken() - studyStateRepository.storeState(StudyState.ACTIVE) - StudyScope.launch { - finishText = StudyRepository().getStudy().firstOrNull()?.finishText - } - activateObservationWatcher() - updateTaskStates() - observationFactory.updateObservationErrors() - bluetoothListener?.cancel() - bluetoothListener = StudyScope.launch { - bluetoothController.listenToConnectionChanges( - observationFactory - ) - }.second - } - - fun exitStudy(onDeletion: () -> Unit) { - StudyScope.cancel() - stopObservations() - bluetoothController.resetAll() - Scope.launch { - networkService.deleteParticipation() - notificationManager.clearAllNotifications() - notificationManager.deleteFCMToken() - clearSharedStorage() - removeStudyData() - onDeletion() - observationFactory.clearNeededObservationTypes() - viewManager.resetAll() - studyStateRepository.storeState(StudyState.NONE) - } - } - - private fun stopObservations() { - dataRecorder.stopAll() - observationDataManager.stopListeningToCountChanges() - } - - private fun clearSharedStorage() { - credentialRepository.remove() - } - - private suspend fun removeStudyData() { - DatabaseManager.deleteAllFromSchema( - setOf( - StudySchema::class, - ObservationSchema::class, - ScheduleSchema::class, - ObservationDataSchema::class, - DataPointCountSchema::class, - ) - ) - observationFactory.clearNeededObservationTypes() - } - - fun onStudyStateChange(providedState: (StudyState) -> Unit) = - currentStudyState.asClosure(providedState) - - fun unreadNotificationCountAsClosure(state: (Int) -> Unit) = - unreadNotificationCount.asClosure(state) - - companion object { - const val FIRST_OPEN_AFTER_LOGIN_KEY = "first_open_after_login_key" - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.kt deleted file mode 100644 index 2b631dbb1..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database - -import io.github.aakira.napier.Napier -import io.ktor.utils.io.core.Closeable -import io.realm.kotlin.types.RealmObject -import io.redlink.more.more_app_mutliplatform.database.schemas.DataPointCountSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import kotlin.reflect.KClass - -object DatabaseManager: Closeable { - val database = RealmDatabase - private val schemas = setOf( - StudySchema::class, - ObservationSchema::class, - ScheduleSchema::class, - ObservationDataSchema::class, - DataPointCountSchema::class, - NotificationSchema::class, - BluetoothDevice::class - ) - - init { - open() - Napier.i { "Opened Database!" } - } - - fun open() { - database.open(this.schemas) - } - - suspend fun deleteAllFromSchema(classes: Set>) { - classes.forEach { - database.deleteAlOfSchema(it) - } - } - - fun deleteAll() { - database.deleteAll() - } - - override fun close() { - database.close() - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/RealmDatabase.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/RealmDatabase.kt deleted file mode 100644 index 4f172b502..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/RealmDatabase.kt +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database - -import io.github.aakira.napier.Napier -import io.realm.kotlin.Realm -import io.realm.kotlin.RealmConfiguration -import io.realm.kotlin.UpdatePolicy -import io.realm.kotlin.ext.query -import io.realm.kotlin.query.Sort -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.TypedRealmObject -import io.redlink.more.more_app_mutliplatform.extensions.asMappedFlow -import io.redlink.more.more_app_mutliplatform.extensions.firstAsFlow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.transform -import kotlinx.coroutines.sync.Mutex -import kotlin.reflect.KClass - -private const val DB_SCHEMA_VERSION: Long = 4 - -object RealmDatabase { - var realm: Realm? = null - private set - - val mutex = Mutex() - - fun open(realmObjects: Set>) { - if (realm == null) { - Napier.i { "Init Realm..." } - val config = RealmConfiguration.Builder(realmObjects) - .schemaVersion(DB_SCHEMA_VERSION) - .build() - this.realm = Realm.open(config) - } - } - - fun close() { - Napier.i { "Closing Realm..." } - this.realm?.close() - this.realm = null - } - - fun store( - realmObjects: Collection, - updatePolicy: UpdatePolicy = UpdatePolicy.ALL, - ): Int { - if (realmObjects.isNotEmpty()) { - var storedObjects = realmObjects.size - realm?.writeBlocking { - realmObjects.forEach { - try { - copyToRealm(it, updatePolicy) - } catch (e: Exception) { - Napier.i { "Copy to Realm exception: $e" } - storedObjects--; - } - } - } - return storedObjects - } - return 0 - } - - inline fun count(): Flow { - return realm?.query()?.count()?.asFlow() ?: emptyFlow() - } - - inline fun findByPrimaryKey(key: String): Flow { - return realm?.query("_id == $0", key)?.firstAsFlow() ?: emptyFlow() - } - - inline fun query( - query: String? = null, - sortBy: String? = null, - sort: Sort = Sort.ASCENDING, - distinctBy: String? = null, - limit: Int = 0, - vararg queryArgs: Any - ): Flow> = realm?.let { realm -> - var realmQuery = query?.let { realm.query(query = it.trim(), args = queryArgs) } - ?: realm.query(args = queryArgs) - sortBy?.let { realmQuery = realmQuery.sort(it, sort) } - distinctBy?.let { realmQuery = realmQuery.distinct(it) } - if (limit > 0) realmQuery = realmQuery.limit(limit) - return realmQuery.asMappedFlow() - } ?: emptyFlow() - - inline fun queryAllWhereFieldInList( - field: String, - list: Set - ): Flow> { - return realm?.query("${field.trim()} IN $0", list)?.asMappedFlow() ?: emptyFlow() - } - - inline fun queryFirst( - query: String? = null, - sortBy: String? = null, - sort: Sort = Sort.ASCENDING, - distinctBy: String? = null, - vararg queryArgs: Any - ): Flow { - return query( - query, - sortBy, - sort, - distinctBy, - limit = 1, - *queryArgs - ).transform { emit(it.firstOrNull()) } - } - - inline fun deleteAllWhereFieldInList( - field: String, - list: List - ) { - realm?.writeBlocking { - list.map { this.query("${field.trim()} == $0", it).find() }.forEach { - delete(it) - } - } - } - - fun deleteItems(items: Collection) { - realm?.writeBlocking { - items.forEach { - delete(it) - } - } - } - - suspend fun deleteAlOfSchema(schema: KClass) { - realm?.write { - delete(schema) - } - } - - fun deleteAll() { - Napier.i { "Deleting all data from database..." } - realm?.writeBlocking { - this.deleteAll() - } - } -} - - - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt deleted file mode 100644 index ea2d5b511..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.realm.kotlin.UpdatePolicy -import io.realm.kotlin.ext.query -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.util.Scope -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.cancellable - -class BluetoothDeviceRepository : - Repository() { - - private val deviceManager = BluetoothDeviceManager - - init { - Scope.launch { - pairedDevices().cancellable().collect { - deviceManager.addPairedDeviceIds(it.toSet()) - } - } - } - - override fun count(): Flow = realmDatabase().count() - - fun storePairedDevice(bluetoothDevice: BluetoothDevice) { - if (bluetoothDevice.address != null) { - StudyScope.launch { - realm()?.write { - val device = - this.query("address = $0", bluetoothDevice.address).first() - .find() - if (device == null) { - this.copyToRealm(bluetoothDevice, updatePolicy = UpdatePolicy.ALL) - } - } - } - } - } - - fun unpairDevice(bluetoothDevice: BluetoothDevice) { - if (bluetoothDevice.address != null) { - StudyScope.launch { - realm()?.write { - val device = - this.query("address = $0", bluetoothDevice.address).first() - .find() - if (device != null) { - this.delete(device) - } - } - } - } - } - - fun pairedDevices() = realmDatabase().query() -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/DataPointCountRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/DataPointCountRepository.kt deleted file mode 100644 index 2d690e2bd..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/DataPointCountRepository.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.realm.kotlin.ext.query -import io.redlink.more.more_app_mutliplatform.database.schemas.DataPointCountSchema -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class DataPointCountRepository : Repository() { - private val mutex = Mutex() - private val countQueue = mutableMapOf() - private var storeJob: Job? = null - - override fun count(): Flow { - return realmDatabase().count() - } - - fun incrementCount(scheduleIdSet: Set, addCount: Long = 1) { - if (scheduleIdSet.isNotEmpty()) { - StudyScope.launch(Dispatchers.IO) { - mutex.withLock { - scheduleIdSet.forEach { scheduleId -> - countQueue[scheduleId] = countQueue.getOrElse(scheduleId) { 0 } + addCount - } - } - if (storeJob == null || storeJob?.isActive == false) { - storeJob = StudyScope.repeatedLaunch(5000L) { - storeCounts() - }.second - storeJob?.invokeOnCompletion { - storeJob = null - } - } - } - } - } - - private suspend fun storeCounts() { - val countsToStore: Map - mutex.withLock { - countsToStore = countQueue.toMap() - countQueue.clear() - } - if (countsToStore.isNotEmpty()) { - StudyScope.launch { - realm()?.write { - val dataPointCounts = this.query().find() - val dataPointScheduleIds = dataPointCounts.map { it.scheduleId }.toSet() - val (existing, nonExisting) = countsToStore.keys.partition { it in dataPointScheduleIds } - existing.forEach { id -> - dataPointCounts.firstOrNull { it.scheduleId == id }?.let { - it.count += countsToStore[id] ?: 0 - } - } - nonExisting.forEach { id -> - this.copyToRealm(DataPointCountSchema().apply { - count = countsToStore[id] ?: 0 - this.scheduleId = id - }) - } - } - } - } - } - - fun get(scheduleId: String): Flow { - return realmDatabase().queryFirst( - query = "scheduleId == $0", - queryArgs = arrayOf(scheduleId) - ) - } - - fun delete(scheduleId: String) { - realm()?.writeBlocking { - val existingObject: DataPointCountSchema? = this.query( - query = "scheduleId == $0", scheduleId - ).first().find() - existingObject?.let { - this.delete(it) - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/NotificationRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/NotificationRepository.kt deleted file mode 100644 index 6b27c083c..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/NotificationRepository.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.github.aakira.napier.Napier -import io.realm.kotlin.UpdatePolicy -import io.realm.kotlin.ext.query -import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema -import io.redlink.more.more_app_mutliplatform.services.network.NetworkService -import io.redlink.more.more_app_mutliplatform.util.Scope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class NotificationRepository : Repository() { - private val readNotificationIds = mutableSetOf() - private val deletedNotificationIds = mutableSetOf() - private val mutex = Mutex() - fun storeNotification( - key: String, - channelId: String?, - title: String?, - body: String?, - timestamp: Long, - priority: Long = 1, - read: Boolean = false, - userFacing: Boolean = true, - additionalData: Map? = null - ) { - Scope.launch { - mutex.withLock { - if (key !in deletedNotificationIds) { - storeNotification( - NotificationSchema.toSchema( - key, - channelId, - title, - body, - timestamp, - priority, - read, - userFacing, - additionalData - ) - ) - } else { - deletedNotificationIds.remove(key) - } - } - } - } - - fun storeNotification(notification: NotificationSchema) { - Scope.launch { - mutex.withLock { - Napier.i { "Delete Notification: $deletedNotificationIds. Notification to store: $notification" } - if (notification.notificationId !in deletedNotificationIds) { - if (notification.notificationId in readNotificationIds) { - notification.read = true - readNotificationIds.remove(notification.notificationId) - } - realmDatabase().store(setOf(notification), UpdatePolicy.ERROR) - } else { - deletedNotificationIds.remove(notification.notificationId) - } - } - } - } - - fun storeNotifications(notifications: List) { - Scope.launch { - mutex.withLock { - val (notificationsToStore, notificationsToDelete) = notifications.partition { it.notificationId !in deletedNotificationIds } - Napier.i { "Delete Notification: $deletedNotificationIds. Storing notifications: $notificationsToStore. Notifications to delete: $notificationsToDelete" } - realmDatabase().store(notificationsToStore.map { - it.apply { - read = it.notificationId in readNotificationIds - } - }, UpdatePolicy.ERROR) - notificationsToDelete.forEach { deletedNotificationIds.remove(it.notificationId) } - } - } - } - - override fun count(): Flow = realmDatabase().count() - - fun getAllNotifications() = realmDatabase().query() - - fun getAllUserFacingNotifications() = - realmDatabase().query("userFacing == true") - - fun getUnreadUserNotifications() = - realmDatabase().query("userFacing == true AND read == false") - - fun update(notificationId: String, read: Boolean? = false, priority: Long? = null) { - Scope.launch { - mutex.withLock { - realm()?.write { - this.query("notificationId == $0", notificationId).first() - .find() - ?.let { - if (read != null) { - it.read = read - } - if (priority != null) { - it.priority = priority - } - } - } - } - } - } - - fun downloadMissedNotifications(networkService: NetworkService) { - Scope.launch { - storeNotifications(NotificationSchema.toSchemaList(networkService.downloadMissedNotifications())) - } - } - - fun allUserFacingNotifications() { - realmDatabase().query("userFacing == true") - } - - fun countUserFacingNotifications() { - realm()?.query("userFacing == true")?.count() - } - - fun setNotificationReadStatus(key: String, read: Boolean = true) { - if (read) { - readNotificationIds.add(key) - } else { - readNotificationIds.remove(key) - } - - Scope.launch { - mutex.withLock { - realm()?.write { - val notification = - this.query("notificationId == $0", key).first().find() - notification?.let { - it.read = read - readNotificationIds.remove(key) - } - } - } - } - } - - fun deleteNotification(notificationId: String) { - Scope.launch { - deletedNotificationIds.add(notificationId) - mutex.withLock { - Napier.i { "Delete Notification: $deletedNotificationIds" } - realm()?.write { - val notification = - this.query("notificationId == $0", notificationId) - .first().find() - notification?.let { - delete(notification) - deletedNotificationIds.remove(notificationId) - Napier.i { "Deleted Notification: $deletedNotificationIds" } - } - } - } - } - } - - fun deleteAll() { - realmDatabase().deleteAll() - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationDataRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationDataRepository.kt deleted file mode 100644 index 017e2b611..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationDataRepository.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.github.aakira.napier.Napier -import io.realm.kotlin.UpdatePolicy -import io.realm.kotlin.ext.query -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema -import io.redlink.more.more_app_mutliplatform.extensions.mapAsBulkData -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk -import io.redlink.more.more_app_mutliplatform.util.Scope -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.mongodb.kbson.ObjectId - -class ObservationDataRepository : Repository() { - private var queue = mutableSetOf() - private val mutex = Mutex() - - - init { - Scope.repeatedLaunch(10000L) { - if (queue.isNotEmpty()) { - store() - } - } - } - - fun addData(dataList: List) { - StudyScope.launch { - mutex.withLock { - queue.addAll(dataList) - } - } - } - - fun store() { - if (queue.isNotEmpty()) { - StudyScope.launch { - val queueCopy = mutex.withLock { - val queueCopy = queue.toSet() - queue.clear() - queueCopy - } - realmDatabase().store(queueCopy, UpdatePolicy.ERROR) - } - } - } - - override fun count() = realmDatabase().count() - - suspend fun allAsBulk(): DataBulk? { - return mutex.withLock { - realmDatabase().query(limit = 5000).firstOrNull() - ?.mapAsBulkData() - } - } - - fun allAsBulk(completionHandler: (DataBulk?) -> Unit) { - StudyScope.launch(Dispatchers.IO) { - allAsBulk()?.let { completionHandler(it) } - } - } - - fun deleteAllWithId(idSet: Set) { - Napier.i { "Deleting ${idSet.size} elements..." } - val objectIdSet = idSet.map { ObjectId(it) }.toSet() - StudyScope.launch(Dispatchers.IO) { - mutex.withLock { - realm()?.write { - this.query().find().filter { it.dataId in objectIdSet } - .forEach { delete(it) } - } - - } - } - } - - companion object { - private const val QUEUE_THRESHOLD = 10 - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationRepository.kt deleted file mode 100644 index bd1ce9aa7..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationRepository.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.ktor.utils.io.core.Closeable -import io.realm.kotlin.ext.query -import io.realm.kotlin.types.RealmInstant -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.util.Scope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.transform - -class ObservationRepository : Repository() { - override fun count(): Flow = realmDatabase().count() - - fun observations() = realmDatabase().query() - - fun observationWithUndoneSchedules(): Flow>> { - return ScheduleRepository().allSchedulesWithStatus() - .combine(observations()) { schedules, observations -> - observations.associateWith { observation -> schedules.filter { it.observationId == observation.observationId } } - } - } - - fun lastCollection(type: String, timestamp: Long) { - Scope.launch { - realm()?.write { - this.query("observationType == $0", type) - .find() - .forEach { - it.collectionTimestamp = RealmInstant.from(timestamp, 0) - } - } - } - } - - fun lastCollection(type: Set, timestamp: Long) { - realm()?.writeBlocking { - this.query("observationType IN $0", type).find() - .forEach { it.collectionTimestamp = RealmInstant.from(timestamp, 0) } - } - } - - fun collectionTimestamp(type: String) = - realmDatabase().query("observationType == $0", queryArgs = arrayOf(type)) - .transform { - emit(it - .map { observationSchema -> observationSchema.collectionTimestamp } - .firstOrNull() - ) - } - - fun collectAllTimestamps() = - observations().transform { emit(it.associate { it.observationType to it.collectionTimestamp }) } - - fun collectTimestampForObservationIds(observationIds: Set) = - realmDatabase().queryAllWhereFieldInList( - "observationType", - observationIds - ).transform { - emit( - it.maxByOrNull { it.collectionTimestamp }?.collectionTimestamp - ?: RealmInstant.now() - ) - } - - fun collectTimestampOfType(type: String, newState: (RealmInstant?) -> Unit): Closeable { - return collectionTimestamp(type).asClosure(newState) - } - - fun collectAllTimestamps(newState: (Map) -> Unit): Closeable { - return collectAllTimestamps().transform { emit(it.mapValues { it.value.epochSeconds }) } - .asClosure(newState) - } - - fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable { - return observationWithUndoneSchedules().asClosure(newState) - } - - fun observationTypes(): Flow> = observations().transform { observationList -> - emit(observationList.map { it.observationType }.toSet()) - } - - fun observationById(observationId: String) = realmDatabase().queryFirst( - "observationId == $0", - queryArgs = arrayOf(observationId) - ) - - suspend fun getObservationByObservationId(observationId: String): ObservationSchema? { - return realmDatabase().queryFirst( - "observationId == $0", - queryArgs = arrayOf(observationId) - ).firstOrNull() - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/Repository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/Repository.kt deleted file mode 100644 index f8f67ac8a..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/Repository.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.ktor.utils.io.core.Closeable -import io.realm.kotlin.types.TypedRealmObject -import io.redlink.more.more_app_mutliplatform.database.DatabaseManager -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import kotlinx.coroutines.flow.Flow - -abstract class Repository : Closeable { - private val database = DatabaseManager - private var cache: T? = null - - fun realm() = database.database.realm - - fun realmDatabase() = database.database - - fun mutex() = database.database.mutex - - fun readCache() = cache - - abstract fun count(): Flow - - fun collectCount(provideNewState: ((Long) -> Unit)): Closeable { - return count().asClosure(provideNewState) - } - - override fun close() { - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ScheduleRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ScheduleRepository.kt deleted file mode 100644 index 60fd7bd25..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ScheduleRepository.kt +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.github.aakira.napier.Napier -import io.ktor.utils.io.core.Closeable -import io.realm.kotlin.ext.query -import io.realm.kotlin.types.RealmInstant -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.extensions.areAllNamesIn -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.asMappedFlow -import io.redlink.more.more_app_mutliplatform.extensions.firstAsFlow -import io.redlink.more.more_app_mutliplatform.extensions.localDateTime -import io.redlink.more.more_app_mutliplatform.extensions.toLocalDate -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.observations.DataRecorder -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.ObservationType -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.transform -import kotlinx.datetime.Clock -import org.mongodb.kbson.ObjectId - -class ScheduleRepository : Repository() { - - override fun count(): Flow = realmDatabase().count() - - fun allSchedulesWithStatus(done: Boolean = false): Flow> { - return realm()?.query("done = $0", done)?.asMappedFlow() ?: emptyFlow() - } - - fun allScheduleWithRunningState(scheduleState: ScheduleState = ScheduleState.RUNNING) = - realmDatabase().query( - query = "state = $0", - queryArgs = arrayOf(scheduleState.name) - ) - - fun firstScheduleAvailableForObservationId(observationId: String): Flow { - return realm()?.query("observationId = $0", observationId)?.asMappedFlow() - ?.transform { scheduleList -> - if (realm()?.query("observationId = $0", observationId) - ?.firstAsFlow()?.firstOrNull()?.scheduleLess == true - ) { - emit(scheduleList.sortedBy { it.end }.last()) - } else { - val now = Clock.System.now().epochSeconds - val filtered = scheduleList.filter { - !it.getState().completed() - && it.start != null - && it.end != null - && (it.end?.epochSeconds ?: 0) > now - }.sortedBy { it.start?.epochSeconds }.firstOrNull() - emit(filtered) - } - } ?: emptyFlow() - } - - fun allSchedulesToday(observationType: ObservationType): Flow> { - val today = Clock.System.now().localDateTime().date - return realm()?.query( - "observationType = $0", - observationType.observationType - ) - ?.asMappedFlow()?.transform { list -> - emit(list.filter { - !it.getState().completed() - && (it.start?.toLocalDate() == today || it.end?.toLocalDate() == today) - }) - } ?: flow { emit(emptyList()) } - } - - fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = - firstScheduleAvailableForObservationId(observationId).transform { it?.scheduleId?.toHexString() } - - fun collectRunningState( - forState: ScheduleState, - provideNewState: (List) -> Unit - ): Closeable { - return allScheduleWithRunningState(forState).asClosure(provideNewState) - } - - fun getFirstAndLastDate(observationId: String): Flow> { - return realmDatabase().query( - query = "observationId = $0", - queryArgs = arrayOf(observationId) - ).transform { - val start = it.sortedBy { it.start }.firstOrNull() - val end = it.sortedBy { it.end }.lastOrNull() - - emit(Pair(start, end)) - } - } - - fun setRunningStateFor(id: String, scheduleState: ScheduleState) { - realm()?.writeBlocking { - this.query("scheduleId = $0", ObjectId(id)).first().find()?.let { - it.state = scheduleState.name - } - } - } - - fun setCompletionStateFor(id: String, wasDone: Boolean) { - realm()?.writeBlocking { - this.query("scheduleId = $0", ObjectId(id)).first().find() - ?.updateState(if (wasDone) ScheduleState.DONE else ScheduleState.ENDED) - } - } - - fun nextSchedule(): Flow { - return allSchedulesWithStatus().transform { schemas -> - val startInstances = - schemas.mapNotNull { it.start }.filter { it > RealmInstant.now() }.toSet() - val endInstances = - schemas.mapNotNull { it.end }.filter { it > RealmInstant.now() }.toSet() - val nextStart = startInstances.minOfOrNull { it.epochSeconds } - val nextEnd = endInstances.minOfOrNull { it.epochSeconds } - if (nextStart != null && nextEnd != null) { - if (nextStart < nextEnd) emit(nextStart) else emit(nextEnd) - return@transform - } - if (nextStart != null) emit(nextStart) else emit(nextEnd) - } - } - - suspend fun getNextSchedule() = nextSchedule().firstOrNull() - - fun nextScheduleStart(): Flow { - return allSchedulesWithStatus().transform { schemas -> - emit( - schemas.mapNotNull { it.start }.filter { it > RealmInstant.now() }.toSet() - .minOfOrNull { it.epochSeconds }) - } - } - - fun collectNextScheduleStart(provideNewState: (Long?) -> Unit) = - nextScheduleStart().asClosure(provideNewState) - - fun scheduleWithId(id: String) = realmDatabase().queryFirst( - query = "scheduleId = $0", - queryArgs = arrayOf(ObjectId(id)) - ) - - fun updateTaskStates(observationFactory: ObservationFactory, dataRecorder: DataRecorder) { - StudyScope.launch { - updateTaskStatesSync(observationFactory, dataRecorder) - } - } - - suspend fun updateTaskStatesSync( - observationFactory: ObservationFactory, - dataRecorder: DataRecorder - ) { - val autoStartingObservations = observationFactory.autoStartableObservations() - Napier.i { "Updating Schedule states..." } - val activeIds = realm()?.let { - it.write { - query("done = $0", false).find().mapNotNull { scheduleSchema -> - val newState = scheduleSchema.updateState() - if (scheduleSchema.getState() != newState) { - Napier.i { "State update for Schema: $scheduleSchema; ${scheduleSchema.getState()} -> $newState" } - } - if (newState == ScheduleState.RUNNING - || (autoStartingObservations.isNotEmpty() - && scheduleSchema.hidden - && newState.active() - && scheduleSchema.observationType in autoStartingObservations - && observationFactory.observation(scheduleSchema.observationType) - ?.bleDevicesNeeded() - ?.areAllNamesIn(BluetoothDeviceManager.connectedDevices.value) != false) - ) { - scheduleSchema.scheduleId.toHexString() - } else { - null - } - } - } - }?.toSet() ?: emptySet() - if (activeIds.isNotEmpty()) { - dataRecorder.startMultiple(activeIds) - } - } - - suspend fun updateTaskStatesWithBLEDevices( - observationFactory: ObservationFactory, - dataRecorder: DataRecorder - ) { - val autoStartingObservations = observationFactory.autoStartableObservations() - if (autoStartingObservations.isNotEmpty()) { - Napier.i { "Updating Schedule states using Bluetooth devices..." } - val activeIds = realm()?.let { - it.write { - query("done = $0", false).find().filter { - observationFactory.observation(it.observationType)?.bleDevicesNeeded() - ?.isNotEmpty() == true && it.observationType in autoStartingObservations - }.mapNotNull { scheduleSchema -> - val newState = scheduleSchema.updateState() - if (newState.active() && scheduleSchema.hidden && observationFactory.observation( - scheduleSchema.observationType - ) - ?.bleDevicesNeeded() - ?.areAllNamesIn(BluetoothDeviceManager.connectedDevices.value) != false - - ) { - scheduleSchema.scheduleId.toHexString() - } else { - null - } - } - } - }?.toSet() ?: emptySet() - if (activeIds.isNotEmpty()) { - dataRecorder.startMultiple(activeIds) - } - } - } - - -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/StudyRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/StudyRepository.kt deleted file mode 100644 index 333a321eb..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/StudyRepository.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.realm.kotlin.types.RealmObject -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import kotlinx.coroutines.flow.Flow - -class StudyRepository : Repository() { - fun storeStudy(study: Study) { - val realmObjects = mutableListOf() - realmObjects.add(StudySchema.toSchema(study)) - realmObjects.addAll(study.observations.map { ObservationSchema.toSchema(it) }) - realmObjects.addAll(study.observations.map { observation -> - observation.schedule.mapNotNull { - ScheduleSchema.toSchema( - it, - observation.observationId, - observation.observationType, - observation.observationTitle, - observation.hidden ?: observation.noSchedule - ) - } - }.flatten()) - realmDatabase().store(realmObjects) - } - - fun getStudy(): Flow { - return realmDatabase().queryFirst() - } - - override fun count(): Flow = realmDatabase().count() -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/DataPointCountSchema.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/DataPointCountSchema.kt deleted file mode 100644 index 5d4819c1c..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/DataPointCountSchema.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.schemas - -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.annotations.PrimaryKey -import org.mongodb.kbson.ObjectId - -class DataPointCountSchema: RealmObject { - @PrimaryKey - var id: ObjectId = ObjectId.invoke() - var scheduleId: String = "" - var count: Long = 0 -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt deleted file mode 100644 index 3030e49c2..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.schemas - -import io.realm.kotlin.ext.realmDictionaryOf -import io.realm.kotlin.ext.toRealmDictionary -import io.realm.kotlin.types.RealmDictionary -import io.realm.kotlin.types.RealmInstant -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.annotations.PrimaryKey -import io.redlink.more.more_app_mutliplatform.extensions.toRealmInstant -import io.redlink.more.more_app_mutliplatform.getPlatform -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotification -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.util.createUUID -import kotlinx.datetime.Instant - -class NotificationSchema : RealmObject { - @PrimaryKey - var notificationId: String = "" - var channelId: String? = "" - var title: String? = "" - var notificationBody: String? = "" - var timestamp: RealmInstant? = RealmInstant.now() - var priority: Long = 0 - var read: Boolean = false - var userFacing: Boolean = true - var deepLink: String? = null - var notificationData: RealmDictionary = realmDictionaryOf() - - override fun toString(): String { - return "NotificationSchema(notificationId='$notificationId', channelId=$channelId, title=$title, notificationBody=$notificationBody, timestamp=${timestamp.toString()}, priority=$priority, read=$read, userFacing=$userFacing, deepLink=$deepLink, notificationData=$notificationData)" - } - - fun deepLink(): String? = deepLink?.let { - if (!it.contains("notificationId=")) { - if (it.contains("?")) { - "$it¬ificationId=$notificationId" - } else { - "$it?notificationId=$notificationId" - } - } else { - it - } - } - - companion object { - fun build(title: String, notificationBody: String): NotificationSchema = - NotificationSchema().apply { - this.notificationId = createUUID() - this.title = title - this.notificationBody = notificationBody - this.priority = if (getPlatform().name.contains("Android")) 2 else 1 - } - - fun toSchema( - notificationId: String, - channelId: String?, - title: String?, - notificationBody: String?, - timestamp: Long? = null, - priority: Long, - read: Boolean, - userFacing: Boolean, - notificationData: Map?, - deepLink: String? = null - ): NotificationSchema { - return NotificationSchema().apply { - this.notificationId = notificationId - this.channelId = channelId - this.title = title - this.notificationBody = notificationBody - this.read = read - this.userFacing = userFacing - this.notificationData = - notificationData?.mapKeys { it.key.replace(".", "_") }?.toRealmDictionary() - ?: realmDictionaryOf() - this.deepLink = deepLink ?: extractDeepLink(this.notificationData) - this.priority = if (this.deepLink != null) 2 else priority - this.timestamp = - timestamp?.let { Instant.fromEpochMilliseconds(timestamp).toRealmInstant() } - ?: RealmInstant.now() - } - } - - fun toSchema(notification: PushNotification): NotificationSchema { - return toSchema( - notificationId = notification.msgId, - channelId = null, - title = notification.title, - notificationBody = notification.body, - timestamp = notification.timestamp?.toEpochMilliseconds(), - priority = 1, - read = false, - userFacing = notification.type == "text", - notificationData = notification.data?.mapValues { it.value.toString() }, - deepLink = notification.deepLink - ) - } - - fun toSchemaList(notifications: List): List = - notifications.map { toSchema(it) } - - private fun extractDeepLink(data: Map) = data[NotificationManager.DEEP_LINK] - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationDataSchema.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationDataSchema.kt deleted file mode 100644 index 729c1b263..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationDataSchema.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.schemas - -import io.realm.kotlin.types.RealmInstant -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.annotations.PrimaryKey -import io.redlink.more.more_app_mutliplatform.extensions.asString -import io.redlink.more.more_app_mutliplatform.extensions.toInstant -import io.redlink.more.more_app_mutliplatform.extensions.toRealmInstant -import io.redlink.more.more_app_mutliplatform.observations.ObservationBulkModel -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.ObservationData -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.mongodb.kbson.ObjectId - -class ObservationDataSchema : RealmObject { - @PrimaryKey - var dataId: ObjectId = ObjectId() - var observationId: String = "" - var observationType: String = "" - var dataValue: String = "" - var timestamp: RealmInstant = RealmInstant.now() - - fun asObservationData(): ObservationData = - ObservationData( - dataId = this.dataId.toHexString(), - observationId = this.observationId, - observationType = this.observationType, - dataValue = Json.decodeFromString(dataValue), - timestamp = this.timestamp.toInstant() - ) - - override fun toString(): String { - return "dataId: $dataId; observationId: $observationId; observationType: $observationType, timestamp: $timestamp, data: $dataValue;" - } - - companion object { - fun fromObservationData(observationData: ObservationData): ObservationDataSchema { - return ObservationDataSchema().apply { - observationId = observationData.observationId - observationType = observationData.observationType - dataValue = observationData.dataValue?.let { Json.encodeToString(it) } ?: "" - timestamp = observationData.timestamp.toRealmInstant() - } - } - - fun fromData(data: Any, timestamp: Long = -1): ObservationDataSchema { - return ObservationDataSchema().apply { - if (timestamp > 0) { - this.timestamp = - RealmInstant.from(epochSeconds = timestamp, nanosecondAdjustment = 0) - } - this.dataValue = data.asString() ?: "{}" - } - } - - fun fromData(data: ObservationBulkModel): ObservationDataSchema { - return fromData(data.data, data.timestamp) - } - - fun fromData(data: Collection): List { - return data.map { fromData(it) } - } - - fun fromData( - observationIdSet: Set, - data: Collection - ): List { - return observationIdSet.flatMap { id -> - fromData(data).map { it.apply { observationId = id } } - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationSchema.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationSchema.kt deleted file mode 100644 index 8060e395f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationSchema.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.schemas - -import io.github.aakira.napier.Napier -import io.realm.kotlin.types.RealmInstant -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.annotations.PrimaryKey -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Observation -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import org.mongodb.kbson.ObjectId - - -class ObservationSchema : RealmObject { - @PrimaryKey - var _id: ObjectId = ObjectId.invoke() - var observationId: String = "" - var observationType: String = "" - var observationTitle: String = "" - var participantInfo: String = "" - var configuration: String? = null - var hidden: Boolean? = null - var scheduleLess: Boolean = false - var version: Long = 0 - var required: Boolean = false - var collectionTimestamp: RealmInstant = RealmInstant.now() - - fun configAsMap(): Map = configuration?.let { config -> - try { - Json.decodeFromString(config).toMap() - } catch (e: Exception) { - Napier.e { e.stackTraceToString() } - emptyMap() - } - } ?: emptyMap() - - companion object { - fun toSchema(observation: Observation): ObservationSchema { - return ObservationSchema().apply { - observationId = observation.observationId - observationTitle = observation.observationTitle - observationType = observation.observationType - participantInfo = observation.participantInfo - configuration = observation.configuration.toString() - hidden = observation.hidden - scheduleLess = observation.noSchedule - required = observation.required - version = observation.version - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ScheduleSchema.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ScheduleSchema.kt deleted file mode 100644 index c35336442..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ScheduleSchema.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.schemas - -import io.realm.kotlin.types.RealmInstant -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.annotations.PrimaryKey -import io.redlink.more.more_app_mutliplatform.extensions.toRealmInstant -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.ObservationSchedule -import kotlinx.datetime.Clock -import org.mongodb.kbson.ObjectId - -class ScheduleSchema : RealmObject { - @PrimaryKey - var scheduleId: ObjectId = ObjectId.invoke() - var observationId: String = "" - var observationType: String = "" - var observationTitle: String = "" - var start: RealmInstant? = null - var end: RealmInstant? = null - var done: Boolean = false - var hidden: Boolean = false - var state: String = ScheduleState.DEACTIVATED.name - - fun getState() = ScheduleState.getState(state) - - fun updateState(specificState: ScheduleState? = null): ScheduleState { - if (specificState != null) { - state = specificState.name - } else { - val now = Clock.System.now().toEpochMilliseconds() - (start?.epochSeconds?.times(1000))?.let { start -> - (end?.epochSeconds?.times(1000))?.let { end -> - state = if (end <= now) { - if (getState().running()) { - ScheduleState.DONE.name - } else { - ScheduleState.ENDED.name - } - } else if (now < start) { - ScheduleState.DEACTIVATED.name - } else if (start <= now && !getState().running()) { - ScheduleState.ACTIVE.name - } else { - state - } - } - } - } - if (getState() == ScheduleState.DONE) { - done = true - } - return getState() - } - - fun equalsSchedule(other: ScheduleSchema): Boolean { - return scheduleId == other.scheduleId - } - - override fun toString(): String { - return """ScheduleSchema: {"scheduleId": "$scheduleId", "observationId": "$observationId","observationType": "$observationType","observationTitle": "$observationTitle","start": "${start?.toString()}","end": "${end?.toString()}","done": $done,"hidden": $hidden,"state": "$state"}""" - } - - - companion object { - fun toSchema( - schedule: ObservationSchedule, - observationId: String, - observationType: String, - observationTitle: String, - hidden: Boolean - ): ScheduleSchema? { - return if (schedule.start != null && schedule.end != null) { - val now = Clock.System.now().epochSeconds - val scheduleState = - if (schedule.start.epochSeconds < now && schedule.end.epochSeconds > now) { - ScheduleState.ACTIVE - } else if (schedule.start.epochSeconds > now) { - ScheduleState.DEACTIVATED - } else { - ScheduleState.ENDED - } - ScheduleSchema().apply { - this.observationId = observationId - this.observationType = observationType - this.observationTitle = observationTitle - start = schedule.start.toRealmInstant() - end = schedule.end.toRealmInstant() - this.hidden = hidden - state = scheduleState.name - } - } else null - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/StudySchema.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/StudySchema.kt deleted file mode 100644 index c902d1193..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/StudySchema.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.database.schemas - -import io.realm.kotlin.types.RealmInstant -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.annotations.PrimaryKey -import io.redlink.more.more_app_mutliplatform.extensions.toRealmInstant -import io.redlink.more.more_app_mutliplatform.models.StudyState -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import kotlinx.datetime.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import org.mongodb.kbson.ObjectId - -class StudySchema : RealmObject { - @PrimaryKey - var studyId: ObjectId = ObjectId.invoke() - var studyTitle: String = "" - var participantId: Int? = null - var participantAlias: String? = "" - var participantInfo: String = "" - var consentInfo: String = "" - var start: RealmInstant? = null - var end: RealmInstant? = null - var contactInstitute: String? = null - var contactPerson: String? = null - var contactEmail: String? = null - var contactPhoneNumber: String? = null - var version: Long = 0 - var active: Boolean = false - var state: String = (if(active) StudyState.ACTIVE else StudyState.PAUSED).descr - var finishText: String? = null - - fun getState() = StudyState.getState(state) - - companion object { - fun toSchema(study: Study): StudySchema { - return StudySchema().apply { - studyTitle = study.studyTitle - consentInfo = study.consentInfo - participantInfo = study.participantInfo - participantId = study.participant?.id - participantAlias = study.participant?.alias - start = Instant.fromEpochMilliseconds( - study.start.atStartOfDayIn(TimeZone.currentSystemDefault()) - .toEpochMilliseconds() - ).toRealmInstant() - end = Instant.fromEpochMilliseconds( - study.end.atStartOfDayIn(TimeZone.currentSystemDefault()) - .toEpochMilliseconds() - ).toRealmInstant() - contactInstitute = study.contact?.institute - contactPerson = study.contact?.person - contactEmail = study.contact?.email - contactPhoneNumber = study.contact?.phoneNumber - version = study.version - active = study.active ?: false - state = (study.studyState?.let { StudyState.getState(it) } ?: if (active) StudyState.ACTIVE else StudyState.PAUSED).descr - finishText = study.finishText - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/RealmExtensions.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/RealmExtensions.kt deleted file mode 100644 index 52478f14f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/RealmExtensions.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.extensions - -import io.realm.kotlin.query.RealmQuery -import io.realm.kotlin.types.RealmInstant -import io.realm.kotlin.types.TypedRealmObject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.transform -import kotlinx.datetime.LocalDate - -fun RealmQuery.asMappedFlow(): Flow> { - return asFlow().transform { emit(it.list) } -} - -fun RealmQuery.firstAsFlow(): Flow { - return first().asFlow().transform { emit(it.obj) } -} - -fun RealmInstant.toLocalDate(): LocalDate = this.toInstant().localDateTime().date \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/AlertDialogModel.kt deleted file mode 100644 index c5af0ffaa..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/AlertDialogModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.models - -data class AlertDialogModel( - var title: String, - var message: String, - var positiveTitle: String, - var negativeTitle: String? = null, - var onPositive: () -> Unit = {}, - var onNegative: () -> Unit = {} -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as AlertDialogModel - - if (title != other.title) return false - if (message != other.message) return false - if (positiveTitle != other.positiveTitle) return false - if (negativeTitle != other.negativeTitle) return false - - return true - } - - override fun hashCode(): Int { - var result = title.hashCode() - result = 31 * result + message.hashCode() - result = 31 * result + positiveTitle.hashCode() - result = 31 * result + (negativeTitle?.hashCode() ?: 0) - return result - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationFilterTypeModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationFilterTypeModel.kt deleted file mode 100644 index 2937bc8af..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationFilterTypeModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.models - -enum class NotificationFilterTypeModel(val type: String, val sortIndex: Int) { - ALL("All", 0), - UNREAD("Unread", 1), - IMPORTANT("Important", 2); - - companion object { - fun createModel(type: String): NotificationFilterTypeModel? { - return when(type) { - ALL.type -> ALL - UNREAD.type -> UNREAD - IMPORTANT.type -> IMPORTANT - else -> { null } - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationModel.kt deleted file mode 100644 index 86aefe0b8..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationModel.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.models - -import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema -import io.redlink.more.more_app_mutliplatform.extensions.toInstant - -data class NotificationModel( - var notificationId: String, - var channelId: String?, - var title: String, - var notificationBody: String, - var timestamp: Long, - var priority: Long, - var read: Boolean, - var userFacing: Boolean, - var deepLink: String?, - var notificationData: Map -) { - - companion object { - fun createModelsFrom(notifications: List): List { - return notifications.mapNotNull { - it?.let { - val channelId = it.channelId - val title = it.title ?: return@mapNotNull null - val notificationBody = it.notificationBody ?: return@mapNotNull null - val timestamp = it.timestamp ?: return@mapNotNull null - val notificationData = it.notificationData - NotificationModel( - notificationId = it.notificationId, - channelId = channelId, - title = title, - notificationBody = notificationBody, - timestamp = timestamp.toInstant().toEpochMilliseconds(), - priority = it.priority, - read = it.read, - userFacing = it.userFacing, - deepLink = it.deepLink(), - notificationData = notificationData - ) - } - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/navigation/DeeplinkManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/navigation/DeeplinkManager.kt deleted file mode 100644 index 4416d768b..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/navigation/DeeplinkManager.kt +++ /dev/null @@ -1,141 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.navigation - -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.extractRouteFromDeepLink -import io.redlink.more.more_app_mutliplatform.extensions.mapQueryParams -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flow -import kotlinx.datetime.Clock - -class DeeplinkManager(private val observationFactory: ObservationFactory) { - private val deepLinks = mutableSetOf() - private val scheduleRepository = ScheduleRepository() - private val observationRepository = ObservationRepository() - - fun addAvailableDeepLinks(deepLinks: Set) { - this.deepLinks.addAll(deepLinks) - } - - fun modifyDeepLink( - deepLink: String?, - protocolReplacement: String? = null, - hostReplacement: String? = null - ): Flow = flow { - deepLink?.let { deepLink -> - val queryParams = deepLink.mapQueryParams() - val observationId = queryParams["observationId"] - if (observationId.isNullOrEmpty() - || observationRepository.observationById(observationId.first()) - .firstOrNull() == null - ) { - emit(null) - return@flow - } - val schedule = - scheduleRepository.firstScheduleAvailableForObservationId(observationId.first()) - .cancellable().firstOrNull() - - emit(deepLinkModifier(deepLink, schedule, protocolReplacement, hostReplacement)) - } ?: run { - emit(deepLink) - } - } - - private fun deepLinkModifier( - deepLink: String, - schedule: ScheduleSchema?, - protocolReplacement: String?, - hostReplacement: String? - ): String { - val selectedRoute = selectRoute(deepLink, schedule) - return replaceRoute(deepLink, selectedRoute, schedule, protocolReplacement, hostReplacement) - } - - private fun validateRoute(deepLink: String): Boolean { - return deepLink.extractRouteFromDeepLink()?.let { route -> - deepLinks.firstOrNull { it.contains(route) } != null - } ?: false - } - - private fun routeForObservation(deepLink: String): String? { - return deepLink.extractRouteFromDeepLink()?.let { route -> - observationFactory.observationTypes().firstOrNull { - it.contains(route) - }?.let { - if (validateRoute(deepLink)) route else null - } ?: TASK_DETAILS - } - } - - private fun selectRoute(deepLink: String, schedule: ScheduleSchema?): String { - val now = Clock.System.now() - - return schedule?.let { scheduleSchema -> - if ((scheduleSchema.start?.epochSeconds ?: 0) <= now.epochSeconds) { - routeForObservation(deepLink) - } else { - TASK_DETAILS - } - } ?: OBSERVATION_DETAILS - } - - private fun replaceRoute( - deepLink: String, - routeToReplace: String, - schedule: ScheduleSchema? = null, - protocolReplacement: String? = null, - hostReplacement: String? = null - ): String { - val protocolAndHost = (protocolReplacement ?: deepLink.substringBefore("://")) + "://" - val afterProtocol = deepLink.substringAfter("://") - val hostAndPath = afterProtocol.substringBefore('?') - val host = hostReplacement ?: hostAndPath.substringBeforeLast( - "/", - missingDelimiterValue = hostAndPath - ) - val fragment = deepLink.substringAfter('#', "") - - val newHostAndPath = - if (hostAndPath.contains('/')) "$host/$routeToReplace" else "$hostAndPath/$routeToReplace" - - val paramsMap = deepLink.mapQueryParams().toMutableMap() - - schedule?.let { - val scheduleIdKeySet = - paramsMap.getOrElse("scheduleId") { mutableSetOf() }.toMutableSet() - scheduleIdKeySet.add(it.scheduleId.toHexString()) - paramsMap["scheduleId"] = scheduleIdKeySet - } - - val newQueryParams = paramsMap.entries.flatMap { entry -> - entry.value.map { "${entry.key}=${it}" } - }.joinToString("&") - - - return buildString { - append(protocolAndHost) - append(newHostAndPath) - if (newQueryParams.isNotEmpty()) append("?").append(newQueryParams) - if (fragment.isNotEmpty()) append("#").append(fragment) - } - } - - fun modifyDeepLink( - deepLink: String?, - protocolReplacement: String? = null, - hostReplacement: String? = null, - newState: (String?) -> Unit - ) = modifyDeepLink(deepLink, protocolReplacement, hostReplacement).asClosure(newState) - - companion object { - const val TASK_DETAILS = "task-details" - const val OBSERVATION_DETAILS = "observation-details" - const val DASHBOARD = "dashboard" - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt deleted file mode 100644 index bed413629..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.observations - -import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.ObservationType -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant - -abstract class Observation(val observationType: ObservationType) { - private val scheduleRepository = ScheduleRepository() - private var dataManager: ObservationDataManager? = null - private var notificationManager: NotificationManager? = null - private val observationRepository = ObservationRepository() - - private var running = false - private val observationIds = mutableSetOf() - private val scheduleIds = mutableMapOf() - private val notificationIds = mutableMapOf() - private val config = mutableMapOf() - private var configChanged = false - - protected var lastCollectionTimestamp: Instant = Clock.System.now() - - var timestampCollectionJob: Job? = null - - private val _observationErrors = MutableStateFlow>>( - Pair( - this.observationType.observationType, - emptySet() - ) - ) - val observationErrors: StateFlow>> = _observationErrors; - - fun start(observationId: String, scheduleId: String, notificationId: String? = null): Boolean { - observationIds.add(observationId) - timestampCollectionJob?.cancel() - timestampCollectionJob = StudyScope.launch { - observationRepository.collectTimestampForObservationIds(observationIds).collect { - lastCollectionTimestamp = Instant.fromEpochSeconds(it.epochSeconds) - Napier.d(tag = "Observation::start") { "Last collection $lastCollectionTimestamp" } - } - }.second - timestampCollectionJob?.invokeOnCompletion { - timestampCollectionJob = null - } - scheduleIds[scheduleId] = observationId - notificationId?.let { - notificationIds[scheduleId] = notificationId - } - if (running && configChanged) { - stopAndFinish(scheduleId) - } - configChanged = false - return if (!running) { - Napier.i(tag = "Observation::start") { "Observation with type ${observationType.observationType} starting" } - applyObservationConfig(config) - running = start() - return running - } else true - } - - fun stop(scheduleId: String, removeNotification: Boolean = false) { - Napier.i(tag = "Observation::stop") { "Stopping observation of type ${observationType.observationType} for schedule $scheduleId." } - if (observationIds.size <= 1) { - stop { - timestampCollectionJob?.cancel() - saveAndSend() - observationShutdown(scheduleId) - } - } else { - saveAndSend() - } - if (removeNotification) { - handleNotification(scheduleId) - } - updateObservationErrors() - } - - fun observationDataManagerAdded() = dataManager != null - - fun setDataManager(observationDataManager: ObservationDataManager) { - Napier.i(tag = "Observation::setDataManager") { "Setting data manager for observation of type ${observationType.observationType}." } - dataManager = observationDataManager - } - - fun setNotificationManager(notificationManager: NotificationManager) { - this.notificationManager = notificationManager - } - - fun addNotificationId(scheduleId: String, notificationId: String) { - notificationIds[scheduleId] = notificationId - } - - fun observationConfig(settings: Map) { - this.lastCollectionTimestamp = (settings[CONFIG_LAST_COLLECTION_TIMESTAMP] as? Long)?.let { - Instant.fromEpochSeconds(it, 0) - } ?: Clock.System.now() - if (settings.isNotEmpty()) { - Napier.i(tag = "Observation::observationConfig") { "Applying new observation settings for ${observationType.observationType}: $settings" } - val newConfig = this.config + settings - if (newConfig != this.config) { - configChanged = true - this.config += newConfig - } - } - } - - protected fun collectionTimestampToNow() { - Napier.d(tag = "Observation::collectionTimeStampToNow") { "Collecting timestamp" } - lastCollectionTimestamp = Clock.System.now() - observationRepository.lastCollection( - observationIds.toSet(), - lastCollectionTimestamp.epochSeconds - ) - } - - protected abstract fun start(): Boolean - - protected abstract fun stop(onCompletion: () -> Unit) - - fun observerAccessible(): Boolean { - val errors = observerErrors() - Napier.d(tag = "Observation::observerAccessible") { errors.toString() } - updateObservationErrors() - return errors.isEmpty() - } - - protected open fun observerErrors(): Set = emptySet() - - fun updateObservationErrors() { - StudyScope.launch(Dispatchers.IO) { - scheduleRepository.allSchedulesToday(observationType).firstOrNull()?.let { - if (it.isNotEmpty()) { - Napier.d(tag = "Observation::updateObservationErrors") { "ObservationErrors for ${observationType.observationType}" } - - _observationErrors.update { - Pair( - observationType.observationType, - observerErrors() - ) - } - } - } - } - } - - protected abstract fun applyObservationConfig(settings: Map) - - open fun bleDevicesNeeded(): Set = emptySet() - - open fun ableToAutomaticallyStart() = true - - fun storeData(data: Any, timestamp: Long = -1, onCompletion: () -> Unit = {}) { - val dataSchemas = ObservationDataSchema.fromData( - observationIds.toSet(), setOf(ObservationBulkModel(data, timestamp)) - ).map { observationType.addObservationType(it) } - Napier.i(tag = "Observation::storeData") { "Observation, with ids $observationIds, ${observationType.observationType} recorded a new data point!" } - dataManager?.add(dataSchemas, scheduleIds.keys) - onCompletion() - } - - fun storeData(data: List, onCompletion: () -> Unit) { - val dataSchemas = ObservationDataSchema.fromData(observationIds.toSet(), data) - .map { observationType.addObservationType(it) } - Napier.i(tag = "Observation::storeData") { "Observation, with ids $observationIds, ${observationType.observationType} recorded new datapoints!" } - dataManager?.add(dataSchemas, scheduleIds.keys) - onCompletion() - } - - fun stopAndFinish(scheduleId: String) { - Napier.i(tag = "Observation::stopAndFinish") { "Stopping and finishing observation ${observationType.observationType} for observationIds: $observationIds" } - stop { - timestampCollectionJob?.cancel() - saveAndSend() - observationShutdown(scheduleId) - } - updateObservationErrors() - } - - fun stopAndSetState(state: ScheduleState = ScheduleState.ACTIVE, scheduleId: String?) { - Napier.d(tag = "Observation::stopAndSetState") { "Stopping observation of type ${observationType.observationType} and setting state to $state for schedule $scheduleId." } - stop { - timestampCollectionJob?.cancel() - saveAndSend() - scheduleIds.keys.forEach { scheduleRepository.setRunningStateFor(it, state) } - scheduleId?.let { - observationShutdown(it) - } - } - updateObservationErrors() - } - - fun stopAndSetDone(scheduleId: String) { - Napier.d(tag = "Observation::stopAndSetDone") { "Stopping observation of type ${observationType.observationType} and setting done for schedule $scheduleId." } - stop { - timestampCollectionJob?.cancel() - saveAndSend() - scheduleIds.keys.forEach { scheduleRepository.setCompletionStateFor(it, true) } - observationShutdown(scheduleId) - removeDataCount() - handleNotification(scheduleId) - updateObservationErrors() - } - } - - open fun store(start: Long = -1, end: Long = -1, onCompletion: () -> Unit) { - Napier.d(tag = "Observation::store") { "Storing data for observation of type ${observationType.observationType} with start time: $start, end time: $end." } - dataManager?.store() - onCompletion() - } - - private fun observationShutdown(scheduleId: String) { - val observationId = scheduleIds.remove(scheduleId) - observationId?.let { observationIds.remove(it) } - if (observationIds.isEmpty()) { - config.clear() - configChanged = false - running = false - } - } - - private fun handleNotification(scheduleId: String) { - notificationIds.remove(scheduleId)?.let { - notificationManager?.markNotificationAsRead(it) - } - } - - protected fun showNotification(title: String, notificationBody: String) { - val notification = NotificationSchema.build(title, notificationBody) - Napier.d(tag = "Observation::showNotification") { "Showing notification: $notification" } - notificationManager?.storeAndDisplayNotification(notification, true) - } - - protected fun showObservationErrorNotification( - notificationBody: String, - fallbackTitle: String = "Error" - ) { - val schedulesSchemaFlows = scheduleIds.keys.map { - scheduleRepository.scheduleWithId(it) - } - val combinedFlow = combine(schedulesSchemaFlows) { values -> - values.mapNotNull { it } - } - - StudyScope.launch { - val scheduleSchemas = combinedFlow.first() - val title = - if (scheduleSchemas.isNotEmpty()) scheduleSchemas.map { it.observationTitle } - .joinToString(", ", limit = 5) else fallbackTitle - withContext(Dispatchers.Main) { - showNotification(title, notificationBody) - } - } - } - - protected fun saveAndSend() { - Napier.d(tag = "Observation::finish") { "Saving and sending data for observation of type ${observationType.observationType}." } - dataManager?.saveAndSend() - } - - fun removeDataCount() { - Napier.d(tag = "Observation::removeDataCount") { "Removing data point count for observation of type ${observationType.observationType}." } - scheduleIds.keys.forEach { - dataManager?.removeDataPointCount(it) - } - scheduleIds.clear() - } - - fun isRunning() = running - - companion object { - const val CONFIG_TASK_START = "observation_start_date_time" - const val CONFIG_TASK_STOP = "observation_stop_date_time" - const val SCHEDULE_ID = "schedule_id" - const val CONFIG_LAST_COLLECTION_TIMESTAMP = "observation_last_collection_timestamp" - - const val ERROR_DEVICE_NOT_CONNECTED = "error_device_not_connected" - } -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationDataManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationDataManager.kt deleted file mode 100644 index 20f9d17c1..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationDataManager.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.observations - -import dev.tmapps.konnection.Konnection -import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.repository.DataPointCountRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationDataRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema -import io.redlink.more.more_app_mutliplatform.util.Scope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.firstOrNull - -abstract class ObservationDataManager { - private val observationDataRepository = ObservationDataRepository() - private val dataPointCountRepository = DataPointCountRepository() - private var countJob: Job? = null - - private var scheduleCount = mutableMapOf() - private val konnection = Konnection.instance - - private var uploadJob: Job? = null - - init { - Napier.i(tag = "ObservationDataManager::init") { "ObservationDataManager init!" } - } - - fun add(dataList: List, scheduleIdList: Set) { - if (dataList.isNotEmpty()) { - Napier.i(tag = "ObservationDataManager::add") { "Adding ${dataList.size} observations for schedule IDs: $scheduleIdList" } - observationDataRepository.addData(dataList) - dataPointCountRepository.incrementCount(scheduleIdList, dataList.size.toLong()) - } - } - - fun saveAndSend() { - Napier.i(tag = "ObservationDataManager::saveAndSend") { "Saving and sending observations" } - observationDataRepository.store() - } - - fun store() { - Napier.i(tag = "ObservationDataManager::store") { "Storing observations" } - observationDataRepository.store() - } - - fun removeDataPointCount(scheduleId: String) { - Napier.d(tag = "ObservationDataManager::removeDataPointCount") { "Removing datapoint count for schedule ID: $scheduleId" } - scheduleCount.remove(scheduleId) - } - - abstract fun sendData(onCompletion: (Boolean) -> Unit = {}) - - fun listenToDatapointCountChanges() { - if (countJob == null) { - Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Starting to listen for changes in datapoint counts" } - countJob = Scope.repeatedLaunch(10000) { - if (konnection.isConnected() && (uploadJob == null || uploadJob?.isActive == false)) { - observationDataRepository.count().firstOrNull()?.let { - if (it > 0) { - Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Observation data count: $it! Sending data..." } - uploadJob = Scope.launch(Dispatchers.IO) { - sendData() - }.second - uploadJob?.invokeOnCompletion { - uploadJob = null - } - } - } - } else { - Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "No conncetion" } - uploadJob?.cancel() - } - }.second - countJob?.invokeOnCompletion { - countJob = null - } - } - } - - fun stopListeningToCountChanges() { - Napier.d(tag = "ObservationDataManager::stopListeningToCountChanges") { "Stopped listening for changes in datapoint counts" } - countJob?.cancel() - countJob = null - } - - private fun deleteAll(idSet: Set) { - observationDataRepository.deleteAllWithId(idSet) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt deleted file mode 100644 index d0e74fbfc..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.observations - -import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.extensions.appendAll -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.clear -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.observations.limesurvey.LimeSurveyObservation -import io.redlink.more.more_app_mutliplatform.observations.simpleQuestionObservation.SimpleQuestionObservation -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.services.store.CredentialRepository -import io.redlink.more.more_app_mutliplatform.util.Scope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.update - - -abstract class ObservationFactory(private val dataManager: ObservationDataManager) { - private var credentialRepository: CredentialRepository? = null - val observations = mutableSetOf() - - private val _studyObservationTypes: MutableStateFlow> = MutableStateFlow(emptySet()) - val studyObservationTypes: StateFlow> = _studyObservationTypes - private val _observationErrors: MutableStateFlow>> = - MutableStateFlow(emptyMap()) - val observationErrors: StateFlow>> = _observationErrors - - private var observationErrorWatcher: Job? = null - - init { - observations.add(SimpleQuestionObservation()) - observations.add(LimeSurveyObservation()) - Scope.launch(Dispatchers.IO) { - ObservationRepository().observationTypes().collect { - Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } - _studyObservationTypes.clear() - _studyObservationTypes.appendAll(it) - } - } - Scope.launch(Dispatchers.IO) { - studyObservationTypes.collect { - if (it.isNotEmpty()) { - listenToObservationErrors() - } else { - observationErrorWatcher?.cancel() - observationErrorWatcher = null - _observationErrors.update { emptyMap() } - } - } - } - } - - fun addNeededObservationTypes(observationTypes: Set) { - Napier.i(tag = "ObservationFactory::addNeededObservationTypes") { "Adding observation types to studyObservationTypes: $observationTypes" } - _studyObservationTypes.appendAll(observationTypes) - } - - fun clearNeededObservationTypes() { - _studyObservationTypes.clear() - observationErrorWatcher?.cancel() - observationErrorWatcher = null - _observationErrors.update { emptyMap() } - } - - fun setCredentialsRepository(credentialRepository: CredentialRepository) { - this.credentialRepository = credentialRepository - } - - fun studySensorPermissions() = - observations.filter { it.observationType.observationType in studyObservationTypes.value } - .map { it.observationType.sensorPermissions }.flatten().toSet() - - fun setNotificationManager(notificationManager: NotificationManager) { - observations.forEach { it.setNotificationManager(notificationManager) } - } - - fun observationTypes() = observations.map { it.observationType.observationType }.toSet() - - fun sensorPermissions() = - observations.map { it.observationType.sensorPermissions }.flatten().toSet() - - fun bleDevicesNeeded(): Set { - Napier.i(tag = "ObservationFactory::bleDevicesNeeded") { "Filtering types for BLE: ${studyObservationTypes.value}" } - val bleTypes = - observations.filter { it.observationType.observationType in studyObservationTypes.value } - .flatMap { it.bleDevicesNeeded() }.toSet() - Napier.i(tag = "ObservationFactory::bleDevicesNeeded") { "BLE observation types: $bleTypes" } - return bleTypes - } - - fun autoStartableObservations(): Set { - val autoStartTypes = studyObservations().filter { it.ableToAutomaticallyStart() } - .map { it.observationType.observationType }.toSet() - Napier.i(tag = "ObservationFactory::autoStartableObservations") { "Auto-startable observations: $autoStartTypes" } - return autoStartTypes - } - - private fun listenToObservationErrors() { - val flowList = studyObservations().map { it.observationErrors } - val combinedFlow = combine(flowList) { values -> - values.toMap() - } - observationErrorWatcher?.cancel() - observationErrorWatcher = Scope.launch { - Napier.d(tag = "ObservationFactory::listenToObservationErrors") { "Listening for observation errors" } - combinedFlow.cancellable().collect { - _observationErrors.set(it) - Napier.d(tag = "ObservationFactory::updateObservationErrors") { observationErrors.value.toString() } - } - }.second - } - - fun updateObservationErrors() { - if (this.credentialRepository?.hasCredentials() == true) { - studyObservations().forEach { it.updateObservationErrors() } - } - } - - fun observation(type: String): Observation? { - Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } - return observations.firstOrNull { it.observationType.observationType == type }?.apply { - if (!this.observationDataManagerAdded()) { - Napier.i(tag = "ObservationFactory::observation") { "Adding data manager to observation of type: $type" } - setDataManager(dataManager) - } - } - } - - private fun studyObservations() = - observations.filter { it.observationType.observationType in studyObservationTypes.value } - - fun observationErrorsAsClosure(state: (Map>) -> Unit) = - observationErrors.asClosure(state) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/ObservationType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/ObservationType.kt deleted file mode 100644 index 8048165c6..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/ObservationType.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes - -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema - -open class ObservationType(val observationType: String, val sensorPermissions: Set) { - fun addObservationType(schema: ObservationDataSchema): ObservationDataSchema { - val obsType = observationType - schema.observationType = obsType - return schema - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt deleted file mode 100644 index 247f968e0..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes - -class PolarVerityHeartRateType(sensorPermissions: Set): ObservationType("polar-verity-observation", sensorPermissions) { -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDevice.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDevice.kt deleted file mode 100644 index 2ee7103f9..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDevice.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.bluetooth - -import io.realm.kotlin.types.RealmObject -import io.realm.kotlin.types.annotations.PrimaryKey - -class BluetoothDevice : RealmObject { - @PrimaryKey - var deviceId: String? = null - var deviceName: String? = null - var address: String? = null - override fun toString(): String { - return "BluetoothDevice {deviceId: $deviceId, name: $deviceName, address: $address}" - } - - override fun hashCode(): Int = address?.hashCode() ?: super.hashCode() - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as BluetoothDevice - - return this.address == other.address - } - - companion object { - fun create( - deviceId: String, - deviceName: String, - address: String, - ): BluetoothDevice { - return BluetoothDevice().apply { - this.deviceId = deviceId - this.deviceName = deviceName - this.address = address - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDeviceManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDeviceManager.kt deleted file mode 100644 index 13e34b893..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDeviceManager.kt +++ /dev/null @@ -1,101 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.services.bluetooth - -import io.redlink.more.more_app_mutliplatform.extensions.appendAll -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.clear -import io.redlink.more.more_app_mutliplatform.extensions.removeAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - - -object BluetoothDeviceManager { - private val _connectedDevices: MutableStateFlow> = - MutableStateFlow(emptySet()) - val connectedDevices: StateFlow> = _connectedDevices - private val _discoveredDevices: MutableStateFlow> = - MutableStateFlow(emptySet()) - val discoveredDevices: StateFlow> = _discoveredDevices - private val _pairedDevices: MutableStateFlow> = - MutableStateFlow(emptySet()) - val pairedDevices: StateFlow> = _pairedDevices - private val _devicesCurrentlyConnecting: MutableStateFlow> = - MutableStateFlow( - emptySet() - ) - - val devicesCurrentlyConnecting: StateFlow> = _devicesCurrentlyConnecting - - fun addConnectedDevices(devices: Set) { - _connectedDevices.appendAll(devices) - addPairedDeviceIds(devices.filter { it !in pairedDevices.value }.toSet()) - removeDiscoveredDevices(devices) - removeConnectingDevices(devices) - } - - fun removeConnectedDevices(devices: Set) { - _connectedDevices.removeAll(devices) - _discoveredDevices.removeAll(devices) - } - - fun addDiscoveredDevices(devices: Set) { - _discoveredDevices.appendAll(devices.filter { !connectedDevices.value.contains(it) }) - } - - fun removeDiscoveredDevices(devices: Set) { - _discoveredDevices.removeAll(devices) - } - - fun addPairedDeviceIds(deviceIds: Set) { - _pairedDevices.appendAll(deviceIds) - } - - fun removePairedDeviceIds(deviceIds: Set) { - _pairedDevices.removeAll(deviceIds) - } - - fun addConnectingDevices(devices: Set) { - _devicesCurrentlyConnecting.appendAll(devices.filter { !connectedDevices.value.contains(it) }) - } - - fun removeConnectingDevices(devices: Set) { - _devicesCurrentlyConnecting.removeAll(devices) - } - - fun connectedDevicesAsClosure(state: (Set) -> Unit) = - this.connectedDevices.asClosure(state) - - fun connectedDevicesAsValue(): Set = connectedDevices.value - - fun discoveredDevicesAsClosure(state: (Set) -> Unit) = - this.discoveredDevices.asClosure(state) - - fun pairedDeviceIdsAsClosure(state: (Set) -> Unit) = - this.pairedDevices.asClosure(state) - - fun devicesCurrentlyConnectingAsClosure(state: (Set) -> Unit) = - this.devicesCurrentlyConnecting.asClosure(state) - - fun foreachConnectedDevice(handler: (BluetoothDevice) -> Unit) { - this.connectedDevices.value.forEach(handler) - } - - fun foreachDiscoveredDevice(handler: (BluetoothDevice) -> Unit) { - this.discoveredDevices.value.forEach(handler) - } - - fun resetAll() { - this._discoveredDevices.clear() - } - - fun clearDiscovered() { - this._discoveredDevices.clear() - } - - fun clearConnected() { - this._connectedDevices.clear() - } - - fun clearConnectingDevices() { - this._devicesCurrentlyConnecting.clear() - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt deleted file mode 100644 index 408665bc2..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt +++ /dev/null @@ -1,380 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network - -import io.github.aakira.napier.Napier -import io.ktor.client.HttpClient -import io.ktor.client.plugins.logging.DEFAULT -import io.ktor.client.plugins.logging.Logger -import io.ktor.client.statement.HttpResponse -import io.ktor.utils.io.core.Closeable -import io.redlink.more.app.android.services.network.errors.NetworkServiceError -import io.redlink.more.more_app_mutliplatform.services.network.openapi.api.ConfigurationApi -import io.redlink.more.more_app_mutliplatform.services.network.openapi.api.DataApi -import io.redlink.more.more_app_mutliplatform.services.network.openapi.api.NotificationApi -import io.redlink.more.more_app_mutliplatform.services.network.openapi.api.RegistrationApi -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.AppConfiguration -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Error -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Log -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotification -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotificationServiceType -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotificationToken -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.StudyConsent -import io.redlink.more.more_app_mutliplatform.services.store.CredentialRepository -import io.redlink.more.more_app_mutliplatform.services.store.EndpointRepository -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import kotlinx.coroutines.cancel -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -private const val TAG = "NetworkService" - -class NetworkService( - private val endpointRepository: EndpointRepository, - private val credentialRepository: CredentialRepository, -) : Closeable { - private var httpClient: HttpClient? = null - private var configurationApi: ConfigurationApi? = null - private var dataApi: DataApi? = null - private var notificationApi: NotificationApi? = null - - private var engineUseCounter = 10 - - private fun initConfigApi() { - if (configurationApi == null) { - Napier.i(tag = "NetworkService::initConfigApi") { "Initializing ConfigurationApi..." } - initHttpClient() - credentialRepository.credentials()?.let { credentials -> - httpClient?.let { httpClient -> - val url = endpointRepository.endpoint() - configurationApi = ConfigurationApi(baseUrl = url, - httpClient.engine, - httpClientConfig = { httpClient.engineConfig }) - configurationApi?.setUsername(credentials.apiId) - configurationApi?.setPassword(credentials.apiKey) - } - } - } - } - - private fun initDataApi() { - if (dataApi == null || dataApi == null) { - Napier.i(tag = "NetworkService::initDataApi") { "Init DataAPI..." } - initHttpClient() - credentialRepository.credentials()?.let { credentials -> - httpClient?.let { httpClient -> - val api = DataApi(baseUrl = endpointRepository.endpoint(), - httpClient.engine, - httpClientConfig = { httpClient.engineConfig }) - api.setUsername(credentials.apiId) - api.setPassword(credentials.apiKey) - dataApi = api - } - } - } - } - - private fun initNotificationApi() { - if (notificationApi == null) { - Napier.i(tag = "NetworkService::initNotificationApi") { "Init Notification API..." } - initHttpClient() - credentialRepository.credentials()?.let { credentials -> - httpClient?.let { httpClient -> - val api = NotificationApi(endpointRepository.endpoint(), - httpClient.engine, - httpClientConfig = { httpClient.engineConfig }) - api.setUsername(credentials.apiId) - api.setPassword(credentials.apiKey) - notificationApi = api - } - } - } - } - - private fun initLoggingApi() { - initHttpClient() - } - - private fun initHttpClient() { - if (httpClient == null) { - httpClient = getHttpClient(Logger.DEFAULT) - } - } - - suspend fun deleteParticipation(): Pair { - try { - credentialRepository.credentials()?.let { - Napier.i(tag = "NetworkService::deleteParticipation") { "Deleting Participation..." } - val httpClient = getHttpClient() - val url = endpointRepository.endpoint() - val registrationApi = - RegistrationApi(baseUrl = url, httpClientEngine = httpClient.engine) - - registrationApi.setUsername(it.apiId) - registrationApi.setPassword(it.apiKey) - - val registrationResponse = registrationApi.unregisterFromStudy() ?: return Pair( - false, NetworkServiceError(0, "Response null") - ) - Napier.i(registrationResponse.response.toString(), tag = TAG) - close() - if (registrationResponse.success) { - Napier.i(tag = "NetworkService::deleteParticipation") { "Participation deleted!" } - return Pair(true, null) - } - Napier.e(tag = "NetworkService::deleteParticipation") { "Error; Code: ${registrationResponse.response.status.value}" } - val error = createErrorBody( - registrationResponse.response.status.value, registrationResponse.response - ) - return Pair( - false, error - ) - } - return Pair(false, NetworkServiceError(null, "No credentials")) - } catch (err: Exception) { - Napier.e(tag = "NetworkService::deleteParticipation") { err.stackTraceToString() } - return Pair(false, getException(err)) - } - } - - suspend fun validateRegistrationToken( - registrationToken: String, endpoint: String? = null - ): Pair { - try { - Napier.i(tag = "NetworkService::validateRegistrationToken") { "Validating Registration token..." } - val httpClient = getHttpClient() - val url = endpoint ?: endpointRepository.endpoint() - val registrationApi = - RegistrationApi(baseUrl = url, httpClientEngine = httpClient.engine) - val registrationResponse = - registrationApi.getStudyRegistrationInfo(moreRegistrationToken = registrationToken) - ?: return Pair(null, NetworkServiceError(0, "Response null")) - Napier.i(registrationResponse.response.toString(), tag = TAG) - if (registrationResponse.success) { - registrationResponse.body().let { - Napier.i(tag = "NetworkService::validateRegistrationToken") { "Registration token valid!" } - return Pair(it, null) - } - } - val error = createErrorBody( - registrationResponse.response.status.value, registrationResponse.response - ) - return Pair( - null, error - ) - - } catch (err: Exception) { - Napier.e(tag = "NetworkService::validateRegistrationToken") { err.stackTraceToString() } - return Pair(null, getException(err)) - } - } - - suspend fun sendConsent( - registrationToken: String, studyConsent: StudyConsent, endpoint: String? = null - ): Pair { - try { - Napier.i(tag = "NetworkService::sendConsent") { "Sending Consent..." } - val httpClient = getHttpClient() - val url = endpoint ?: endpointRepository.endpoint() - val registrationApi = RegistrationApi(baseUrl = url, - httpClient.engine, - httpClientConfig = { httpClient.engineConfig }) - val consentResponse = - registrationApi.registerForStudy(registrationToken, studyConsent) ?: return Pair( - null, NetworkServiceError(0, "Response null") - ) - if (consentResponse.success) { - consentResponse.body().let { - Napier.i(tag = "NetworkService::sendConsent") { "Credentials received!" } - return Pair(it, null) - } - } - return Pair( - null, - createErrorBody(consentResponse.response.status.value, consentResponse.response) - ) - } catch (e: Exception) { - Napier.e(tag = "NetworkService::sendConsent") { e.stackTraceToString() } - return Pair(null, getException(e)) - } - } - - suspend fun getStudyConfig(): Pair { - initConfigApi() - try { - Napier.i(tag = "NetworkService::getStudyConfig") { "Downloading study data..." } - val configResponse = configurationApi?.getStudyConfiguration() ?: return Pair( - null, NetworkServiceError(null, "No credentials set!") - ) - if (configResponse.success) { - configResponse.body().let { - Napier.i(tag = "NetworkService::getStudyConfig") { "Loading study data success!" } - return Pair(it, null) - } - } - - return Pair( - null, createErrorBody(configResponse.response.status.value, configResponse.response) - ) - } catch (e: Exception) { - Napier.e(tag = "NetworkService::getStudyConfig") { e.stackTraceToString() } - return Pair(null, getException(e)) - } - } - - suspend fun sendNotificationToken(token: String): Pair { - initConfigApi() - configurationApi?.let { - try { - Napier.i(tag = "NetworkService::sendNotificationToken") { "Sending notification token..." } - val tokenResponse = it.setPushNotificationToken( - serviceType = PushNotificationServiceType.FCM, - pushNotificationToken = PushNotificationToken(token = token) - ) ?: return Pair(false, NetworkServiceError(0, "Response null")) - if (tokenResponse.success) { - Napier.i(tag = "NetworkService::sendNotificationToken") { "Uploading notification token success!" } - return Pair(true, null) - } - return Pair( - false, createErrorBody(tokenResponse.status, tokenResponse.response) - ) - } catch (err: Exception) { - Napier.e(tag = "NetworkService::sendNotificationToken") { err.stackTraceToString() } - return Pair(false, getException(err)) - } - } - return Pair(false, getException(Exception("No credentials found!"))) - } - - suspend fun sendData(data: DataBulk): Pair, NetworkServiceError?> { - initDataApi() - try { - Napier.i(tag = "NetworkService::sendData") { "Sending bulk ${data.bulkId} with ${data.dataPoints.size} datapoints with first being ${data.dataPoints.first()}..." } - val dataApiResponse = dataApi?.storeBulk(data) ?: return Pair( - emptySet(), NetworkServiceError(null, "No credentials set!") - ) - if (dataApiResponse.success) { - dataApiResponse.body().let { - Napier.i(tag = "NetworkService::sendData") { "Sent data!" } - dataApiResponse.response.cancel() - return Pair(it.toSet() ?: emptySet(), null) - } - } - dataApiResponse.response.cancel() - - return Pair( - emptySet(), createErrorBody( - dataApiResponse.response.status.value, dataApiResponse.response - ) - ) - } catch (e: Exception) { - Napier.e(tag = "NetworkService::sendData") { e.stackTraceToString() } - return Pair(emptySet(), getException(e)) - } - } - - fun iosSendData( - data: DataBulk, - completionHandler: (Pair, NetworkServiceError?>) -> Unit - ) { - StudyScope.launch { - completionHandler( - sendData(data) - ) - } - } - - suspend fun downloadMissedNotifications(): List { - initNotificationApi() - val list = notificationApi?.let { notificationApi -> - try { - Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloading missed notifications from the Server..." } - notificationApi.listPushNotifications()?.let { response -> - if (response.success) { - response.body() - } else { - Napier.d(tag = "NetworkService::downloadMissedNotifications") { "No notifications received from the server" } - emptyList() - } - } ?: kotlin.run { - Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Notification Response Null" } - emptyList() - } - } catch (e: Exception) { - Napier.e(tag = "NetworkService::downloadMissedNotifications") { "Notification List error: $e" } - return emptyList() - } - } ?: emptyList() - Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloaded Messages list: $list" } - return list - } - - suspend fun deletePushNotification(msgId: String) { - initNotificationApi() - notificationApi?.let { notificationApi -> - try { - notificationApi.deleteNotification(msgId)?.let { httpResponse -> - if (httpResponse.success) { - Napier.d(tag = "NetworkService::deletePushNotification") { "Successfully deleted notification with id: $msgId" } - } else { - Napier.d(tag = "NetworkService::deletePushNotification") { "Push notification not found with msgID: $msgId. Could not delete!" } - } - } - } catch (e: Exception) { - Napier.e(tag = "NetworkService::deletePushNotification") { "Notification deletion error: $e" } - } - } - } - - private fun createErrorBody(code: Int, responseBody: HttpResponse?): NetworkServiceError { - return try { - if (responseBody == null) { - return NetworkServiceError(code = code, message = "Error") - } - val error = Json.decodeFromString( - responseBody.toString() - ) - NetworkServiceError(code = code, message = error.msg ?: "Error") - } catch (e: Exception) { - getException(e) - } - } - - private fun getException(exception: Exception): NetworkServiceError { - val errorResponse = when (exception) { - else -> "System error!" - } - Napier.e("Exception: ${exception.stackTraceToString()}", tag = TAG) - exception.printStackTrace() - return NetworkServiceError(null, errorResponse) - } - - override fun close() { - Napier.d(tag = "NetworkService::close") { "Clearing the Http engine..." } - engineUseCounter = 10 - configurationApi = null - dataApi = null - httpClient?.close() - httpClient = null - } - - private fun serializeToNDJson(logs: List): String { - return buildString { - logs.forEach { log -> - appendLine("{\"index\":{}}") - appendLine(Json.encodeToString(log)) - } - } - } -} - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/RegistrationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/RegistrationService.kt deleted file mode 100644 index 7c701bff2..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/RegistrationService.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network - -import io.github.aakira.napier.Napier -import io.redlink.more.app.android.services.network.errors.NetworkServiceError -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.DatabaseManager -import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepository -import io.redlink.more.more_app_mutliplatform.getPlatform -import io.redlink.more.more_app_mutliplatform.models.CredentialModel -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.ObservationConsent -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.StudyConsent -import io.redlink.more.more_app_mutliplatform.services.store.EndpointRepository -import io.redlink.more.more_app_mutliplatform.util.StudyScope - -class RegistrationService( - private val shared: Shared -) { - var study: Study? = null - private set - - var participationToken: String? = null - private var endpoint: String? = null - - fun getEndpointRepository(): EndpointRepository = shared.endpointRepository - - fun sendRegistrationToken( - token: String, - manualEndpoint: String? = null, - onSuccess: (Study) -> Unit, - onError: ((NetworkServiceError?) -> Unit), - onFinish: () -> Unit - ) { - if (token.isNotEmpty()) { - StudyScope.launch { - val (result, networkError) = shared.networkService.validateRegistrationToken( - token.uppercase(), - manualEndpoint - ) - result?.let { - endpoint = manualEndpoint - study = it - participationToken = token - addObservationPermissions(it) - onSuccess(it) - } - networkError?.let { - onError(networkError) - } - onFinish() - } - } - } - - fun acceptConsent( - consentInfoMd5: String, - uniqueDeviceId: String, - onSuccess: (Boolean) -> Unit, - onError: ((NetworkServiceError?) -> Unit), - onFinish: () -> Unit - ) { - StudyScope.launch { - shared.credentialRepository.remove() - DatabaseManager.deleteAll() - } - study?.let { study -> - participationToken?.let { token -> - val studyConsent = StudyConsent( - consent = true, - observations = study.observations.map { - ObservationConsent(observationId = it.observationId, active = true) - }, - consentInfoMD5 = consentInfoMd5, - deviceId = "${getPlatform().productName}#$uniqueDeviceId" - ) - sendConsent(token, studyConsent, endpoint, onSuccess, onError, onFinish) - } - } - } - - fun declineConsent() { - shared.endpointRepository.removeEndpoint() - } - - private fun sendConsent( - token: String, - studyConsent: StudyConsent, - endpoint: String? = null, - onSuccess: (Boolean) -> Unit, - onError: ((NetworkServiceError?) -> Unit), - onFinish: () -> Unit - ) { - StudyScope.launch { - val (config, networkError) = shared.networkService.sendConsent( - token, - studyConsent, - endpoint - ) - if (config != null) { - config.endpoint?.let { - shared.endpointRepository.storeEndpoint(it) - } - val credentialModel = - CredentialModel(config.credentials.apiId, config.credentials.apiKey) - if (shared.credentialRepository.store(credentialModel) && shared.credentialRepository.hasCredentials()) { - - val (study, error) = shared.networkService.getStudyConfig() - if (error != null) { - Napier.e { error.message } - onError(NetworkServiceError(null, "Could not get study: ${error.message}")) - } else { - study?.let { study -> - shared.observationFactory.clearNeededObservationTypes() - StudyRepository().storeStudy(study) - shared.resetFirstStartUp() - onSuccess(shared.credentialRepository.hasCredentials()) - } ?: run { - onError(NetworkServiceError(null, "Could not get study")) - } - } - } else { - onError(NetworkServiceError(null, "Could not store credentials")) - } - } - networkError?.let { - onError(it) - } - onFinish() - } - } - - private fun addObservationPermissions(study: Study) { - shared.observationFactory - .addNeededObservationTypes(study.observations.map { it.observationType }.toSet()) - } - - - fun reset() { - study = null - participationToken = null - } - -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/ConfigurationApi.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/ConfigurationApi.kt deleted file mode 100644 index e89461d72..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/ConfigurationApi.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.api - -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.ApiClient -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.HttpResponse -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestConfig -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestMethod -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.map -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.wrap -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotificationConfig -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotificationServiceType -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotificationToken -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer - -open class ConfigurationApi( - baseUrl: String = ApiClient.BASE_URL, - httpClientEngine: HttpClientEngine? = null, - httpClientConfig: ((HttpClientConfig<*>) -> Unit)? = null, - jsonSerializer: Json = ApiClient.JSON_DEFAULT -) : ApiClient(baseUrl, httpClientEngine, httpClientConfig, jsonSerializer) { - - /** - * - * retrieve the client-configuration for the push-notification service - * @param serviceType - * @return PushNotificationConfig - */ - @Suppress("UNCHECKED_CAST") - open suspend fun getPushNotificationServiceClientConfig(serviceType: PushNotificationServiceType): HttpResponse? { - - val localVariableAuthNames = listOf("apiKey") - - val localVariableBody = - io.ktor.client.utils.EmptyContent - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.GET, - "/config/notifications/{serviceType}".replace("{" + "serviceType" + "}", "$serviceType"), - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true, - ) - - return request( - localVariableConfig, - localVariableBody, - localVariableAuthNames - )?.wrap() - } - - - /** - * - * (re)load the study configuration - * @return Study - */ - @Suppress("UNCHECKED_CAST") - open suspend fun getStudyConfiguration(): HttpResponse? { - - val localVariableAuthNames = listOf("apiKey") - - val localVariableBody = - io.ktor.client.utils.EmptyContent - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.GET, - "/config/study", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true, - ) - - return request( - localVariableConfig, - localVariableBody, - localVariableAuthNames - )?.wrap() - } - - - /** - * - * list available push-notification services - * @return kotlin.collections.List - */ - @Suppress("UNCHECKED_CAST") - open suspend fun listPushNotificationServices(): HttpResponse>? { - - val localVariableAuthNames = listOf("apiKey") - - val localVariableBody = - io.ktor.client.utils.EmptyContent - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.GET, - "/config/notifications", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true, - ) - - return request( - localVariableConfig, - localVariableBody, - localVariableAuthNames - )?.wrap()?.map { value } - } - - @Serializable - private class ListPushNotificationServicesResponse(val value: List) { - @Serializer(ListPushNotificationServicesResponse::class) - companion object : KSerializer { - private val serializer: KSerializer> = serializer>() - override val descriptor = serializer.descriptor - override fun serialize(encoder: Encoder, obj: ListPushNotificationServicesResponse) = serializer.serialize(encoder, obj.value) - override fun deserialize(decoder: Decoder) = ListPushNotificationServicesResponse( - serializer.deserialize(decoder)) - } - } - - /** - * - * store the client's push-notification token - * @param serviceType - * @param pushNotificationToken (optional) - * @return void - */ - open suspend fun setPushNotificationToken(serviceType: PushNotificationServiceType, pushNotificationToken: PushNotificationToken? = null): HttpResponse? { - - val localVariableAuthNames = listOf("apiKey") - - val localVariableBody = pushNotificationToken - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.PUT, - "/config/notifications/{serviceType}".replace("{" + "serviceType" + "}", "$serviceType"), - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true, - ) - - var jsonRequest = jsonRequest( - localVariableConfig, - localVariableBody, - localVariableAuthNames - ) - return jsonRequest?.wrap() - } -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/DataApi.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/DataApi.kt deleted file mode 100644 index fbb07fb4f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/DataApi.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.api - -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.ApiClient -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.HttpResponse -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestConfig -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestMethod -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.map -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.wrap -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer - -open class DataApi( - baseUrl: String = ApiClient.BASE_URL, - httpClientEngine: HttpClientEngine? = null, - httpClientConfig: ((HttpClientConfig<*>) -> Unit)? = null, - jsonSerializer: Json = ApiClient.JSON_DEFAULT -) : ApiClient(baseUrl, httpClientEngine, httpClientConfig, jsonSerializer) { - - /** - * - * add data to elastic shard - * @param dataBulk (optional) - * @return kotlin.collections.List - */ - open suspend fun storeBulk(dataBulk: DataBulk? = null): HttpResponse>? { - - val localVariableAuthNames = listOf("apiKey") - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.POST, - "/data/bulk", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true, - ) - - return jsonRequest( - localVariableConfig, - dataBulk, - localVariableAuthNames - )?.wrap()?.map { value } - } - - - @Serializable - private class StoreBulkResponse(val value: List) { - @OptIn(ExperimentalSerializationApi::class) - @Serializer(StoreBulkResponse::class) - companion object : KSerializer { - private val serializer: KSerializer> = - serializer>() - override val descriptor = serializer.descriptor - override fun serialize(encoder: Encoder, obj: StoreBulkResponse) = - serializer.serialize(encoder, obj.value) - - override fun deserialize(decoder: Decoder) = - StoreBulkResponse(serializer.deserialize(decoder)) - } - } - -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/NotificationApi.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/NotificationApi.kt deleted file mode 100644 index 0bc77510b..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/NotificationApi.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.api - -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.ApiClient -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.HttpResponse -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestConfig -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestMethod -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.wrap -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.PushNotification -import kotlinx.serialization.json.Json - -open class NotificationApi( - baseUrl: String = BASE_URL, - httpClientEngine: HttpClientEngine? = null, - httpClientConfig: ((HttpClientConfig<*>) -> Unit)? = null, - jsonSerializer: Json = JSON_DEFAULT -) : ApiClient(baseUrl, httpClientEngine, httpClientConfig, jsonSerializer) { - open suspend fun listPushNotifications(): HttpResponse>? { - val localVariableAuthNames = listOf("apiKey") - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.GET, - "/notifications", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true - ) - - return jsonRequest( - localVariableConfig, - null, - localVariableAuthNames - )?.wrap() - } - - open suspend fun deleteNotification(msgId: String): HttpResponse? { - val localVariableAuthNames = listOf("apiKey") - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.DELETE, - "/notifications/$msgId", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true - ) - - return jsonRequest( - localVariableConfig, - null, - localVariableAuthNames - )?.wrap() - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/RegistrationApi.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/RegistrationApi.kt deleted file mode 100644 index 20ee605e4..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/RegistrationApi.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.api - -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.ApiClient -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.HttpResponse -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestConfig -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.RequestMethod -import io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure.wrap -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.AppConfiguration -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.StudyConsent -import kotlinx.serialization.json.Json - -open class RegistrationApi( - baseUrl: String = ApiClient.BASE_URL, - httpClientEngine: HttpClientEngine? = null, - httpClientConfig: ((HttpClientConfig<*>) -> Unit)? = null, - jsonSerializer: Json = ApiClient.JSON_DEFAULT -) : ApiClient(baseUrl, httpClientEngine, httpClientConfig, jsonSerializer) { - - /** - * - * Provide the information on a study required to register and consent. - * @param moreRegistrationToken The token to register for a study - * @return Study - */ - @Suppress("UNCHECKED_CAST") - open suspend fun getStudyRegistrationInfo(moreRegistrationToken: kotlin.String): HttpResponse? { - - val localVariableAuthNames = listOf() - - val localVariableBody = - io.ktor.client.utils.EmptyContent - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - moreRegistrationToken?.apply { localVariableHeaders["More-Registration-Token"] = this } - - val localVariableConfig = RequestConfig( - RequestMethod.GET, - "/registration", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = false, - ) - - return request( - localVariableConfig, - localVariableBody, - localVariableAuthNames - )?.wrap() - } - - - /** - * - * Perform the Registration to the Study and express the users consent. - * @param moreRegistrationToken The token to register for a study - * @param studyConsent - * @return AppConfiguration - */ - @Suppress("UNCHECKED_CAST") - open suspend fun registerForStudy(moreRegistrationToken: kotlin.String, studyConsent: StudyConsent): HttpResponse? { - - val localVariableAuthNames = listOf() - - val localVariableBody = studyConsent - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - moreRegistrationToken?.apply { localVariableHeaders["More-Registration-Token"] = this.toString() } - - val localVariableConfig = RequestConfig( - RequestMethod.POST, - "/registration", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = false, - ) - - return jsonRequest( - localVariableConfig, - localVariableBody, - localVariableAuthNames - )?.wrap() - } - - - - /** - * - * Leave study / Withdraw Consent - * @return void - */ - open suspend fun unregisterFromStudy(): HttpResponse? { - - val localVariableAuthNames = listOf("apiKey") - - val localVariableBody = - io.ktor.client.utils.EmptyContent - - val localVariableQuery = mutableMapOf>() - val localVariableHeaders = mutableMapOf() - - val localVariableConfig = RequestConfig( - RequestMethod.DELETE, - "/registration", - query = localVariableQuery, - headers = localVariableHeaders, - requiresAuthentication = true, - ) - - return request( - localVariableConfig, - localVariableBody, - localVariableAuthNames - )?.wrap() - } -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/HttpBasicAuth.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/HttpBasicAuth.kt deleted file mode 100644 index d8292cc6d..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/HttpBasicAuth.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.auth - -import io.ktor.util.InternalAPI -import io.ktor.util.encodeBase64 - -class HttpBasicAuth : Authentication { - var username: String? = null - var password: String? = null - - @OptIn(InternalAPI::class) - override fun apply(query: MutableMap>, headers: MutableMap) { - if (username == null && password == null) return - val str = (username ?: "") + ":" + (password ?: "") - val auth = str.encodeBase64() - headers["Authorization"] = "Basic $auth" - } -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiAbstractions.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiAbstractions.kt deleted file mode 100644 index 61cb2387b..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiAbstractions.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -typealias MultiValueMap = MutableMap> - -fun collectionDelimiter(collectionFormat: String) = when(collectionFormat) { - "csv" -> "," - "tsv" -> "\t" - "pipe" -> "|" - "space" -> " " - else -> "" -} - -val defaultMultiValueConverter: (item: Any?) -> String = { item -> "$item" } - -fun toMultiValue(items: Array, collectionFormat: String, map: (item: T) -> String = defaultMultiValueConverter) - = toMultiValue(items.asIterable(), collectionFormat, map) - -fun toMultiValue(items: Iterable, collectionFormat: String, map: (item: T) -> String = defaultMultiValueConverter): List { - return when(collectionFormat) { - "multi" -> items.map(map) - else -> listOf(items.joinToString(separator = collectionDelimiter(collectionFormat), transform = map)) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiClient.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiClient.kt deleted file mode 100644 index 892ff0f4f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiClient.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.forms.FormDataContent -import io.ktor.client.request.forms.MultiPartFormDataContent -import io.ktor.client.request.header -import io.ktor.client.request.parameter -import io.ktor.client.request.request -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.Parameters -import io.ktor.http.URLBuilder -import io.ktor.http.content.PartData -import io.ktor.http.contentType -import io.ktor.http.encodeURLQueryComponent -import io.ktor.http.encodedPath -import io.ktor.http.takeFrom -import io.ktor.serialization.kotlinx.json.json -import io.redlink.more.more_app_mutliplatform.services.network.openapi.auth.Authentication -import io.redlink.more.more_app_mutliplatform.services.network.openapi.auth.HttpBasicAuth -import kotlinx.serialization.json.Json - -open class ApiClient( - private val baseUrl: String, - httpClientEngine: HttpClientEngine?, - httpClientConfig: ((HttpClientConfig<*>) -> Unit)? = null, - private val jsonBlock: Json -) { - - private val clientConfig: (HttpClientConfig<*>) -> Unit by lazy { - { - it.install(ContentNegotiation) { json(jsonBlock) } - httpClientConfig?.invoke(it) - } - } - - private val client: HttpClient by lazy { - httpClientEngine?.let { HttpClient(it, clientConfig) } ?: HttpClient(clientConfig) - } - - private val authentications: kotlin.collections.Map by lazy { - mapOf( - "apiKey" to HttpBasicAuth() - ) - } - - companion object { - const val BASE_URL = "/api/v1" - val JSON_DEFAULT = Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - } - protected val UNSAFE_HEADERS = listOf(HttpHeaders.ContentType) - } - - /** - * Set the username for the first HTTP basic authentication. - * - * @param username Username - */ - fun setUsername(username: String) { - val auth = authentications?.values?.firstOrNull { it is HttpBasicAuth } as HttpBasicAuth? - ?: throw Exception("No HTTP basic authentication configured") - auth.username = username - } - - /** - * Set the password for the first HTTP basic authentication. - * - * @param password Password - */ - fun setPassword(password: String) { - val auth = authentications?.values?.firstOrNull { it is HttpBasicAuth } as HttpBasicAuth? - ?: throw Exception("No HTTP basic authentication configured") - auth.password = password - } - - protected suspend fun multipartFormRequest(requestConfig: RequestConfig, body: List?, authNames: List): HttpResponse? { - return request(requestConfig, MultiPartFormDataContent(body ?: listOf()), authNames) - } - - protected suspend fun urlEncodedFormRequest(requestConfig: RequestConfig, body: Parameters?, authNames: List): HttpResponse? { - return request(requestConfig, FormDataContent(body ?: Parameters.Empty), authNames) - } - - protected suspend fun jsonRequest(requestConfig: RequestConfig, body: Any? = null, authNames: List): HttpResponse? = request(requestConfig, body, authNames) - - protected suspend fun request(requestConfig: RequestConfig, body: Any? = null, authNames: List): HttpResponse? { - requestConfig.updateForAuth(authNames) - val headers = requestConfig.headers - return client.request { - this.url { - this.takeFrom(URLBuilder(baseUrl)) - appendPath(requestConfig.path.trimStart('/').split('/')) - requestConfig.query.forEach { query -> - query.value.forEach { value -> - parameter(query.key, value) - } - } - } - this.method = requestConfig.method.httpMethod - headers.filter { header -> !UNSAFE_HEADERS.contains(header.key) }.forEach { header -> this.header(header.key, header.value) } - if (requestConfig.method in listOf( - RequestMethod.PUT, - RequestMethod.POST - )){ - this.contentType(ContentType.Application.Json) - } - if (requestConfig.method in listOf( - RequestMethod.PUT, - RequestMethod.POST, - RequestMethod.PATCH - )) { - this.setBody(body) - } - } - } - - private fun RequestConfig.updateForAuth(authNames: List) { - for (authName in authNames) { - val auth = authentications[authName] ?: throw Exception("Authentication undefined: $authName") - auth.apply(query, headers) - } - } - - private fun URLBuilder.appendPath(components: List): URLBuilder = apply { - encodedPath = encodedPath.trimEnd('/') + components.joinToString("/", prefix = "/") { it.encodeURLQueryComponent() } - } - - private val RequestMethod.httpMethod: HttpMethod - get() = when (this) { - RequestMethod.DELETE -> HttpMethod.Delete - RequestMethod.GET -> HttpMethod.Get - RequestMethod.HEAD -> HttpMethod.Head - RequestMethod.PATCH -> HttpMethod.Patch - RequestMethod.PUT -> HttpMethod.Put - RequestMethod.POST -> HttpMethod.Post - RequestMethod.OPTIONS -> HttpMethod.Options - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Base64ByteArrayNew.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Base64ByteArrayNew.kt deleted file mode 100644 index a0417241d..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Base64ByteArrayNew.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -@Serializable -class Base64ByteArrayNew(val value: ByteArray) { - @Serializer(Base64ByteArrayNew::class) - companion object : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("Base64ByteArray", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, obj: Base64ByteArrayNew) = encoder.encodeString(obj.value.encodeBase64()) - override fun deserialize(decoder: Decoder) = Base64ByteArrayNew(decoder.decodeString().decodeBase64Bytes()) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - other as Base64ByteArrayNew - return value.contentEquals(other.value) - } - - override fun hashCode(): Int { - return value.contentHashCode() - } - - override fun toString(): String { - return "Base64ByteArray(${hex(value)})" - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Bytes.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Bytes.kt deleted file mode 100644 index 00f93edda..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Bytes.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -import io.ktor.utils.io.core.ByteReadPacket -import io.ktor.utils.io.core.Input -import io.ktor.utils.io.core.buildPacket -import io.ktor.utils.io.core.readAvailable -import io.ktor.utils.io.core.readBytes -import io.ktor.utils.io.core.writeFully -import kotlin.experimental.and - -private val digits = "0123456789abcdef".toCharArray() -private const val BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" -private const val BASE64_MASK: Byte = 0x3f -private const val BASE64_PAD = '=' -private val BASE64_INVERSE_ALPHABET = IntArray(256) { BASE64_ALPHABET.indexOf(it.toChar()) } - -private fun String.toCharArray(): CharArray = CharArray(length) { get(it) } -private fun ByteArray.clearFrom(from: Int) = (from until size).forEach { this[it] = 0 } -private fun Int.toBase64(): Char = BASE64_ALPHABET[this] -private fun Byte.fromBase64(): Byte = BASE64_INVERSE_ALPHABET[toInt() and 0xff].toByte() and BASE64_MASK -internal fun ByteArray.encodeBase64(): String = buildPacket { writeFully(this@encodeBase64) }.encodeBase64() -internal fun String.decodeBase64Bytes(): ByteArray = buildPacket { dropLastWhile { it == BASE64_PAD } }.decodeBase64Bytes().readBytes() - -/** - * Encode [bytes] as a HEX string with no spaces, newlines and `0x` prefixes. - * - * Taken from https://github.com/ktorio/ktor/blob/master/ktor-utils/common/src/io/ktor/util/Crypto.kt - */ -internal fun hex(bytes: ByteArray): String { - val result = CharArray(bytes.size * 2) - var resultIndex = 0 - val digits = digits - - for (element in bytes) { - val b = element.toInt() and 0xff - result[resultIndex++] = digits[b shr 4] - result[resultIndex++] = digits[b and 0x0f] - } - - return result.concatToString() -} - -/** - * Decode bytes from HEX string. It should be no spaces and `0x` prefixes. - * - * Taken from https://github.com/ktorio/ktor/blob/master/ktor-utils/common/src/io/ktor/util/Crypto.kt - */ -internal fun hex(s: String): ByteArray { - val result = ByteArray(s.length / 2) - for (idx in result.indices) { - val srcIdx = idx * 2 - val high = s[srcIdx].toString().toInt(16) shl 4 - val low = s[srcIdx + 1].toString().toInt(16) - result[idx] = (high or low).toByte() - } - - return result -} - -/** - * Encode [ByteReadPacket] in base64 format. - * - * Taken from https://github.com/ktorio/ktor/blob/424d1d2cfaa3281302c60af9500f738c8c2fc846/ktor-utils/common/src/io/ktor/util/Base64.kt - */ -private fun ByteReadPacket.encodeBase64(): String = buildString { - val data = ByteArray(3) - while (remaining > 0) { - val read = readAvailable(data) - data.clearFrom(read) - - val padSize = (data.size - read) * 8 / 6 - val chunk = ((data[0].toInt() and 0xFF) shl 16) or - ((data[1].toInt() and 0xFF) shl 8) or - (data[2].toInt() and 0xFF) - - for (index in data.size downTo padSize) { - val char = (chunk shr (6 * index)) and BASE64_MASK.toInt() - append(char.toBase64()) - } - - repeat(padSize) { append(BASE64_PAD) } - } -} - -/** - * Decode [ByteReadPacket] from base64 format - * - * Taken from https://github.com/ktorio/ktor/blob/424d1d2cfaa3281302c60af9500f738c8c2fc846/ktor-utils/common/src/io/ktor/util/Base64.kt - */ -private fun ByteReadPacket.decodeBase64Bytes(): Input = buildPacket { - val data = ByteArray(4) - - while (remaining > 0) { - val read = readAvailable(data) - - val chunk = data.foldIndexed(0) { index, result, current -> - result or (current.fromBase64().toInt() shl ((3 - index) * 6)) - } - - for (index in data.size - 2 downTo (data.size - read)) { - val origin = (chunk shr (8 * index)) and 0xff - writeByte(origin.toByte()) - } - } -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/HttpResponse.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/HttpResponse.kt deleted file mode 100644 index 73c791aeb..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/HttpResponse.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -import io.ktor.http.Headers -import io.ktor.http.isSuccess -import io.ktor.util.reflect.TypeInfo -import io.ktor.util.reflect.typeInfo - -open class HttpResponse(val response: io.ktor.client.statement.HttpResponse, val provider: BodyProvider) { - val status: Int = response.status.value - val success: Boolean = response.status.isSuccess() - val headers: Map> = response.headers.mapEntries() - suspend fun body() = provider.body(response) - suspend fun typedBody(type: TypeInfo): V = provider.typedBody(response, type) - - companion object { - private fun Headers.mapEntries(): Map> { - val result = mutableMapOf>() - entries().forEach { result[it.key] = it.value } - return result - } - } -} - -interface BodyProvider { - suspend fun body(response: io.ktor.client.statement.HttpResponse): T - suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V -} - -class TypedBodyProvider(private val type: TypeInfo) : BodyProvider { - @Suppress("UNCHECKED_CAST") - override suspend fun body(response: io.ktor.client.statement.HttpResponse): T = - response.call.body(type) as T - - @Suppress("UNCHECKED_CAST") - override suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V = - response.call.body(type) as V -} - -class MappedBodyProvider(private val provider: BodyProvider, private val block: S.() -> T) : BodyProvider { - override suspend fun body(response: io.ktor.client.statement.HttpResponse): T = - block(provider.body(response)) - - override suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V = - provider.typedBody(response, type) -} - -inline fun io.ktor.client.statement.HttpResponse.wrap(): HttpResponse = - HttpResponse(this, TypedBodyProvider(typeInfo())) - -fun HttpResponse.map(block: T.() -> V): HttpResponse = - HttpResponse(response, MappedBodyProvider(provider, block)) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/OctetByteArray.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/OctetByteArray.kt deleted file mode 100644 index 4940eed03..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/OctetByteArray.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -@Serializable -class OctetByteArray(val value: ByteArray) { - @Serializer(OctetByteArray::class) - companion object : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("OctetByteArray", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, obj: OctetByteArray) = encoder.encodeString(hex(obj.value)) - override fun deserialize(decoder: Decoder) = OctetByteArray(hex(decoder.decodeString())) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - other as OctetByteArray - return value.contentEquals(other.value) - } - - override fun hashCode(): Int { - return value.contentHashCode() - } - - override fun toString(): String { - return "OctetByteArray(${hex(value)})" - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/PartConfig.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/PartConfig.kt deleted file mode 100644 index 33e973983..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/PartConfig.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -/** - * Defines a config object for a given part of a multi-part request. - * NOTE: Headers is a Map because rfc2616 defines - * multi-valued headers as csv-only. - */ -data class PartConfig( - val headers: MutableMap = mutableMapOf(), - val body: T? = null -) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestConfig.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestConfig.kt deleted file mode 100644 index 5df0babb6..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestConfig.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -/** - * Defines a config object for a given request. - * NOTE: This object doesn't include 'body' because it - * allows for caching of the constructed object - * for many request definitions. - * NOTE: Headers is a Map because rfc2616 defines - * multi-valued headers as csv-only. - */ -data class RequestConfig( - val method: RequestMethod, - val path: String, - val headers: MutableMap = mutableMapOf(), - val query: MutableMap> = mutableMapOf(), - val requiresAuthentication: Boolean, - val body: T? = null -) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestMethod.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestMethod.kt deleted file mode 100644 index a0064fd42..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestMethod.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.infrastructure - -/** - * Provides enumerated HTTP verbs - */ -enum class RequestMethod { - GET, DELETE, HEAD, OPTIONS, PATCH, POST, PUT -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ApiKey.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ApiKey.kt deleted file mode 100644 index 3742de8fd..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ApiKey.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Credentials for the App for interacting with the backends - * - * @param apiId - * @param apiKey - */ -@Serializable - -data class ApiKey ( - - @SerialName(value = "apiId") @Required val apiId: kotlin.String, - - @SerialName(value = "apiKey") @Required val apiKey: kotlin.String - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/AppConfiguration.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/AppConfiguration.kt deleted file mode 100644 index 7e2566425..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/AppConfiguration.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * The configuration settings for the App while participating on a study - * - * @param credentials - * @param endpoint base-uri of the App-API to use during the runtime of the study. If omitted, the client should stay with the current endpoint. - */ -@Serializable - -data class AppConfiguration ( - - @SerialName(value = "credentials") @Required val credentials: ApiKey, - - /* base-uri of the App-API to use during the runtime of the study. If omitted, the client should stay with the current endpoint. */ - @SerialName(value = "endpoint") val endpoint: kotlin.String? = null - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/DataBulk.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/DataBulk.kt deleted file mode 100644 index b26d87675..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/DataBulk.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * A bulk of observation data containing a unique id, the API Key of the participant and the array of observation data - * - * @param bulkId - * @param dataPoints - */ -@Serializable - -data class DataBulk ( - - @SerialName(value = "bulkId") @Required val bulkId: kotlin.String, - - @SerialName(value = "dataPoints") @Required val dataPoints: kotlin.collections.List - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Error.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Error.kt deleted file mode 100644 index 22c31ab79..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Error.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Generic Error - * - * @param code - * @param msg - */ -@Serializable - -data class Error ( - - @SerialName(value = "code") val code: kotlin.String? = null, - - @SerialName(value = "msg") val msg: kotlin.String? = null - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfig.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfig.kt deleted file mode 100644 index d44e274c8..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfig.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * configuration-settings for Firebase Cloud Messaging - * - * @param service - * @param projectId The Google Cloud project ID - * @param applicationId The Google App ID that is used to uniquely identify an instance of an app. - * @param apiKey - * @param databaseUrl - * @param gcmSenderId The Project Number from the Google Developer's console - * @param storageBucket - */ -@Serializable - -data class FcmNotificationConfig ( - - @SerialName(value = "service") @Required override val service: PushNotificationServiceType, - - /* The Google Cloud project ID */ - @SerialName(value = "projectId") val projectId: kotlin.String? = null, - - /* The Google App ID that is used to uniquely identify an instance of an app. */ - @SerialName(value = "applicationId") val applicationId: kotlin.String? = null, - - @SerialName(value = "apiKey") val apiKey: kotlin.String? = null, - - @SerialName(value = "databaseUrl") val databaseUrl: kotlin.String? = null, - - /* The Project Number from the Google Developer's console */ - @SerialName(value = "gcmSenderId") val gcmSenderId: kotlin.String? = null, - - @SerialName(value = "storageBucket") val storageBucket: kotlin.String? = null - -) : PushNotificationConfig - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfigAllOf.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfigAllOf.kt deleted file mode 100644 index 0e0a90f9b..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfigAllOf.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * - * - * @param projectId The Google Cloud project ID - * @param applicationId The Google App ID that is used to uniquely identify an instance of an app. - * @param apiKey - * @param databaseUrl - * @param gcmSenderId The Project Number from the Google Developer's console - * @param storageBucket - */ -@Serializable - -data class FcmNotificationConfigAllOf ( - - /* The Google Cloud project ID */ - @SerialName(value = "projectId") val projectId: kotlin.String? = null, - - /* The Google App ID that is used to uniquely identify an instance of an app. */ - @SerialName(value = "applicationId") val applicationId: kotlin.String? = null, - - @SerialName(value = "apiKey") val apiKey: kotlin.String? = null, - - @SerialName(value = "databaseUrl") val databaseUrl: kotlin.String? = null, - - /* The Project Number from the Google Developer's console */ - @SerialName(value = "gcmSenderId") val gcmSenderId: kotlin.String? = null, - - @SerialName(value = "storageBucket") val storageBucket: kotlin.String? = null - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Log.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Log.kt deleted file mode 100644 index 05f96c876..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Log.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import io.github.aakira.napier.LogLevel -import io.redlink.more.more_app_mutliplatform.Platform -import io.redlink.more.more_app_mutliplatform.getPlatform -import kotlinx.datetime.Clock -import kotlinx.serialization.Serializable - -@Serializable -data class Log( - val priority: LogLevel, - val message: String?, - val user: User? = null, - val tag: String? = null, - val throwable: LogThrowable? = null, -){ - val timestamp: String = Clock.System.now().toString() - val platform: Platform = getPlatform() -} - -@Serializable -data class User( - val userId: Int?, - val alias: String? -) - -@Serializable -data class LogThrowable( - val cause: LogThrowable?, - val message: String? -) { - companion object { - fun fromSystemThrowable(throwable: Throwable?): LogThrowable { - return LogThrowable(null, throwable?.message) - } - } -} - -fun Throwable.transformForLog() = LogThrowable.fromSystemThrowable(this) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Observation.kt deleted file mode 100644 index b2c77df71..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Observation.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject - -/** - * The configuration of an observation for the study. - * - * @param observationId - * @param observationType - * @param observationTitle - * @param participantInfo - * @param schedule - * @param required - * @param version A version indicator. Currently the last-modified date in EPOCH-format but that's not guaranteed. - * @param configuration - * @param hidden - */ -@Serializable - -data class Observation ( - - @SerialName(value = "observationId") @Required val observationId: kotlin.String, - - @SerialName(value = "observationType") @Required val observationType: kotlin.String, - - @SerialName(value = "observationTitle") @Required val observationTitle: kotlin.String, - - @SerialName(value = "participantInfo") @Required val participantInfo: kotlin.String, - - @SerialName(value = "schedule") @Required val schedule: kotlin.collections.List, - - @SerialName(value = "noSchedule") var noSchedule: kotlin.Boolean = false, - - @SerialName(value = "required") @Required val required: kotlin.Boolean = true, - - /* A version indicator. Currently the last-modified date in EPOCH-format but that's not guaranteed. */ - @SerialName(value = "version") @Required val version: kotlin.Long, - - @SerialName(value = "configuration") val configuration: JsonObject? = null, - - @SerialName(value = "hidden") val hidden: kotlin.Boolean? = false - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationConsent.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationConsent.kt deleted file mode 100644 index 5ba230ca9..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationConsent.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * - * - * @param observationId - * @param active - */ -@Serializable - -data class ObservationConsent ( - - @SerialName(value = "observationId") @Required val observationId: kotlin.String, - - @SerialName(value = "active") @Required val active: kotlin.Boolean = true - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationData.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationData.kt deleted file mode 100644 index 4361bb850..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationData.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import kotlinx.datetime.Instant -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject - -/** - * - * - * @param dataId - * @param observationId - * @param observationType - * @param dataValue - * @param timestamp - */ -@Serializable - -data class ObservationData ( - - @SerialName(value = "dataId") @Required val dataId: kotlin.String, - - @SerialName(value = "observationId") @Required val observationId: kotlin.String, - - @SerialName(value = "observationType") @Required val observationType: kotlin.String, - - @SerialName(value = "dataValue") @Required val dataValue: JsonObject? = null, - - @SerialName(value = "timestamp") @Required val timestamp: Instant - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationSchedule.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationSchedule.kt deleted file mode 100644 index 5e192f803..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationSchedule.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.datetime.Instant -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * - * - * @param start - * @param end - */ -@Serializable - -data class ObservationSchedule ( - - @SerialName(value = "start") val start: Instant? = null, - - @SerialName(value = "end") val end: Instant? = null - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Participant.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Participant.kt deleted file mode 100644 index 7c65d26ac..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Participant.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * The study contact object containing all contact information for the study, that can be used when problems occure. - * - * @param alias States the alias name given on the study manager frontend. - * @param id States the ID of the participant. - */ -@Serializable - -data class Participant ( - - @SerialName(value = "alias") val alias: kotlin.String? = null, - - @SerialName(value = "id") val id: kotlin.Int? = null, - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotification.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotification.kt deleted file mode 100644 index 61e2dea9d..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotification.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import kotlinx.datetime.Instant -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject - -@Serializable -data class PushNotification( - @SerialName(value = "type") @Required val type: String, - @SerialName(value = "msgId") @Required val msgId: String, - @SerialName(value = "title") @Required val title: String? = null, - @SerialName(value = "body") @Required val body: String? = null, - @SerialName(value = "data") @Required val data: JsonObject? = null, - @SerialName(value = "deepLink") val deepLink: String? = null, - @SerialName(value = "timestamp") @Required val timestamp: Instant? = null -) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationServiceType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationServiceType.kt deleted file mode 100644 index 737107aa0..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationServiceType.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * available services for push-notification - * - * Values: FCM - */ -@Serializable -enum class PushNotificationServiceType(val value: kotlin.String) { - - @SerialName(value = "FCM") - FCM("FCM"); - - /** - * Override toString() to avoid using the enum variable name as the value, and instead use - * the actual value defined in the API spec file. - * - * This solves a problem when the variable name and its value are different, and ensures that - * the client sends the correct enum values to the server always. - */ - override fun toString(): String = value - - companion object { - /** - * Converts the provided [data] to a [String] on success, null otherwise. - */ - fun encode(data: kotlin.Any?): kotlin.String? = if (data is PushNotificationServiceType) "$data" else null - - /** - * Returns a valid [PushNotificationServiceType] for [data], null otherwise. - */ - fun decode(data: kotlin.Any?): PushNotificationServiceType? = data?.let { - val normalizedData = "$it".lowercase() - values().firstOrNull { value -> - it == value || normalizedData == "$value".lowercase() - } - } - } -} - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationToken.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationToken.kt deleted file mode 100644 index 25d943fa5..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationToken.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * - * - * @param token - */ -@Serializable - -data class PushNotificationToken ( - - @SerialName(value = "token") val token: kotlin.String? = null - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Study.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Study.kt deleted file mode 100644 index c02a0dd74..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Study.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.datetime.LocalDate -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * The study object containing all information and observation information to configure and initialize the APP - * - * @param studyTitle - * @param participantInfo - * @param consentInfo - * @param start - * @param end - * @param observations - * @param contact - * @param version A version indicator. Currently the last-modified date in EPOCH-format but that's not guaranteed. - * @param active The current study-state. Mainly used during the registration process. - * @param finishText Finish message, when the study is set to completed. - */ -@Serializable - -data class Study( - - @SerialName(value = "studyTitle") @Required val studyTitle: String, - - @SerialName(value = "participantInfo") @Required val participantInfo: String, - - @SerialName(value = "participant") val participant: Participant? = Participant(), - - @SerialName(value = "consentInfo") @Required val consentInfo: String, - - @SerialName(value = "start") @Required val start: LocalDate, - - @SerialName(value = "end") @Required val end: LocalDate, - - @SerialName(value = "observations") @Required val observations: List, - - @SerialName(value = "contact") val contact: StudyContact? = StudyContact(), - - /* A version indicator. Currently the last-modified date in EPOCH-format but that's not guaranteed. */ - @SerialName(value = "version") @Required val version: Long, - - /* The current study-state. Mainly used during the registration process. */ - @SerialName(value = "active") val active: Boolean? = true, - - @SerialName(value = "studyState") val studyState: String? = if (active == true) "active" else "passive", - - @SerialName(value = "finishText") val finishText: String? = null -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyConsent.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyConsent.kt deleted file mode 100644 index 4a68c4c12..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyConsent.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Confirms the participants consent to the study including supported observations on the device. - * - * @param consent Explicitly state the consent of the Participant - * @param deviceId Identifier of the device used to provide consent - * @param consentInfoMD5 MD5-Hash of the `consentInfo` (text) the participant actually gave consent. - * @param observations - */ -@Serializable - -data class StudyConsent ( - - /* Explicitly state the consent of the Participant */ - @SerialName(value = "consent") @Required val consent: kotlin.Boolean = false, - - /* Identifier of the device used to provide consent */ - @SerialName(value = "deviceId") @Required val deviceId: kotlin.String, - - /* MD5-Hash of the `consentInfo` (text) the participant actually gave consent. */ - @SerialName(value = "consentInfoMD5") @Required val consentInfoMD5: kotlin.String, - - @SerialName(value = "observations") @Required val observations: kotlin.collections.List - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyContact.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyContact.kt deleted file mode 100644 index 07da56278..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyContact.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -@file:Suppress( - "ArrayInDataClass", - "EnumEntryName", - "RemoveRedundantQualifierName", - "UnusedImport" -) - -package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * The study contact object containing all contact information for the study, that can be used when problems occure. - * - * @param institute States the institute, that handles the study. - * @param person States the person a participant can contact, when problems occure. Is required. - * @param email States the contact email address, that can be written to. Is required. - * @param phoneNumber States the contact phone number to contact, if added. - */ -@Serializable - -data class StudyContact ( - - @SerialName(value = "institute") val institute: kotlin.String? = null, - - @SerialName(value = "person") val person: kotlin.String? = null, - - @SerialName(value = "email") val email: kotlin.String? = null, - - @SerialName(value = "phoneNumber") val phoneNumber: kotlin.String? = null, - -) - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationActionHandler.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationActionHandler.kt deleted file mode 100644 index cd7a2f9ee..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationActionHandler.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.services.notification - -enum class NotificationActionHandler { - DEEPLINK -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt deleted file mode 100644 index 5cbe9821c..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.notification - -import io.github.aakira.napier.Napier -import io.realm.kotlin.ext.toRealmDictionary -import io.realm.kotlin.types.RealmDictionary -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.repository.NotificationRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.NotificationSchema -import io.redlink.more.more_app_mutliplatform.models.NotificationModel -import io.redlink.more.more_app_mutliplatform.models.StudyState -import io.redlink.more.more_app_mutliplatform.navigation.DeeplinkManager -import io.redlink.more.more_app_mutliplatform.services.network.NetworkService -import io.redlink.more.more_app_mutliplatform.services.store.SharedStorageRepository -import io.redlink.more.more_app_mutliplatform.util.Scope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext - -interface LocalNotificationListener { - fun displayNotification(notification: NotificationSchema) - - fun deleteNotificationFromSystem(notificationId: String) - - fun createNewFCMToken(onCompletion: (String) -> Unit) - fun clearNotifications() - fun deleteFCMToken() - fun updateBadgeCount(count: Int = 0) -} - -class NotificationManager( - private val localNotificationListener: LocalNotificationListener, - private val networkService: NetworkService, - private val deeplinkManager: DeeplinkManager, - private val sharedStorageRepository: SharedStorageRepository -) { - val notificationRepository = NotificationRepository() - val _unreadUserCount = MutableStateFlow(0) - val unreadUserCount: StateFlow = _unreadUserCount - - init { - Scope.launch(Dispatchers.IO) { - notificationRepository.getUnreadUserNotifications().collect { notificationList -> - _unreadUserCount.update { notificationList.count() } - withContext(Dispatchers.Main) { - localNotificationListener.updateBadgeCount(notificationList.count()) - } - } - } - } - - fun storeAndHandleNotification( - shared: Shared, - key: String, - title: String?, - body: String?, - priority: Long = 1, - read: Boolean = false, - data: Map? = null, - displayNotification: Boolean - ) { - storeAndHandleNotification( - shared, - NotificationSchema.toSchema( - notificationId = key, - channelId = null, - title = title, - notificationBody = body, - priority = priority, - read = read, - userFacing = title != null, - notificationData = data - ), - displayNotification - ) - } - - fun storeAndHandleNotification( - shared: Shared, - notification: NotificationSchema, - displayNotification: Boolean - ) { - storeAndDisplayNotification(notification, displayNotification) - if (notification.notificationData.isNotEmpty()) { - handleNotificationDataAsync( - shared, - notification.notificationData - ) - } - } - - fun storeAndDisplayNotification( - notification: NotificationSchema, - displayNotification: Boolean - ) { - if (notification.title != null && notification.notificationBody != null) { - notificationRepository.storeNotification(notification) - if (displayNotification) { - Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } - localNotificationListener.displayNotification(notification) - } - } - } - - fun storeNotifications(notifications: List) { - notificationRepository.storeNotifications(notifications) - } - - fun downloadMissedNotifications() { - Scope.launch { - Napier.d { "Updating notifications" } - storeNotifications(NotificationSchema.toSchemaList(networkService.downloadMissedNotifications())) - } - } - - fun deleteNotificationFromRepository(notificationId: String) { - deleteNotificationFromSystemTray(notificationId) - notificationRepository.deleteNotification(notificationId) - } - - fun deleteNotificationFromServer(msgID: String) { - Napier.i { "Deleting notification with msgID $msgID from server..." } - Scope.launch { - networkService.deletePushNotification(msgID) - } - } - - fun deleteNotificationFromSystemTray(notificationId: String) { - localNotificationListener.deleteNotificationFromSystem(notificationId = notificationId) - } - - fun markNotificationAsRead(notificationId: String) { - notificationRepository.setNotificationReadStatus(notificationId, true) - deleteNotificationFromSystemTray(notificationId) - } - - fun handleNotificationDataAsync(shared: Shared, data: Map) { - Scope.launch { - handleNotificationData( - shared, - data.toRealmDictionary() - ) - } - } - - suspend fun handleNotificationData( - shared: Shared, - data: RealmDictionary - ) { - if (data.isNotEmpty()) { - if (data[MAIN_DATA_KEY] == STUDY_CHANGED) { - updateStudy(shared, data) - } - data[MSG_ID]?.let { - deleteNotificationFromServer(it) - } - } - } - - fun handleNotificationInteraction( - notificationId: String, - deeplink: String? = null - ) { - if (deeplink == null || deeplink.contains(DeeplinkManager.TASK_DETAILS) || deeplink.contains( - DeeplinkManager.OBSERVATION_DETAILS - ) - ) { - markNotificationAsRead(notificationId) - } - } - - fun handleNotificationInteraction( - notification: NotificationModel, - protocolReplacement: String? = null, - hostReplacement: String? = null, - handler: ((NotificationActionHandler, String) -> Unit) - ) { - notification.deepLink?.let { deepLink -> - Scope.launch { - deeplinkManager.modifyDeepLink(deepLink, protocolReplacement, hostReplacement) - .firstOrNull()?.let { modifiedDeepLink -> - if (modifiedDeepLink.contains(DeeplinkManager.TASK_DETAILS) || modifiedDeepLink.contains( - DeeplinkManager.OBSERVATION_DETAILS - ) - ) { - withContext(Dispatchers.Main) { - markNotificationAsRead(notification.notificationId) - } - } - withContext(Dispatchers.Main) { - handler(NotificationActionHandler.DEEPLINK, modifiedDeepLink) - } - } ?: run { - withContext(Dispatchers.Main) { - markNotificationAsRead(notification.notificationId) - } - } - } - } ?: run { - markNotificationAsRead(notification.notificationId) - } - } - - fun newFCMToken(token: String? = null) { - sharedStorageRepository.remove(FCM_TOKEN_UPLOADED) - token?.let { storeAndUploadToken(it) } - ?: run { - localNotificationListener.createNewFCMToken { storeAndUploadToken(it) } - } - } - - private fun storeAndUploadToken(newToken: String) { - Scope.launch(Dispatchers.IO) { - val (successful, _) = networkService.sendNotificationToken(newToken) - sharedStorageRepository.store(FCM_TOKEN_UPLOADED, successful) - } - } - - fun deleteFCMToken() { - sharedStorageRepository.remove(FCM_TOKEN_UPLOADED) - localNotificationListener.deleteFCMToken() - } - - fun createNewFCMIfNecessary() { - if (!sharedStorageRepository.load(FCM_TOKEN_UPLOADED, false)) { - newFCMToken() - } - } - - fun clearAllNotifications() { - localNotificationListener.clearNotifications() - } - - fun updateNotificationBadgeCount() { - Scope.launch { - notificationRepository.getUnreadUserNotifications().firstOrNull().let { - withContext(Dispatchers.Main) { - localNotificationListener.updateBadgeCount(it?.count() ?: 0) - } - } - } - } - - private suspend fun updateStudy(shared: Shared, data: Map) { - val oldStudyState = - data[STUDY_OLD_STATE]?.let { StudyState.getState(it) } - val newStudyState = - data[STUDY_NEW_STATE]?.let { StudyState.getState(it) } - shared.updateStudy(oldStudyState, newStudyState) - } - - companion object { - const val FCM_TOKEN = "FCM_TOKEN" - - private const val MAIN_DATA_KEY = "key" - private const val STUDY_CHANGED = "STUDY_STATE_CHANGED" - private const val STUDY_OLD_STATE = "oldState" - private const val STUDY_NEW_STATE = "newState" - - const val FCM_TOKEN_UPLOADED = "FCM_TOKEN_UPLOADED" - - const val DEEP_LINK = "deepLink" - const val MSG_ID = "MSG_ID" - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/StudyStateRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/StudyStateRepository.kt deleted file mode 100644 index 52d9830fb..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/StudyStateRepository.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.store - -import io.redlink.more.more_app_mutliplatform.models.StudyState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.datetime.Clock - -class StudyStateRepository(private val sharedStorageRepository: SharedStorageRepository) { - private var _currentStudyState: MutableStateFlow = MutableStateFlow(StudyState.NONE) - val currentStudyState: StateFlow = _currentStudyState - - private var _lastUpdateTime: MutableStateFlow = MutableStateFlow(null) - val lastUpdateTime: StateFlow = _lastUpdateTime - - init { - _currentStudyState.value = loadState() - _lastUpdateTime.value = loadUpdateTime() - } - - fun storeState(studyState: StudyState) { - sharedStorageRepository.store(STUDY_STATE_KEY, studyState.descr) - val currentTime = Clock.System.now().epochSeconds - sharedStorageRepository.store(LAST_UPDATE_TIME_KEY, currentTime.toString()) - _currentStudyState.value = studyState - _lastUpdateTime.value = currentTime - } - - fun studyWasUpdatedBefore(epochSeconds: Long): Boolean = - (lastUpdateTime.value ?: 0) <= epochSeconds - - private fun loadState(): StudyState { - return StudyState.getState(sharedStorageRepository.load(STUDY_STATE_KEY, "none")) - } - - private fun loadUpdateTime(): Long? { - val storedValue = sharedStorageRepository.load(LAST_UPDATE_TIME_KEY, "") - return if (storedValue.isNotEmpty()) { - storedValue.toLongOrNull() - } else { - null - } - } - - companion object { - private const val STUDY_STATE_KEY = "studyStateKey" - private const val LAST_UPDATE_TIME_KEY = "lastUpdateTimeKey" - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/Scope.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/Scope.kt deleted file mode 100644 index c5d2e5c0f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/Scope.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.util - -import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.extensions.repeatEveryFewSeconds -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.CoroutineContext - -object Scope { - private val mutex = Mutex() - private val rootJob = SupervisorJob() - private val exceptionHandler = CoroutineExceptionHandler { _, exception -> - Napier.e(throwable = exception, message = "Caught $exception in CoroutineExceptionHandler") - } - private val scope = CoroutineScope(rootJob + Dispatchers.Default + exceptionHandler) - private val jobs = mutableMapOf() - - fun launch( - coroutineContext: CoroutineContext = Dispatchers.Default, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit - ): Pair { - val uuid = createUUID() - val job = scope.launch(coroutineContext + exceptionHandler, start, block) - scope.launch { - mutex.withLock { - jobs[uuid] = job - job.invokeOnCompletion { - scope.launch { - mutex.withLock { - try { - jobs.remove(uuid) - } catch (e: Exception) { - if (e !is CancellationException) { - Napier.e(tag = "Scope::launch::invokeOnCompletion") { e.stackTraceToString() } - } - } - } - } - } - } - } - return Pair(uuid, job) - } - - fun create(): Pair { - val job = Job(rootJob) - val uuid = createUUID() - job.invokeOnCompletion { - scope.launch { - mutex.withLock { - try { - it?.let { - Napier.w(throwable = it) { "Coroutine with UUID: $uuid was completed or threw!" } - } - jobs.remove(uuid) - } catch (e: Exception) { - Napier.e { e.stackTraceToString() } - } - } - } - } - scope.launch { - mutex.withLock { - jobs[uuid] = job - } - } - return Pair(uuid, job) - } - - fun isActive(uuid: String) = jobs[uuid]?.isActive ?: false - - fun repeatedLaunch(intervalMillis: Long, block: suspend CoroutineScope.() -> Unit): Pair { - val uuid = createUUID() - val job = scope.repeatEveryFewSeconds(intervalMillis, block) - scope.launch { - mutex.withLock { - jobs[uuid] = job - job.invokeOnCompletion { - scope.launch { - mutex.withLock { - try { - it?.let { - Napier.e(throwable = it) { "Coroutine with UUID: $uuid has thrown!" } - } - jobs.remove(uuid) - } catch (e: Exception) { - Napier.e { e.stackTraceToString() } - } - } - } - } - } - } - return Pair(uuid, job) - } - - fun cancel(uuid: String) { - scope.launch { - mutex.withLock { - jobs[uuid]?.cancel() - } - } - } - - fun cancel(uuids: Collection) { - val set = uuids.toSet() - scope.launch { - mutex.withLock { - val jobsToCancel = jobs.filter { it.key in set }.toList() - try { - jobsToCancel.forEach { it.second.cancel() } - } catch (exception: Exception) { - Napier.e { exception.stackTraceToString() } - } - } - } - } - - fun cancel() { - scope.launch { - mutex.withLock { - rootJob.cancelChildren() - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StudyScope.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StudyScope.kt deleted file mode 100644 index 0b774bc15..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StudyScope.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.util - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.CoroutineContext - -object StudyScope { - private val mutex = Mutex() - private val jobs = mutableSetOf() - - fun launch( - coroutineContext: CoroutineContext = Dispatchers.Default, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit - ): Pair { - val result = Scope.launch(coroutineContext, start, block) - Scope.launch { - mutex.withLock { - jobs.add(result.first) - - } - } - result.second.invokeOnCompletion { - Scope.launch { - mutex.withLock { - jobs.remove(result.first) - } - } - } - return result - } - - fun repeatedLaunch( - intervalMillis: Long, - block: suspend CoroutineScope.() -> Unit - ): Pair { - val result = Scope.repeatedLaunch(intervalMillis, block) - Scope.launch { - mutex.withLock { - jobs.add(result.first) - } - } - result.second.invokeOnCompletion { - Scope.launch { - mutex.withLock { - jobs.remove(result.first) - } - } - } - return result - } - - fun cancel(uuid: String) { - Scope.cancel(uuid) - } - - fun cancel(uuids: Collection) { - Scope.cancel(uuids) - } - - fun cancel() { - Scope.cancel(this.jobs) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt deleted file mode 100644 index babd29000..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels - -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.util.Scope -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.CoroutineContext - -abstract class CoreViewModel : Closeable { - private val mutex = Mutex() - private var viewJobs = mutableSetOf() - - abstract fun viewDidAppear() - - open fun viewDidDisappear() { - cancelScope() - } - - fun launchScope( - coroutineContext: CoroutineContext = Dispatchers.Default, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit - ) { - val result = Scope.launch(coroutineContext, start, block) - Scope.launch { - mutex.withLock { - viewJobs.add(result.first) - } - } - result.second.invokeOnCompletion { - Scope.launch { - mutex.withLock { - viewJobs.remove(result.first) - } - } - } - } - - private fun cancelScope() { - Scope.launch { - mutex.withLock { - val jobsToCancel = viewJobs.toSet() - viewJobs.clear() - Scope.cancel(jobsToCancel) - } - } - } - - override fun close() { - cancelScope() - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt deleted file mode 100644 index 6cbfc6efb..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.viewModels - -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.set -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update - -object ViewManager { - private val _studyIsUpdating = MutableStateFlow(false) - private val _showBluetoothView = MutableStateFlow(false) - val studyIsUpdating: StateFlow = _studyIsUpdating - - val showBluetoothView: StateFlow = _showBluetoothView - - private var bleViewOpen = false - - - fun studyIsUpdating(state: Boolean) { - _studyIsUpdating.set(state) - } - - fun showBLEView(state: Boolean): Boolean { - if (!state || !bleViewOpen) { - _showBluetoothView.update { state } - return state - } - return false - } - - fun bleViewOpen(state: Boolean) { - bleViewOpen = state - } - - fun showBluetoothViewAsClosure(state: (Boolean) -> Unit) = showBluetoothView.asClosure(state) - - fun studyIsUpdatingAsClosure(state: (Boolean) -> Unit) = studyIsUpdating.asClosure(state) - - fun resetAll() { - _studyIsUpdating.set(false) - _showBluetoothView.set(false) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt deleted file mode 100644 index e30323791..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection - -import io.github.aakira.napier.Napier -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.repository.BluetoothDeviceRepository -import io.redlink.more.more_app_mutliplatform.extensions.anyNameIn -import io.redlink.more.more_app_mutliplatform.extensions.areAllNamesIn -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnector -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnectorObserver -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothState -import io.redlink.more.more_app_mutliplatform.util.Scope -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull - -class BluetoothController( - private val bluetoothConnector: BluetoothConnector, - private val scanDuration: Long = 10000, - private val scanInterval: Long = 5000 -) : CoreViewModel(), BluetoothConnectorObserver, Closeable { - private val deviceManager = BluetoothDeviceManager - private val bluetoothDeviceRepository = BluetoothDeviceRepository() - - private val _isScanning = MutableStateFlow(false) - val isScanning: StateFlow = _isScanning - - private var backgroundScanningEnabled = false - private var viewActive = false - - private var scanJob: String? = null - - val bluetoothPower = MutableStateFlow(BluetoothState.ON) - - private var bleViewHasBeenOpened = false - - init { - bluetoothConnector.addObserver(this) - bluetoothConnector.replayStates() - } - - fun observerDeviceAccessible(bleDevices: Set): Boolean { - val pairedDevices = deviceManager.pairedDevices.value - val connectedDevices = deviceManager.connectedDevices.value; - if (bleDevices.anyNameIn(pairedDevices)) { - if (bleDevices.anyNameIn(connectedDevices)) { - disableBackgroundScanner() - return true - } else { - enableBackgroundScanner() - } - } else { - if (!bleViewHasBeenOpened) { - if (ViewManager.showBLEView(true)) { - bleViewHasBeenOpened = true - } - } - } - return false - } - - fun startScanningForDevices(bleDeviceSet: Set) { - if (bleDeviceSet.isNotEmpty()) { - val pairedDevices = deviceManager.pairedDevices.value - if (bleDeviceSet.areAllNamesIn(pairedDevices)) { - val connectedDevices = deviceManager.connectedDevices.value - if (bleDeviceSet.areAllNamesIn(connectedDevices)) { - disableBackgroundScanner() - } else { - enableBackgroundScanner() - } - } else { - if (!bleViewHasBeenOpened) { - if (ViewManager.showBLEView(true)) { - bleViewHasBeenOpened = true - } - } - } - } - } - - private fun enableBackgroundScanner() { - if (!backgroundScanningEnabled) { - backgroundScanningEnabled = true - StudyScope.launch { - if (!viewActive && bluetoothDeviceRepository.pairedDevices().firstOrNull() - ?.isNotEmpty() == true - ) { - delay(2000) - periodicScan(BACKGROUND_SCAN_DURATION, BACKGROUND_SCAN_INTERVAL) - } - } - } - } - - private fun disableBackgroundScanner() { - backgroundScanningEnabled = false - if (!viewActive) { - stopPeriodicScan() - } - } - - override fun viewDidAppear() { - viewActive = true - bleViewHasBeenOpened = true - if (backgroundScanningEnabled) { - stopPeriodicScan() - } - periodicScan() - } - - private fun periodicScan( - customScanDuration: Long = scanDuration, - customScanInterval: Long = scanInterval - ) { - launchScope { - bluetoothPower.collect { - if (it == BluetoothState.ON) { - startPeriodicScan(customScanDuration, customScanInterval) - } else { - stopPeriodicScan() - } - } - } - } - - override fun viewDidDisappear() { - super.viewDidDisappear() - viewActive = false - scanJob?.let { StudyScope.cancel(it) } - scanJob = null - stopPeriodicScan() - if (backgroundScanningEnabled) { - StudyScope.launch { - delay(10000L) - if (backgroundScanningEnabled) { - periodicScan(BACKGROUND_SCAN_DURATION, BACKGROUND_SCAN_INTERVAL) - } - } - } else { - deviceManager.clearDiscovered() - } - } - - private fun startPeriodicScan( - customScanDuration: Long = scanDuration, - customScanInterval: Long = scanInterval - ) { - if (scanJob == null) { - Napier.i(tag = "BluetoothController::startPeriodicScan") { "Starting period scanner with Duration= $customScanDuration; Interval= $customScanInterval" } - scanJob = StudyScope.repeatedLaunch(customScanInterval) { - Napier.i(tag = "BluetoothController::startPeriodicScan") { "Scanning..." } - scanForDevices() - delay(customScanDuration) - Napier.i(tag = "BluetoothController::startPeriodicScan") { "Stop Scanning..." } - stopScanning() - }.first - } - } - - private fun stopPeriodicScan() { - Napier.i(tag = "BluetoothController::stopPeriodicScan") { "Stopping period scanner!" } - scanJob?.let { StudyScope.cancel(it) } - scanJob = null - bluetoothConnector.stopScanning() - } - - private fun scanForDevices() { - bluetoothConnector.scan() - } - - fun stopScanning() { - bluetoothConnector.stopScanning() - } - - fun connectToDevice(device: BluetoothDevice): Boolean { - if (!deviceManager.connectedDevices.value.contains(device)) { - Napier.i(tag = "BluetoothController::connectToDevice") { "Connecting to $device" } - deviceManager.addConnectingDevices(setOf(device)) - return bluetoothConnector.connect(device) == null - } - return true - } - - fun unpairFromDevice(device: BluetoothDevice) { - Napier.i(tag = "BluetoothController::disconnectFromDevice") { "Disconnecting from $device" } - bluetoothConnector.disconnect(device) - bluetoothDeviceRepository.unpairDevice(device) - deviceManager.removePairedDeviceIds(setOf(device)) - } - - override fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) { - deviceManager.addConnectingDevices(setOf(bluetoothDevice)) - } - - override fun didConnectToDevice(bluetoothDevice: BluetoothDevice) { - deviceManager.addConnectedDevices(setOf(bluetoothDevice)) - bluetoothDeviceRepository.storePairedDevice(bluetoothDevice) - } - - override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { - Napier.i(tag = "BluetoothController::didDisconnectFromDevice") { "Disconnected from $bluetoothDevice" } - deviceManager.removeConnectedDevices(setOf(bluetoothDevice)) - } - - override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { - Napier.e(tag = "BluetoothController::didFailToConnectToDevice") { "Failed to connect to $bluetoothDevice" } - deviceManager.removeConnectingDevices(setOf(bluetoothDevice)) - } - - override fun onBluetoothStateChange(bluetoothState: BluetoothState) { - Napier.i(tag = "BluetoothController::onBluetoothStateChange") { "Bluetooth state changed to $bluetoothState" } - bluetoothPower.set(bluetoothState) - if (bluetoothState == BluetoothState.OFF) { - deviceManager.clearDiscovered() - deviceManager.clearConnected() - deviceManager.clearConnectingDevices() - } - } - - override fun didDiscoverDevice(device: BluetoothDevice) { - if (!deviceManager.connectedDevices.value.contains(device)) { - Napier.i(tag = "BluetoothController::didDiscoverDevice") { "Discovered device: $device" } - deviceManager.addDiscoveredDevices(setOf(device)) - if (deviceManager.pairedDevices.value.contains(device)) { - connectToDevice(device) - } - } - } - - override fun removeDiscoveredDevice(device: BluetoothDevice) { - Napier.i(tag = "BluetoothController::removeDiscoveredDevice") { "Removed discovered device: $device" } - deviceManager.removeDiscoveredDevices(setOf(device)) - } - - override fun isScanning(boolean: Boolean) { - Napier.i(tag = "BluetoothController::isScanning") { "Scanning status changed to: $boolean" } - this._isScanning.set(boolean) - } - - - fun bluetoothStateAsClosure(providedState: (BluetoothState) -> Unit) = - bluetoothPower.asClosure(providedState) - - fun isScanningAsClosure(state: (Boolean) -> Unit) = isScanning.asClosure(state) - - suspend fun listenToConnectionChanges( - observationFactory: ObservationFactory - ) { - deviceManager.connectedDevices.collect { - observationFactory.updateObservationErrors() - } - } - - fun resetAll() { - Napier.i(tag = "BluetoothController::resetAll") { "Resetting Bluetooth data!" } - stopPeriodicScan() - close() - _isScanning.set(false) - backgroundScanningEnabled = false - viewActive = false - deviceManager.resetAll() - scanJob = null - } - - override fun close() { - scanJob?.let { Scope.cancel(it) } - scanJob = null - bluetoothConnector.stopScanning() - } - - companion object { - private const val BACKGROUND_SCAN_DURATION = 2000L - private const val BACKGROUND_SCAN_INTERVAL = 10000L - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardViewModel.kt deleted file mode 100644 index 2906b5ca5..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardViewModel.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.dashboard - -import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.cancellable - -class CoreDashboardViewModel: CoreViewModel() { - private val studyRepository: StudyRepository = StudyRepository() - val study: MutableStateFlow = MutableStateFlow(null) - - override fun viewDidAppear() { - launchScope { - studyRepository.getStudy().cancellable().collect { - study.value = it - } - } - } - - fun onLoadStudy(provideNewState: ((StudySchema?) -> Unit)) = study.asClosure(provideNewState) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt deleted file mode 100644 index 0b954fdbc..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.limeSurvey - -import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.asNullableClosure -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.observations.limesurvey.LimeSurveyObservation -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.transform - -class CoreLimeSurveyViewModel(observationFactory: ObservationFactory): CoreViewModel() { - private var observation: LimeSurveyObservation? = observationFactory.observation("lime-survey-observation") as? LimeSurveyObservation - private var scheduleId: String? = null - private var observationId: String? = null - val limeSurveyLink: StateFlow? = observation?.limeURL - val dataLoading = MutableStateFlow(false) - - private val scheduleRepository = ScheduleRepository() - private val observationRepository = ObservationRepository() - - fun setScheduleId(scheduleId: String, notificationId: String?) { - if (scheduleId != this.scheduleId) { - Napier.i { "Setting scheduleId: $scheduleId for LimeSurvey" } - observation?.let { observation -> - if (scheduleId.isNotEmpty() || scheduleId.isNotBlank()) { - this.scheduleId = scheduleId - launchScope(Dispatchers.Main) { - dataLoading.set(true) - scheduleRepository.scheduleWithId(scheduleId).cancellable().transform { scheduleSchema -> - emit(scheduleSchema?.let { - observationRepository.observationById(it.observationId).cancellable().firstOrNull() - }) - }.cancellable().firstOrNull().let { observationSchema -> - observationSchema?.let { - observationId = it.observationId - observation.observationConfig(it.configAsMap()) - observation.start(it.observationId, scheduleId, notificationId) - } - dataLoading.set(false) - } - } - } - - } - } - } - - fun setObservationId(observationId: String, notificationId: String?) { - launchScope { - scheduleRepository.firstScheduleIdAvailableForObservationId(observationId).cancellable().firstOrNull()?.let { setScheduleId(it, notificationId) } - } - } - - fun onLimeSurveyLinkChange(providedState: (String?) -> Unit) = limeSurveyLink?.asNullableClosure(providedState) - - fun onDataLoadingChange(providedState: (Boolean) -> Unit) = dataLoading.asClosure(providedState) - - override fun viewDidAppear() { - - } - - override fun viewDidDisappear() { - super.viewDidDisappear() - clear() - } - - fun finish() { - scheduleId?.let { - observation?.storeData() - observation?.stopAndSetDone(it) - } - clear() - } - - fun cancel() { - scheduleId?.let { - observation?.stop(it) - } - clear() - } - - fun clear() { - scheduleId = null - observationId = null - dataLoading.value = false - } - - override fun close() { - super.close() - clear() - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/login/CoreLoginViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/login/CoreLoginViewModel.kt deleted file mode 100644 index 973f082b3..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/login/CoreLoginViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.login - -import io.redlink.more.app.android.services.network.errors.NetworkServiceError -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.services.network.RegistrationService -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow - -class CoreLoginViewModel(private val registrationService: RegistrationService): CoreViewModel() { - - val loadingFlow: MutableStateFlow = MutableStateFlow(false) - - fun sendRegistrationToken(token: String, endpoint: String? = null, onSuccess: (Study) -> Unit, onError: (NetworkServiceError?) -> Unit) { - if (token.isNotEmpty()) { - loadingFlow.value = true - registrationService.sendRegistrationToken(token.uppercase(), endpoint, onSuccess, onError) { - loadingFlow.value = false - } - } - } - - fun onLoadingChange(provideNewState: ((Boolean) -> Unit)) = loadingFlow.asClosure(provideNewState) - - override fun viewDidAppear() { - - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CorePermissionViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CorePermissionViewModel.kt deleted file mode 100644 index dcb0de7e5..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CorePermissionViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.permission - -import io.redlink.more.app.android.services.network.errors.NetworkServiceError -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.PermissionModel -import io.redlink.more.more_app_mutliplatform.services.network.RegistrationService -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Observation -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow - -class CorePermissionViewModel( - private val registrationService: RegistrationService, - private val studyConsentTitle: String -) : CoreViewModel() { - val permissionModel: MutableStateFlow = MutableStateFlow( - PermissionModel( - "Title", - "info", - "consent info", - consentInfo = emptyList() - ) - ) - val loadingFlow: MutableStateFlow = MutableStateFlow(false) - val observations: MutableStateFlow> = MutableStateFlow(emptyList()) - - fun buildConsentModel() { - registrationService.study?.let { - permissionModel.value = PermissionModel.create(it, studyConsentTitle) - observations.value = it.observations - } - } - - fun acceptConsent( - consentInfoMd5: String, - uniqueDeviceId: String, - onSuccess: (Boolean) -> Unit, - onError: (NetworkServiceError?) -> Unit - ) { - loadingFlow.value = true - registrationService.acceptConsent(consentInfoMd5, uniqueDeviceId, onSuccess, onError) { - loadingFlow.value = false - } - } - - fun declineConsent() { - registrationService.declineConsent() - } - - fun onConsentModelChange(provideNewState: ((PermissionModel) -> Unit)) = - permissionModel.asClosure(provideNewState) - - fun onLoadingChange(provideNewState: ((Boolean) -> Unit)) = - loadingFlow.asClosure(provideNewState) - - override fun viewDidAppear() { - - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/PermissionViewModelModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/PermissionViewModelModel.kt deleted file mode 100644 index 1ebc80b01..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/PermissionViewModelModel.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.permission - -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.services.store.CredentialRepository -import io.redlink.more.more_app_mutliplatform.services.store.EndpointRepository - -data class PermissionViewModelModel( - val study: Study, - val token: String, - val endpoint: String? = null, - val endpointRepository: EndpointRepository, - val credentialRepository: CredentialRepository -) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/schedules/CoreScheduleViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/schedules/CoreScheduleViewModel.kt deleted file mode 100644 index a46e8c39a..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/schedules/CoreScheduleViewModel.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.schedules - -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.DateFilterModel -import io.redlink.more.more_app_mutliplatform.models.ScheduleListType -import io.redlink.more.more_app_mutliplatform.models.ScheduleModel -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.observations.DataRecorder -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.update -import kotlinx.datetime.Clock - -class CoreScheduleViewModel( - private val dataRecorder: DataRecorder, - private val scheduleListType: ScheduleListType, - private val coreFilterModel: CoreDashboardFilterViewModel, -) : CoreViewModel() { - private val scheduleRepository = ScheduleRepository() - private var originalScheduleList = emptySet() - - private val _scheduleListState = MutableStateFlow( - Triple( - emptySet(), - emptySet(), - emptySet() - ) - ) - - val scheduleListState: StateFlow, Set, Set>> = - _scheduleListState - - init { - launchScope { - coreFilterModel.currentTypeFilter - .combine(coreFilterModel.currentDateFilter) { typeFilter, dateFilter -> - typeFilter.values.any() - && dateFilter[DateFilterModel.ENTIRE_TIME] == false && dateFilter.any { it.value } - } - .cancellable().collect { - if (it) { - updateList(coreFilterModel.applyFilter(originalScheduleList).toSet()) - } else { - val copy = originalScheduleList.toSet() - originalScheduleList = emptySet() - updateList(copy) - } - } - } - - } - - override fun viewDidAppear() { - launchScope { - scheduleRepository.allSchedulesWithStatus(done = scheduleListType == ScheduleListType.COMPLETED) - .cancellable() - .collect { - val newList = when (scheduleListType) { - ScheduleListType.COMPLETED -> createCompletedModels(it) - ScheduleListType.RUNNING -> createRunningModels(it) - ScheduleListType.MANUALS -> createManualTasks(it) - else -> createModels(it) - } - val modified = if (coreFilterModel.filterActive()) { - coreFilterModel.applyFilter(newList) - } else { - newList - }.toSet() - updateList(modified) - } - } - } - - fun start(scheduleId: String) { - dataRecorder.start(scheduleId) - } - - fun pause(scheduleId: String) { - dataRecorder.pause(scheduleId) - } - - fun stop(scheduleId: String) { - dataRecorder.stop(scheduleId) - } - - private fun updateList(newList: Set) { - val oldIds = originalScheduleList.map { it.scheduleId }.toSet() - val newIds = newList.map { it.scheduleId }.toSet() - val addedIds = newIds - oldIds - val removedIds = (oldIds - newIds).toMutableSet() - var added = newList.filter { it.scheduleId in addedIds }.toSet() - var updated = newList.filter { old -> - originalScheduleList.any { new -> old.isSameAs(new) && !old.hasSameContentAs(new) } - }.toSet() - - if (scheduleListType != ScheduleListType.COMPLETED) { - added = added.filter { - it.end > Clock.System.now().epochSeconds - && it.scheduleState.active() - || it.scheduleState == ScheduleState.DEACTIVATED - }.toSet() - val (update, remove) = updated.partition { - it.end > Clock.System.now().epochSeconds - && it.scheduleState.active() - || it.scheduleState == ScheduleState.DEACTIVATED - } - removedIds.addAll(remove.map { it.scheduleId }.toSet()) - updated = update.toSet() - } - - if (added.isNotEmpty() || removedIds.isNotEmpty() || updated.isNotEmpty()) { - _scheduleListState.update { Triple(added, removedIds, updated) } - } - originalScheduleList = newList.toSet() - } - - private fun createModels(scheduleList: List): List { - return scheduleList - .mapNotNull { ScheduleModel.createModel(it) } - } - - private fun createCompletedModels(scheduleList: List): List { - return createModels(scheduleList.filter { it.getState().completed() }) - } - - private fun createRunningModels(scheduleList: List): List { - return createModels(scheduleList.filter { it.getState().running() }) - } - - private fun createManualTasks(scheduleList: List): List { - return createModels(scheduleList.filter { !it.hidden }) - } - - fun onScheduleStateUpdated(providedState: (Triple, Set, Set>) -> Unit) = - scheduleListState.asClosure(providedState) -} - diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/settings/CoreSettingsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/settings/CoreSettingsViewModel.kt deleted file mode 100644 index 7e5a4dad4..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/settings/CoreSettingsViewModel.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.settings - -import io.ktor.utils.io.core.Closeable -import io.realm.kotlin.ext.copyFromRealm -import io.realm.kotlin.ext.isValid -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema -import io.redlink.more.more_app_mutliplatform.database.schemas.StudySchema -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.PermissionModel -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.combine - -class CoreSettingsViewModel( - private val shared: Shared -): CoreViewModel() { - val dataDeleted = MutableStateFlow(false) - private val studyRepository: StudyRepository = StudyRepository() - private val observationRepository = ObservationRepository() - - val study = MutableStateFlow(null) - val observations = MutableStateFlow(emptyList()) - - val permissionModel = MutableStateFlow(null) - - override fun viewDidAppear() { - launchScope { - studyRepository.getStudy().cancellable() - .combine(observationRepository.observations()) { study, observations -> - Pair(study?.copyFromRealm(), observations) - }.cancellable().collect { - if (it.first?.isValid() == true) { - study.value = it.first - it.first?.let { study -> - permissionModel.value = PermissionModel.createFromSchema(study, it.second) - } - } - } - } - launchScope { - observationRepository.observations().cancellable().collect { - observations.value = it.map { it.copyFromRealm() } - } - } - } - - fun onLoadStudy(provideNewState: ((StudySchema?) -> Unit)): Closeable { - return study.asClosure(provideNewState) - } - - fun onPermissionChange(provideNewState: (PermissionModel?) -> Unit): Closeable { - return permissionModel.asClosure(provideNewState) - } - - fun exitStudy() { - shared.exitStudy { - dataDeleted.value = true - } - } - -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/simpleQuestion/SimpleQuestionCoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/simpleQuestion/SimpleQuestionCoreViewModel.kt deleted file mode 100644 index e6fcf68fa..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/simpleQuestion/SimpleQuestionCoreViewModel.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.simpleQuestion - -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.SimpleQuestionModel -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.SimpleQuestionType -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.firstOrNull - -class SimpleQuestionCoreViewModel( - observationFactory: ObservationFactory, -): CoreViewModel() { - private var scheduleId: String? = null - private val scheduleRepository: ScheduleRepository = ScheduleRepository() - private val observationRepository: ObservationRepository = ObservationRepository() - - val simpleQuestionModel = MutableStateFlow(null) - private var observation: Observation? = observationFactory.observation(SimpleQuestionType().observationType) - - private var notificationId: String? = null - - fun setScheduleId(scheduleId: String, notificationId: String? = null) { - this.scheduleId = scheduleId - this.notificationId = notificationId - launchScope { - scheduleRepository.scheduleWithId(scheduleId).cancellable().firstOrNull()?.let { scheduleSchema -> - observationRepository.observationById(scheduleSchema.observationId).cancellable().firstOrNull()?.let { observationSchema -> - simpleQuestionModel.emit(SimpleQuestionModel.createModelFrom(observationSchema, scheduleId)) - } - } - } - } - - fun setScheduleViaObservationId(observationId: String, notificationId: String? = null) { - launchScope { - scheduleRepository.firstScheduleIdAvailableForObservationId(observationId).cancellable().firstOrNull()?.let { setScheduleId(it, notificationId)} - } - } - - fun finishQuestion(data: String, setObservationToDone: Boolean){ - simpleQuestionModel.value?.let { - observation?.let { observation -> - observation.start(it.observationId, it.scheduleId, notificationId) - observation.storeData(mapOf("answer" to data)) { - scheduleId?.let { - observation.stopAndSetDone(it) - } - } - notificationId = null - } - } - } - - fun onLoadSimpleQuestionObservation(provideNewState: ((SimpleQuestionModel?) -> Unit)): Closeable { - return simpleQuestionModel.asClosure(provideNewState) - } - - override fun viewDidAppear() { - - } -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/studydetails/CoreStudyDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/studydetails/CoreStudyDetailsViewModel.kt deleted file mode 100644 index 9327f2a1b..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/studydetails/CoreStudyDetailsViewModel.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.studydetails - -import io.ktor.utils.io.core.Closeable -import io.ktor.utils.io.core.use -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.StudyDetailsModel -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.combine - -class CoreStudyDetailsViewModel: CoreViewModel() { - val studyModel = MutableStateFlow(null) - - fun onLoadStudyDetails(provideNewState: ((StudyDetailsModel?) -> Unit)): Closeable { - return studyModel.asClosure(provideNewState) - } - - override fun viewDidAppear() { - launchScope { - StudyRepository().use { studyRepository -> - ScheduleRepository().use { scheduleRepository -> - ObservationRepository().use { observationRepository -> - studyRepository.getStudy() - .combine(scheduleRepository.allSchedulesWithStatus(true).cancellable()) { study, doneTasks -> - Pair(study, doneTasks.size) - }.combine(scheduleRepository.count().cancellable()) { (studySchema, doneTaskCount), taskCount -> - Triple( - studySchema, - doneTaskCount, - taskCount - ) - }.combine(observationRepository.observations().cancellable()) { triple, observations -> - Pair( - triple, - observations, - ) - }.cancellable().collect { (triple, observations) -> - triple.first?.let {studySchema -> - println(studySchema) - studyModel.value = StudyDetailsModel.createModelFrom(studySchema, observations.sortedBy { it.observationTitle }, triple.third, - triple.second.toLong() - ) - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt deleted file mode 100644 index 4e01fef7d..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.taskCompletionBar - -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.TaskCompletion -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.combine - -class CoreTaskCompletionBarViewModel: CoreViewModel() { - val taskCompletion: MutableStateFlow = MutableStateFlow(TaskCompletion()) - private val repository = ScheduleRepository() - - init { - launchScope { - repository.count() - .combine(repository.allSchedulesWithStatus(true).cancellable()) { scheduleCount, doneSchedules -> - TaskCompletion( - doneSchedules.size, - scheduleCount.toInt() - ) - }.cancellable().collect { - taskCompletion.emit(it) - } - } - } - - override fun viewDidAppear() { - - } - - fun onLoadTaskCompletion(provideNewState: ((taskCompletion: TaskCompletion) -> Unit)): Closeable { - return taskCompletion.asClosure(provideNewState) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/tasks/CoreTaskDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/tasks/CoreTaskDetailsViewModel.kt deleted file mode 100644 index 8626da5f9..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/tasks/CoreTaskDetailsViewModel.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.viewModels.tasks - -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.repository.DataPointCountRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.TaskDetailsModel -import io.redlink.more.more_app_mutliplatform.observations.DataRecorder -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.firstOrNull - -class CoreTaskDetailsViewModel( - private val dataRecorder: DataRecorder -): CoreViewModel() { - private var scheduleId: String? = null - - private val dataPointCountRepository: DataPointCountRepository = DataPointCountRepository() - private val observationRepository: ObservationRepository = ObservationRepository() - private val scheduleRepository: ScheduleRepository = ScheduleRepository() - val taskDetailsModel = MutableStateFlow(null) - val dataCount = MutableStateFlow(0) - - fun setSchedule(scheduleId: String) { - this.scheduleId = scheduleId - taskDetailsModel.value = null - dataCount.value = 0 - } - - override fun viewDidAppear() { - scheduleId?.let { - launchScope { - scheduleRepository.scheduleWithId(it).cancellable().collect { schedule -> - schedule?.let { schedule -> - observationRepository.observationById(schedule.observationId).cancellable().firstOrNull()?.let { - taskDetailsModel.emit(TaskDetailsModel.createModelFrom(it, schedule)) - } - } - } - } - launchScope { - dataPointCountRepository.get(it).cancellable().collect { - it?.let { - dataCount.emit(it.count) - } - } - } - } - } - - fun onLoadTaskDetails(provideNewState: ((TaskDetailsModel?) -> Unit)): Closeable = - taskDetailsModel.asClosure(provideNewState) - - fun onNewDataCount(provideNewState: (Long?) -> Unit) = dataCount.asClosure(provideNewState) - - fun startObservation() { - scheduleId?.let { dataRecorder.start(it) } - } - - fun stopObservation() { - scheduleId?.let { dataRecorder.stop(it) } - } - - fun pauseObservation() { - scheduleId?.let { dataRecorder.pause(it) } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt new file mode 100644 index 000000000..7c38cd62e --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt @@ -0,0 +1,37 @@ +package io.redlink.more.navigation + +import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.navigation.model.DeepLinkData +import io.redlink.more.observations.ObservationFactory +import kotlinx.coroutines.flow.Flow + +interface DeeplinkManager { + val observationFactory: ObservationFactory + + fun addAvailableDeepLinks(deepLinks: Set) + + fun setProtocol(protocolReplacement: String?) + + fun setHost(hostReplacement: String?) + + fun getNotificationViewDeepLink( + notificationId: String, + ): Flow + + fun modifyDeepLink( + deepLink: String?, + ): Flow + + fun modifyDeepLink( + deepLink: String?, + newState: (DeepLinkData?) -> Unit + ): Closeable + + fun validateRoute(deepLink: String): Boolean + + fun createDeeplinkForSchedule( + schedule: ScheduleEntity, + baseDeeplink: String? = null + ): String +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManagerImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManagerImpl.kt new file mode 100644 index 000000000..bb4163725 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManagerImpl.kt @@ -0,0 +1,240 @@ +package io.redlink.more.navigation + +import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.asClosure +import io.redlink.more.extensions.extractRouteFromDeepLink +import io.redlink.more.extensions.mapQueryParams +import io.redlink.more.navigation.model.DeepLinkData +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.navigation.model.NavigationRouteParameter +import io.redlink.more.observations.ObservationFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import kotlinx.datetime.Clock + +class DeeplinkManagerImpl( + private val repos: MainRepository, + override val observationFactory: ObservationFactory +) : DeeplinkManager { + private val deepLinks = mutableSetOf() + private var protocolReplacement: String? = null + private var hostReplacement: String? = null + + override fun addAvailableDeepLinks(deepLinks: Set) { + this.deepLinks.addAll(deepLinks) + } + + override fun setProtocol(protocolReplacement: String?) { + this.protocolReplacement = protocolReplacement + } + + override fun setHost(hostReplacement: String?) { + this.hostReplacement = hostReplacement + } + + override fun getNotificationViewDeepLink( + notificationId: String, + ): Flow { + return modifyDeepLink( + "/${NavigationRoute.NOTIFICATIONS.route}?${NavigationRouteParameter.NOTIFICATION_ID.key}=$notificationId", + ) + } + + override fun modifyDeepLink( + deepLink: String?, + ): Flow = flow { + deepLink?.let { deepLink -> + val queryParams = deepLink.mapQueryParams() + val observationIdParam = + queryParams[NavigationRouteParameter.OBSERVATION_ID.key]?.firstOrNull() + val scheduleIdParam = + queryParams[NavigationRouteParameter.SCHEDULE_ID.key]?.firstOrNull() + val notificationId = + queryParams[NavigationRouteParameter.NOTIFICATION_ID.key]?.firstOrNull() + + val schedule = scheduleIdParam?.let { id -> + repos.schedule.scheduleWithId(id).cancellable().firstOrNull() + } ?: observationIdParam?.let { id -> + repos.schedule.firstScheduleAvailableForObservationId(id) + .cancellable().firstOrNull() + } + + Napier.d { "Schedule: $schedule, observationId: $observationIdParam" } + + val scheduleIdToUse = scheduleIdParam ?: schedule?.scheduleId + val observationIdToUse = observationIdParam ?: schedule?.observationId + + emit( + DeepLinkData( + deepLinkModifier( + deepLink, + schedule, + ), + mapOf( + NavigationRouteParameter.NOTIFICATION_ID.key to notificationId, + NavigationRouteParameter.SCHEDULE_ID.key to scheduleIdToUse, + NavigationRouteParameter.OBSERVATION_ID.key to observationIdToUse + ) + ) + ) + } ?: run { + emit(deepLink?.let { DeepLinkData(it) }) + } + } + + private fun deepLinkModifier( + deepLink: String, + schedule: ScheduleEntity?, + ): String { + val selectedRoute = selectRoute(deepLink, schedule) + Napier.d { "Selected route: $selectedRoute, schedule: $schedule, observationId: ${schedule?.observationId}" } + return replaceRoute(deepLink, selectedRoute, schedule) + } + + + /** + * Returns true if [incomingRoute] should be treated as the same logical route as [registeredRoute]. + * + * This allows deeplinks like `question-observation_response` to resolve to the registered + * navigation route `question-observation` (or other variants where the incoming route has a + * suffix/prefix separated by '_' or '-'). + */ + private fun routeMatches(incomingRoute: String, registeredRoute: String): Boolean { + if (incomingRoute == registeredRoute) return true + + fun startsWithDelimited(value: String, prefix: String): Boolean { + if (!value.startsWith(prefix)) return false + if (value.length == prefix.length) return true + return value[prefix.length] == '_' || value[prefix.length] == '-' + } + + return startsWithDelimited(incomingRoute, registeredRoute) || + startsWithDelimited(registeredRoute, incomingRoute) + } + + /** Extracts the route part from a registered deep link uriPattern string. */ + private fun extractRegisteredRoute(uriPattern: String): String? = + uriPattern.extractRouteFromDeepLink() + + override fun validateRoute(deepLink: String): Boolean { + Napier.d { "Available deeplinks: $deepLinks" } + val incomingRoute = extractIncomingRoute(deepLink) ?: deepLink + return deepLinks.any { registered -> + val registeredRoute = extractRegisteredRoute(registered) ?: registered + routeMatches(incomingRoute, registeredRoute) + } + } + + private fun routeForObservation(deepLink: String): String { + val incomingRoute = extractIncomingRoute(deepLink.lowercase()) + ?: return NavigationRoute.DASHBOARD.route + + val resolvedObservationRoute = + observationFactory.getMatchingObservationTypes(setOf(incomingRoute)).firstOrNull() + ?: incomingRoute + + Napier.d { "Resolved observation route: $resolvedObservationRoute" } + + val valid = validateRoute(resolvedObservationRoute) + Napier.d { "Validating route: $valid" } + return resolvedObservationRoute + } + + private fun extractIncomingRoute(raw: String): String? { + // 1) Try the existing extractor (works for full deeplinks like scheme://host/path?...) + val extracted = raw.extractRouteFromDeepLink() + if (!extracted.isNullOrBlank()) return extracted + + // 2) Fallback: treat the input as a route/path-only string + // e.g. "/notifications", "notifications", "notifications?x=1", "/foo#bar" + return raw.trim() + .removePrefix("/") + .substringBefore('?') + .substringBefore('#') + .takeIf { it.isNotBlank() } + } + + private fun selectRoute(deepLink: String, schedule: ScheduleEntity?): String { + val now = Clock.System.now() + + return schedule?.let { scheduleSchema -> + if ((scheduleSchema.start ?: (now.epochSeconds + 1)) <= now.epochSeconds + && (scheduleSchema.end ?: 0) >= now.epochSeconds + && !scheduleSchema.getState().completed() + ) { + routeForObservation(deepLink) + } else { + Napier.d { "Schedule is not active, using default route" } + Napier.d { "Schedule start: ${scheduleSchema.start}, end: ${scheduleSchema.end}, currentTime: ${now.epochSeconds}" } + NavigationRoute.SCHEDULE_DETAILS.route + } + } ?: routeForObservation(deepLink) + } + + private fun replaceRoute( + deepLink: String, + routeToReplace: String, + schedule: ScheduleEntity? = null, + ): String { + val protocol = protocolReplacement + ?: if (deepLink.contains("://")) deepLink.substringBefore("://") else "app" + val host = hostReplacement ?: if (deepLink.contains("://")) deepLink.substringAfter("://") + .substringBefore("/") else "more" + + val paramsMap = deepLink.mapQueryParams().toMutableMap() + schedule?.let { + val scheduleIdKeySet = + paramsMap.getOrElse(NavigationRouteParameter.SCHEDULE_ID.key) { mutableSetOf() } + .toMutableSet() + scheduleIdKeySet.add(it.scheduleId) + paramsMap[NavigationRouteParameter.SCHEDULE_ID.key] = scheduleIdKeySet + } + + val newQueryParams = paramsMap.entries.flatMap { entry -> + entry.value.map { "${entry.key}=${it}" } + }.joinToString("&") + + return buildString { + append(protocol).append("://").append(host).append("/").append(routeToReplace) + if (newQueryParams.isNotEmpty()) append("?").append(newQueryParams) + } + } + + override fun modifyDeepLink( + deepLink: String?, + newState: (DeepLinkData?) -> Unit + ) = modifyDeepLink(deepLink).asClosure(newState) + + /** + * Creates a deep link for a given [ScheduleEntity]. + * + * Expected format: + * /?observationId=&scheduleId= + * + * `baseDeeplink` should look like: ":///" (including the trailing slash). + */ + override fun createDeeplinkForSchedule( + schedule: ScheduleEntity, + baseDeeplink: String? + ): String { + val host = baseDeeplink ?: "$protocolReplacement://$hostReplacement/" + val base = if (host.endsWith("/")) host else "$host/" + + val observationRoute = + observationFactory.getMatchingObservationTypes(setOf(schedule.observationType)) + .firstOrNull() ?: NavigationRoute.SCHEDULE_DETAILS.route + + return buildString { + append(base) + append(observationRoute) + append("?observationId=") + append(schedule.observationId) + append("&scheduleId=") + append(schedule.scheduleId) + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/DeepLinkData.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/DeepLinkData.kt new file mode 100644 index 000000000..fc7867359 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/DeepLinkData.kt @@ -0,0 +1,6 @@ +package io.redlink.more.navigation.model + +data class DeepLinkData( + val route: String, + val params: Map = emptyMap() +) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt new file mode 100644 index 000000000..b5c17c9b6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt @@ -0,0 +1,47 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.navigation.model + +import io.redlink.more.observations.observationTypes.GarminType +import io.redlink.more.observations.observationTypes.LimeSurveyType +import io.redlink.more.observations.observationTypes.QuestionType + +enum class NavigationRoute(val route: String, val viewIdentifier: String) { + LOGIN("login", "Login"), + CONSENT("consent", "Consent"), + DASHBOARD("dashboard", "Dashboard/Manual tasks"), + NOTIFICATIONS("notifications", "Notifications"), + INFO("information", "Information Menu"), + SETTINGS("settings", "Settings"), + SCHEDULE_DETAILS("task-details", "Task Detail Information"), + OBSERVATION_DETAILS("observation-details", "Observation Detail Information"), + STUDY_DETAILS("study-details", "Study Detail Information"), + OBSERVATION_FILTER("observation-filter", "Observation Filter"), + QUESTION( + QuestionType().observationType, + "${QuestionType().observationType} Questionnaire Interaction" + ), + QUESTIONNAIRE_RESPONSE( + "${QuestionType().observationType}_response", + "${QuestionType().observationType} Questionnaire Response" + ), + BLUETOOTH_CONNECTION("devices", "Bluetooth Connections"), + RUNNING_SCHEDULES("running-observations", "Running Observation List"), + COMPLETED_SCHEDULES("past-observations", "Completed Observation List"), + NOTIFICATION_FILTER("notification-filter", "Notification Filter"), + LEAVE_STUDY("leave-study", "Study Exit"), + LEAVE_STUDY_CONFIRM("leave-study-confirmation", "Study Exit Confirmation"), + LIMESURVEY(LimeSurveyType().observationType, "LimeSurvey Interaction"), + GARMIN_CONNECT(GarminType().observationType, "Garmin Connect Login"), + OBSERVATION_ERRORS("observation-errors", "Observation Errors"), + QR_CODE("scan-qr-code", "Scan QR Code"); +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRouteParameter.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRouteParameter.kt new file mode 100644 index 000000000..9b491b2b3 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRouteParameter.kt @@ -0,0 +1,12 @@ +package io.redlink.more.navigation.model + +enum class NavigationRouteParameter(val key: String) { + SCHEDULE_ID("scheduleId"), + OBSERVATION_ID("observationId"), + NOTIFICATION_ID("notificationId"), + SCHEDULE_LIST_TYPE("scheduleListType"); + + companion object { + fun fromKey(key: String) = entries.find { it.key == key } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/DataConfig.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/DataConfig.kt similarity index 90% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/DataConfig.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/DataConfig.kt index d0a5db047..fd20b2278 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/DataConfig.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/DataConfig.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations +package io.redlink.more.observations const val QUEUE_COUNT_THRESHOLD = 5 diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/DataRecorder.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/DataRecorder.kt similarity index 92% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/DataRecorder.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/DataRecorder.kt index b8d95fd7a..a3d261982 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/DataRecorder.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/DataRecorder.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations +package io.redlink.more.observations interface DataRecorder { fun start(scheduleId: String) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt new file mode 100644 index 000000000..1132f11c4 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -0,0 +1,450 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.observations + +import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.longRunningObservation.LongRunningObservationStorage +import io.redlink.more.observations.observationTypes.ObservationType +import io.redlink.more.scopes.Scope +import io.redlink.more.scopes.StudyScope +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.services.store.PermissionApprovalState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +interface ObservationPermissionObserver { + fun requestPermission(observationType: ObservationType) + fun permissionState(observationType: ObservationType): PermissionApprovalState +} + +abstract class Observation( + protected val repos: MainRepository, + val observationType: ObservationType, +) { + private var dataManager: ObservationDataManager? = null + private var notificationManager: NotificationManager? = null + + private var permissionObserver: ObservationPermissionObserver? = null + + protected var running = false + protected val observationIds = mutableSetOf() + + protected var longRunningStorage: LongRunningObservationStorage? = null + + + private val observationTypes = mutableMapOf() + protected val scheduleIds = mutableMapOf() + private val notificationIds = mutableMapOf() + private val config = mutableMapOf() + private var configChanged = false + + protected var lastCollectionTimestamp: Instant = Clock.System.now() + + var timestampCollectionJob: Job? = null + + fun setPermissionObserver(observer: ObservationPermissionObserver?) { + permissionObserver = observer + Napier.d { "PermissionObserver set for ${observationTypes.values.joinToString(", ")}" } + } + + fun setLongRunningObservationStorage(longRunningObservationStorage: LongRunningObservationStorage?) { + this.longRunningStorage = longRunningObservationStorage + } + + open fun start( + observationId: String, + scheduleId: String, + notificationId: String? = null + ): Boolean { + observationIds.add(observationId) + StudyScope.launch { + val realObservationType = + repos.observation.observationById(observationId).firstOrNull()?.observationType + ?: observationType.observationType + observationTypes[observationId] = realObservationType + } + timestampCollectionJob?.cancel() + timestampCollectionJob = StudyScope.launch { + repos.observation.collectTimestampForObservationIds(observationIds).collect { + lastCollectionTimestamp = Instant.fromEpochMilliseconds(it) + Napier.d(tag = "Observation::start") { "Last collection $lastCollectionTimestamp" } + } + }.second + timestampCollectionJob?.invokeOnCompletion { + timestampCollectionJob = null + } + scheduleIds[scheduleId] = observationId + notificationId?.let { + notificationIds[scheduleId] = notificationId + } + if (running && configChanged) { + stop {} + running = false + } + configChanged = false + return if (!running) { + Napier.i(tag = "Observation::start") { "Observation with type ${observationType.observationType} starting..." } + applyObservationConfig(config) + permissionObserver?.let { + updateObservationPermissions() + } + running = start() + Napier.i { "Observation with type ${observationType.observationType} started: $running" } + running + } else true + } + + open fun stop(scheduleId: String, removeNotification: Boolean = false) { + Napier.i(tag = "Observation::stop") { "Stopping observation of type ${observationType.observationType} for schedule $scheduleId." } + if (scheduleIds.size <= 1) { + stop { + timestampCollectionJob?.cancel() + saveAndSend() + observationShutdown(scheduleId) + } + } else { + saveAndSend() + observationShutdown(scheduleId) + } + if (removeNotification) { + handleNotification(scheduleId) + } + Scope.launch { + updateObservationErrors() + } + } + + fun observationDataManagerAdded() = dataManager != null + + fun setDataManager(observationDataManager: ObservationDataManager) { + Napier.i(tag = "Observation::setDataManager") { "Setting data manager for observation of type ${observationType.observationType}." } + dataManager = observationDataManager + } + + fun setNotificationManager(notificationManager: NotificationManager) { + this.notificationManager = notificationManager + } + + fun addNotificationId(scheduleId: String, notificationId: String) { + notificationIds[scheduleId] = notificationId + } + + fun requestPermission() { + if (isPermissionRequested(observationType.observationType)) { + Napier.d(tag = "Observation::requestPermission") { "Permission already requested for ${observationType.observationType} in this session, skipping..." } + return + } + markPermissionRequested(observationType.observationType) + if (permissionObserver == null) { + Napier.w { "Permission observer is null for observation type ${observationType.observationType}" } + } + permissionObserver?.requestPermission(observationType) + } + + open fun hasPermission(): PermissionApprovalState { + return permissionObserver?.permissionState(observationType) ?: run { + Napier.w { "Permission observer is null for observation type ${observationType.observationType}" } + PermissionApprovalState.NOT_SET + } + } + + fun observationConfig(settings: Map) { + this.lastCollectionTimestamp = (settings[CONFIG_LAST_COLLECTION_TIMESTAMP] as? Long)?.let { + Instant.fromEpochMilliseconds(it) + } ?: Clock.System.now() + if (settings.isNotEmpty()) { + Napier.i(tag = "Observation::observationConfig") { "Applying new observation settings for ${observationType.observationType}: $settings" } + val newConfig = this.config + settings + if (newConfig != this.config) { + configChanged = true + this.config += newConfig + } + } + } + + protected fun collectionTimestampToNow() { + Napier.d(tag = "Observation::collectionTimeStampToNow") { "Collecting timestamp" } + lastCollectionTimestamp = Clock.System.now() + StudyScope.launch(Dispatchers.IO) { + repos.observation.updateLastCollection( + observationIds.toSet(), + lastCollectionTimestamp.toEpochMilliseconds() + ) + } + } + + protected abstract fun start(): Boolean + + protected abstract fun stop(onCompletion: () -> Unit) + + fun observerAccessible(): Boolean { + val errors = observerErrors() + Napier.d(tag = "Observation::observerAccessible") { errors.toString() } + return errors.isEmpty() + } + + protected open fun observerErrors(): Set = emptySet() + + fun updateObservationPermissions() { + if (hasPermission() != PermissionApprovalState.GRANTED) { + Napier.w { "Permissions not given for observation ${observationType.observationType}! Requesting permissions..." } + requestPermission() + } else { + Napier.d { "All permissions given for observation ${observationType.observationType}!" } + } + } + + suspend fun updateObservationErrors() { + repos.schedule.allSchedulesToday(observationType).firstOrNull()?.let { + if (it.isNotEmpty()) { + Napier.d(tag = "Observation::updateObservationErrors") { "ObservationErrors for ${observationType.observationType}" } + + if (repos.study.studyState.value.isActive()) { + ObservationStates.updateObservationErrors( + observationType.observationType, + observerErrors() + ) + } + } + } + } + + protected abstract fun applyObservationConfig(settings: Map) + + open fun bleDevicesNeeded(): Set = emptySet() + + open fun ableToAutomaticallyStart() = true + + fun storeInstant(data: T, timestamp: Long) { + longRunningStorage?.storeInstant(data, timestamp) + } + + fun startLongRunningObservation(data: T, identifier: String, timestamp: Long) { + longRunningStorage?.startObservation(data, identifier, timestamp) + } + + fun finishLongRunningObservation(data: T, identifier: String, timestamp: Long) { + longRunningStorage?.finishObservation(data, identifier, timestamp) + } + + fun inRangeLongRunningObservation(data: T, identifier: String, timestamp: Long) { + longRunningStorage?.inRangeObservation(data, identifier, timestamp) + } + + fun storeData(data: Map, timestamp: Long = -1, onCompletion: () -> Unit = {}) { + val dataSchemas = ObservationDataEntity.fromData( + observationIds.toSet(), setOf(ObservationBulkModel(data, timestamp)) + ).map { + it.observationType = + observationTypes[it.observationId] ?: observationType.observationType + it + } + Napier.i(tag = "Observation::storeData") { "Observation, with ids $observationIds, ${observationType.observationType} recorded a new data point!" } + dataManager?.add(dataSchemas, scheduleIds.keys) + onCompletion() + } + + fun storeData(data: List, onCompletion: () -> Unit) { + val dataSchemas = ObservationDataEntity.fromData(observationIds.toSet(), data) + .map { + it.observationType = + observationTypes[it.observationId] ?: observationType.observationType + it + } + Napier.i(tag = "Observation::storeData") { "Observation, with ids $observationIds, ${observationType.observationType} recorded new datapoints!" } + dataManager?.add(dataSchemas, scheduleIds.keys) + onCompletion() + } + + open fun stopAndFinish(scheduleId: String) { + Napier.i(tag = "Observation::stopAndFinish") { "Stopping and finishing observation ${observationType.observationType} for scheduleId: $scheduleId" } + if (scheduleIds.size <= 1) { + stop { + timestampCollectionJob?.cancel() + saveAndSend() + observationShutdown(scheduleId) + } + } else { + saveAndSend() + observationShutdown(scheduleId) + } + Scope.launch { + updateObservationErrors() + } + } + + // Used in iOS + fun stopAndSetState(state: ScheduleState = ScheduleState.ACTIVE, scheduleId: String?) { + Napier.d(tag = "Observation::stopAndSetState") { "Stopping observation of type ${observationType.observationType} and setting state to $state for schedule $scheduleId." } + if (scheduleIds.size <= 1 || scheduleId == null) { + stop { + timestampCollectionJob?.cancel() + saveAndSend() + scheduleIds.keys.forEach { + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setRunningStateFor(it, state) + } + } + scheduleId?.let { + observationShutdown(it) + } + } + } else { + saveAndSend() + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setRunningStateFor(scheduleId, state) + } + observationShutdown(scheduleId) + } + Scope.launch { + updateObservationErrors() + } + } + + fun stopAndSetDone(scheduleId: String) { + Napier.d(tag = "Observation::stopAndSetDone") { "Stopping observation of type ${observationType.observationType} and setting done for schedule $scheduleId." } + if (scheduleIds.size <= 1) { + stop { + timestampCollectionJob?.cancel() + saveAndSend() + scheduleIds.keys.forEach { + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setCompletionStateFor(it, true) + } + } + observationShutdown(scheduleId) + removeDataCount() + handleNotification(scheduleId) + Scope.launch { + updateObservationErrors() + } + } + } else { + saveAndSend() + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setCompletionStateFor(scheduleId, true) + } + observationShutdown(scheduleId) + removeDataCount() + handleNotification(scheduleId) + Scope.launch { + updateObservationErrors() + } + } + } + + open fun store(start: Long = -1, end: Long = -1, onCompletion: () -> Unit) { + Napier.d(tag = "Observation::store") { "Storing data for observation of type ${observationType.observationType} with start time: $start, end time: $end." } + dataManager?.store() + onCompletion() + } + + private fun observationShutdown(scheduleId: String) { + val observationId = scheduleIds.remove(scheduleId) + observationId?.let { id -> + if (scheduleIds.values.none { it == id }) { + observationIds.remove(id) + observationTypes.remove(id) + } + } + if (scheduleIds.isEmpty()) { + config.clear() + configChanged = false + running = false + } + } + + private fun handleNotification(scheduleId: String) { + notificationIds.remove(scheduleId)?.let { + notificationManager?.markNotificationAsCompleted(it) + } + } + + protected fun showNotification(title: String, notificationBody: String) { + val notification = NotificationEntity.build(title, notificationBody) + Napier.d(tag = "Observation::showNotification") { "Showing notification: $notification" } + notificationManager?.storeAndDisplayNotification(notification, true) + } + + protected fun showObservationErrorNotification( + notificationBody: String, + fallbackTitle: String = "Error" + ) { + val schedulesSchemaFlows = scheduleIds.keys.map { + repos.schedule.scheduleWithId(it) + } + val combinedFlow = combine(schedulesSchemaFlows) { values -> + values.mapNotNull { it } + } + + StudyScope.launch { + val scheduleSchemas = combinedFlow.first() + val title = + if (scheduleSchemas.isNotEmpty()) scheduleSchemas.joinToString( + ", ", + limit = 5 + ) { it.observationTitle } else fallbackTitle + withContext(Dispatchers.Main) { + showNotification(title, notificationBody) + } + } + } + + protected fun saveAndSend() { + Napier.d(tag = "Observation::finish") { "Saving and sending data for observation of type ${observationType.observationType}." } + dataManager?.saveAndSend() + } + + fun removeDataCount() { + Napier.d(tag = "Observation::removeDataCount") { "Removing data point count for observation of type ${observationType.observationType}." } + scheduleIds.keys.forEach { + dataManager?.removeDataPointCount(it) + } + scheduleIds.clear() + } + + open fun onStudyExit() {} + + companion object { + private val requestedPermissions = mutableSetOf() + + fun resetRequestedPermissions() { + Napier.d(tag = "Observation::companion") { "Resetting requested permissions" } + requestedPermissions.clear() + } + + fun markPermissionRequested(observationType: String) { + requestedPermissions.add(observationType) + } + + fun isPermissionRequested(observationType: String): Boolean { + return requestedPermissions.contains(observationType) + } + + const val CONFIG_TASK_START = "observation_start_date_time" + const val CONFIG_TASK_STOP = "observation_stop_date_time" + const val SCHEDULE_ID = "schedule_id" + const val CONFIG_LAST_COLLECTION_TIMESTAMP = "observation_last_collection_timestamp" + + const val ERROR_DEVICE_NOT_CONNECTED = "error_device_not_connected" + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationBulkModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt similarity index 87% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationBulkModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt index 474259a2b..f8402ad17 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationBulkModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt @@ -8,9 +8,9 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations +package io.redlink.more.observations data class ObservationBulkModel( - val data: Any, + val data: Map, val timestamp: Long = 0 ) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt new file mode 100644 index 000000000..4a283b337 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt @@ -0,0 +1,116 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.observations + +import dev.tmapps.konnection.Konnection +import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.models.StudyState +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.MoreDispatchers +import io.redlink.more.scopes.MoreScope +import io.redlink.more.scopes.Scope +import io.redlink.more.scopes.StudyMoreScope +import io.redlink.more.scopes.StudyScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +abstract class ObservationDataManager( + private val repository: MainRepository, + private val scope: MoreScope = Scope, + private val studyScope: StudyMoreScope = StudyScope, + private val dispatchers: MoreDispatchers = AppDispatchers +) { + private var countJob: Job? = null + + private var scheduleCount = mutableMapOf() + protected open val konnection: Konnection? by lazy { Konnection.instance } + + init { + Napier.i(tag = "ObservationDataManager::init") { "ObservationDataManager init!" } + } + + open fun add(dataList: List, scheduleIdList: Set) { + if (dataList.isNotEmpty()) { + Napier.i(tag = "ObservationDataManager::add") { "Adding ${dataList.size} observations for schedule IDs: $scheduleIdList" } + repository.observationData.addData(dataList) + repository.dataPointCount.incrementCount(scheduleIdList, dataList.size.toLong()) + if (countJob == null && repository.study.studyState.value == StudyState.ACTIVE) { + listenToDatapointCountChanges() + } + } + } + + open fun saveAndSend() { + Napier.i(tag = "ObservationDataManager::saveAndSend") { "Saving and sending observations" } + scope.launch(dispatchers.io) { + repository.observationData.store() + } + } + + open fun store() { + Napier.i(tag = "ObservationDataManager::store") { "Storing observations" } + studyScope.launch(dispatchers.io) { + repository.observationData.store() + } + } + + open fun removeDataPointCount(scheduleId: String) { + Napier.d(tag = "ObservationDataManager::removeDataPointCount") { "Removing datapoint count for schedule ID: $scheduleId" } + scheduleCount.remove(scheduleId) + } + + abstract fun sendData(immediately: Boolean = false, onCompletion: (Boolean) -> Unit = {}) + + open suspend fun sendData(immediately: Boolean): Boolean { + return suspendCancellableCoroutine { cont -> + sendData(immediately) { success -> + if (cont.isActive) cont.resume(success) + } + } + } + + open fun listenToDatapointCountChanges() { + if (countJob == null) { + Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Starting to listen for changes in datapoint counts" } + countJob = scope.repeatedLaunch(60000, dispatchers.io) { + if (isConnected()) { + val count = repository.observationData.getCount() + if (count > 0) { + Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Observation data count: $count! Sending data..." } + sendData() + } + } else { + Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "No connection" } + } + }.second + countJob?.invokeOnCompletion { + countJob = null + } + } + } + + open fun stopListeningToCountChanges() { + Napier.d(tag = "ObservationDataManager::stopListeningToCAndroiduntChanges") { "Stopped listening for changes in datapoint counts" } + countJob?.cancel() + countJob = null + } + + protected open fun isConnected() = konnection?.isConnected() ?: false + + protected suspend fun dataBulk() = repository.observationData.allAsBulk() + + protected suspend fun deleteAll(idSet: Set) { + repository.observationData.deleteAllWithId(idSet) + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt new file mode 100644 index 000000000..2e5f49329 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -0,0 +1,275 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.observations + +import io.github.aakira.napier.Napier +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.logging.EventCollection +import io.redlink.more.logging.EventObserver +import io.redlink.more.observations.appUsage.AppUsageObservation +import io.redlink.more.observations.garmin.GarminObservation +import io.redlink.more.observations.limesurvey.LimeSurveyObservation +import io.redlink.more.observations.questionObservation.QuestionObservation +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.MoreScope +import io.redlink.more.scopes.Scope +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.services.store.CredentialRepository +import io.redlink.more.services.store.PermissionRepository +import io.redlink.more.services.store.PermissionRepositoryImpl +import io.redlink.more.services.store.SharedStorageRepository +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield +import kotlin.reflect.KClass + +abstract class ObservationFactory( + repository: MainRepository, + sharedStorageRepository: SharedStorageRepository, + private val dataManager: ObservationDataManager, + private val scope: MoreScope = Scope +) { + private var isRequestingPermissions = false + private var updateErrorsDeferred = false + + private var activePermissionRequests = 0 + + fun startRequestingPermissions() { + activePermissionRequests++ + isRequestingPermissions = true + } + + fun stopRequestingPermissions() { + activePermissionRequests-- + if (activePermissionRequests <= 0) { + activePermissionRequests = 0 + isRequestingPermissions = false + if (updateErrorsDeferred) { + updateErrorsDeferred = false + scope.launch(AppDispatchers.default) { + updateObservationErrors() + } + } + } + } + private var credentialRepository: CredentialRepository? = null + open val observations = mutableSetOf() + + private val _studyObservationTypes: MutableStateFlow> = MutableStateFlow(emptySet()) + open val studyObservationTypes: StateFlow> = _studyObservationTypes + + private val observationProviders = mutableSetOf<() -> Observation>() + + protected val permissionRepository = PermissionRepositoryImpl( + sharedStorageRepository + ) + + protected var appUsageObservation: AppUsageObservation? = null + + init { + registerImportantObservations(repository, permissionRepository) + registerNormalObservations(repository) + scope.launch(AppDispatchers.default) { + repository.observation.observationTypes().collectLatest { + Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } + initializeNeededObservations(it) + _studyObservationTypes.value = it + if (it.isNotEmpty()) { + updateObservationPermissionsAndErrorsWhenInForeground() + } + } + } + } + + private fun registerImportantObservations( + repository: MainRepository, + permissionRepository: PermissionRepository + ) { + if (appUsageObservation == null) { + appUsageObservation = AppUsageObservation(repository, permissionRepository) + } + addObservationToList(appUsageObservation!!) + } + + private fun registerNormalObservations(repository: MainRepository) { + registerObservation { QuestionObservation(repository) } + registerObservation { LimeSurveyObservation(repository) } + registerObservation { GarminObservation(repository) } + } + + open fun observationPostConstruct(observation: Observation) {} + + protected fun registerObservation(provider: () -> Observation) { + observationProviders.add(provider) + } + + private fun initializeNeededObservations(types: Set) { + observationProviders.forEach { provider -> + val observation = provider() + if (observation.observationType.matchesAny(types)) { + if (observations.none { it.observationType.observationType == observation.observationType.observationType }) { + addObservationToList( + observation + ) + } + } + } + Napier.d("Initialized needed observations: ${observations.map { it.observationType.observationType }}") + } + + private fun addObservationToList(observation: Observation) { + observations.add( + observation + .also { observationPostConstruct(it) } + ) + } + + open fun addNeededObservationTypes(observationTypes: Set) { + Napier.i(tag = "ObservationFactory::addNeededObservationTypes") { "Adding observation types to studyObservationTypes: $observationTypes" } + _studyObservationTypes.value += observationTypes + initializeNeededObservations(_studyObservationTypes.value) + } + + open fun clearNeededObservationTypes() { + _studyObservationTypes.value = setOf() + observationsWithInterface(EventObserver::class) + .forEach { EventCollection.removeObserver(it) } + observations.removeAll { + !EventObserver::class.isInstance(it) + } + Napier.d("Cleared needed observations, but ${observations.map { it.observationType.observationType }}") + ObservationStates.resetAll() + } + + open fun setCredentialsRepository(credentialRepository: CredentialRepository) { + this.credentialRepository = credentialRepository + } + + open fun studySensorPermissions() = + observations.filter { observationMatchesStudyTypes(it, studyObservationTypes.value) } + .flatMap { it.observationType.sensorPermissions }.toSet() + + open fun setNotificationManager(notificationManager: NotificationManager) { + observations.forEach { it.setNotificationManager(notificationManager) } + } + + open fun observationTypes() = + observations.map { it.observationType.observationType }.toSet() + + open fun getMatchingObservationTypes(types: Set): Set = + observations.filter { it.observationType.matchesAny(types) } + .map { it.observationType.observationType }.toSet() + + open fun sensorPermissions() = + observations.map { it.observationType.sensorPermissions }.flatten().toSet() + + open fun bleDevicesNeeded(): Set { + Napier.i(tag = "ObservationFactory::bleDevicesNeeded") { "Filtering types for BLE: ${studyObservationTypes.value}" } + val bleTypes = + observations.filter { observationMatchesStudyTypes(it, studyObservationTypes.value) } + .flatMap { it.bleDevicesNeeded() }.toSet() + Napier.i(tag = "ObservationFactory::bleDevicesNeeded") { "BLE observation types: $bleTypes" } + return bleTypes + } + + open fun autoStartableObservations(): Set { + val autoStartTypes = studyObservations().filter { it.ableToAutomaticallyStart() } + .map { it.observationType.observationType }.toSet() + Napier.i(tag = "ObservationFactory::autoStartableObservations") { "Auto-startable observations: $autoStartTypes" } + return autoStartTypes + } + + private suspend fun updateObservationPermissionsAndErrorsWhenInForeground() { + withTimeoutOrNull(300_000L) { + ViewManager.appInForeground.collectLatest { inForeground -> + if (inForeground) { + Napier.d { "App in foreground, updating observation permissions and errors..." } + updateObservationPermissions() + updateObservationErrors() + Napier.d { "Updated permission check!" } + cancel() + } else { + var currentLogDelay = 1000L + while (true) { + Napier.d { "App not in foreground! Waiting for permission check..." } + delay(currentLogDelay) + currentLogDelay = (currentLogDelay * 2).coerceAtMost(30000L) + } + } + } + } + } + + open suspend fun updateObservationErrors() { + if (isRequestingPermissions) { + updateErrorsDeferred = true + Napier.d { "Observation error update blocked while requesting permissions. Deferring..." } + return + } + if (this.credentialRepository?.hasCredentials?.value == true) { + Napier.d { "Updating Observation errors..." } + studyObservations().forEach { it.updateObservationErrors() } + } + } + + suspend fun updateObservationPermissions() { + if (this.credentialRepository?.hasCredentials?.value == true) { + startRequestingPermissions() + try { + Napier.d { "Updating Observation permissions for types ${studyObservations()}..." } + studyObservations().forEach { + yield() + it.updateObservationPermissions() + } + } finally { + stopRequestingPermissions() + } + } + } + + open fun observation(type: String): Observation? { + Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } + val observation = observations.firstOrNull { + it.observationType.matches(type) + } ?: observationProviders.map { it() }.firstOrNull { it.observationType.matches(type) } + ?.also { + observations.add(it) + } + return observation?.apply { + if (!this.observationDataManagerAdded()) { + Napier.i(tag = "ObservationFactory::observation") { "Adding data manager to observation of type: $type" } + setDataManager(dataManager) + } + } + } + + fun observationsWithInterface(clazz: KClass): Set = + observations.filter { clazz.isInstance(it) } + .mapNotNull { it as? T } + .toSet() + + fun onStudyExit() { + observations.forEach { it.onStudyExit() } + clearNeededObservationTypes() + } + + private fun studyObservations(): List = + observations.filter { it.observationType.matchesAny(studyObservationTypes.value) } + + private fun observationMatchesStudyTypes(obs: Observation, types: Set): Boolean = + obs.observationType.matchesAny(types) + +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt similarity index 58% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt index 152dd99e5..e3126fbb8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt @@ -8,60 +8,58 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations +package io.redlink.more.observations import io.github.aakira.napier.Napier -import io.realm.kotlin.ext.copyFromRealm -import io.realm.kotlin.types.RealmInstant -import io.redlink.more.more_app_mutliplatform.database.repository.DataPointCountRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.util.StudyScope -import kotlinx.coroutines.delay +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.models.ScheduleState +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.MoreDispatchers +import io.redlink.more.scopes.StudyMoreScope +import io.redlink.more.scopes.StudyScope import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.datetime.Clock import kotlin.math.ceil class ObservationManager( + private val repositories: MainRepository, private val observationFactory: ObservationFactory, - private val dataRecorder: DataRecorder + private val dataRecorder: DataRecorder, + private val studyScope: StudyMoreScope = StudyScope, + private val dispatchers: MoreDispatchers = AppDispatchers ) { - private val scheduleRepository = ScheduleRepository() - private val dataPointCountRepository = DataPointCountRepository() - private val observationRepository = ObservationRepository() private val runningObservations = mutableMapOf() - private val scheduleSchemaList = mutableSetOf() + private val scheduleSchemaList = mutableSetOf() private val currentlyRunning = mutableSetOf() - private var upToDateTimestamps: Map = emptyMap() + var upToDateTimestamps: Map = emptyMap() fun activateScheduleUpdate() { Napier.i(tag = "ObservationManager::activateScheduleUpdate") { "ObservationManager: ScheduleUpdater activating..." } - StudyScope.launch { - scheduleRepository.allSchedulesWithStatus(true).cancellable().collect { list -> - if (runningObservations.isNotEmpty()) { - list.filter { it.scheduleId.toHexString() in runningObservations.keys } - .map { it.scheduleId.toHexString() }.forEach { - stop(it) - dataPointCountRepository.delete(it) - } + studyScope.launch(dispatchers.io) { + repositories.schedule.allSchedulesWithStatus(true).distinctUntilChanged().cancellable() + .collect { list -> + if (runningObservations.isNotEmpty()) { + list.filter { it.scheduleId in runningObservations.keys } + .map { it.scheduleId }.forEach { + stop(it) + repositories.dataPointCount.delete(it) + } + } } - } } val firstCall = ceil(Clock.System.now().toEpochMilliseconds() / 60_000.0).toLong() * 60_000 - StudyScope.launch { - delay(firstCall - Clock.System.now().toEpochMilliseconds()) - StudyScope.repeatedLaunch(30000L) { - updateTaskStates() - } + val initialDelay = firstCall - Clock.System.now().toEpochMilliseconds() + studyScope.repeatedLaunch(60000L, dispatchers.io, initialDelay) { + updateTaskStates() } - StudyScope.launch { - observationRepository.collectAllTimestamps().cancellable().collect { + studyScope.launch(dispatchers.io) { + repositories.observation.collectAllTimestamps().cancellable().collect { upToDateTimestamps = it } } @@ -69,14 +67,15 @@ class ObservationManager( suspend fun restartStillRunning(): Set { val startedObservations = mutableSetOf() - scheduleRepository.allScheduleWithRunningState().cancellable().firstOrNull()?.let { list -> - Napier.i(tag = "ObservationManager::restartStillRunning") { "Restarting schedules: $list" } - list.filter { it.scheduleId.toHexString() !in runningObservations.keys }.forEach { - if (start(it.scheduleId.toHexString())) { - startedObservations.add(it.scheduleId.toHexString()) + repositories.schedule.allScheduleWithRunningState().cancellable().firstOrNull() + ?.let { list -> + Napier.i(tag = "ObservationManager::restartStillRunning") { "Restarting schedules: $list" } + list.filter { it.scheduleId !in runningObservations.keys }.forEach { + if (start(it.scheduleId)) { + startedObservations.add(it.scheduleId) + } } } - } return startedObservations } @@ -87,31 +86,31 @@ class ObservationManager( val result = findOrCreateObservation(scheduleId)?.let { scheduleSchema -> Napier.i(tag = "ObservationManager::start") { "Trying to start schedule: $scheduleSchema" } val result = - observationRepository.getObservationByObservationId(scheduleSchema.observationId) + repositories.observation.getObservationByObservationId(scheduleSchema.observationId) ?.let { observation -> Napier.i(tag = "ObservationManager::start") { "Found Observation Config: ${observation.configAsMap()}" } val config = observation.configAsMap().toMutableMap() scheduleSchema.start?.let { - config[Observation.CONFIG_TASK_START] = it.epochSeconds + config[Observation.CONFIG_TASK_START] = it } scheduleSchema.end?.let { - config[Observation.CONFIG_TASK_STOP] = it.epochSeconds + config[Observation.CONFIG_TASK_STOP] = it } config[Observation.SCHEDULE_ID] = - scheduleSchema.scheduleId.toHexString() + scheduleSchema.scheduleId if (scheduleSchema.getState() == ScheduleState.PAUSED) { config[Observation.CONFIG_LAST_COLLECTION_TIMESTAMP] = - observation.collectionTimestamp.epochSeconds + observation.collectionTimestamp } start(scheduleSchema, config) } ?: false if (!result) { Napier.w(tag = "ObservationManager::start") { "Could not retrieve Observation Schema for schedule: $scheduleSchema" } runningObservations.remove(scheduleId) - scheduleSchemaList.removeAll { it.scheduleId.toHexString() == scheduleId } + scheduleSchemaList.removeAll { it.scheduleId == scheduleId } currentlyRunning.remove(scheduleId) } - result + return@let result } ?: false if (!result) { Napier.w(tag = "ObservationManager::start") { "Could not find observation for schema for scheduleId: $scheduleId" } @@ -123,13 +122,13 @@ class ObservationManager( } private fun start( - schedule: ScheduleSchema, + schedule: ScheduleEntity, config: Map ): Boolean { - runningObservations[schedule.scheduleId.toHexString()]?.observationConfig(config) - return if (runningObservations[schedule.scheduleId.toHexString()]?.start( + runningObservations[schedule.scheduleId]?.observationConfig(config) + return if (runningObservations[schedule.scheduleId]?.start( schedule.observationId, - schedule.scheduleId.toHexString() + schedule.scheduleId ) == true ) { setObservationState(schedule, ScheduleState.RUNNING) @@ -143,9 +142,9 @@ class ObservationManager( fun pause(scheduleId: String) { runningObservations[scheduleId]?.let { observation -> - scheduleSchemaList.firstOrNull { it.scheduleId.toHexString() == scheduleId }?.let { + scheduleSchemaList.firstOrNull { it.scheduleId == scheduleId }?.let { Napier.i(tag = "ObservationManager::pause") { "Pausing schedule: $it" } - observation.stop(scheduleId) + observation.stop(scheduleId, false) setObservationState(it, ScheduleState.PAUSED) Napier.i(tag = "ObservationManager::pause") { "Recording paused of ${it.scheduleId}" } } @@ -160,33 +159,37 @@ class ObservationManager( runningObservations.filterValues { it.observationType.observationType == type } .keys.forEach { key -> Napier.d(tag = "ObservationManager::pauseObservationType") { "Pausing schedule: $key" } - dataRecorder.pause(key) + pause(key) Napier.d(tag = "ObservationManager::pauseObservationType") { "Recording paused of $key" } } } - fun startObservationType(type: String) { - StudyScope.launch { - Napier.d(tag = "ObservationManager::startObservationType") { "Restarting Observations with type: $type" } - scheduleRepository.allSchedulesWithStatus(false) + suspend fun startObservationType(type: String) { + Napier.d(tag = "ObservationManager::startObservationType") { "Restarting Observations with type: $type" } + repositories.schedule.allSchedulesWithStatus(false) + .firstOrNull() + ?.filter { it.observationType == type && it.getState().active() } + ?.forEach { + if (start(it.scheduleId)) { + Napier.i(tag = "ObservationManager::startObservationType") { "Started Schedule: $it" } + } else { + currentlyRunning.remove(it.scheduleId) + Napier.i(tag = "ObservationManager::startObservationType") { "Failed to start schedule: $it" } + } + } + dataRecorder.startMultiple( + repositories.schedule.allSchedulesWithStatus(false) .firstOrNull() ?.filter { it.observationType == type && it.getState().active() } - ?.forEach { - if (start(it.scheduleId.toHexString())) { - Napier.i(tag = "ObservationManager::startObservationType") { "Started Schedule: $it" } - } else { - currentlyRunning.remove(it.scheduleId.toHexString()) - Napier.i(tag = "ObservationManager::startObservationType") { "Failed to start schedule: $it" } - } - } - } + ?.map { it.scheduleId }?.toSet() ?: emptySet() + ) } fun stop(scheduleId: String) { runningObservations[scheduleId]?.let { observation -> - scheduleSchemaList.firstOrNull { it.scheduleId.toHexString() == scheduleId }?.let { + scheduleSchemaList.firstOrNull { it.scheduleId == scheduleId }?.let { Napier.i(tag = "ObservationManager::stop") { "Stopping schedule: $it" } - observation.stop(scheduleId) + observation.stop(scheduleId, false) observation.removeDataCount() setObservationState(it, ScheduleState.DONE) runningObservations.remove(scheduleId) @@ -194,7 +197,7 @@ class ObservationManager( Napier.i(tag = "ObservationManager::stop") { "Observation removed: ${it.scheduleId}! Observations left: $runningObservations" } Napier.i(tag = "ObservationManager::stop") { "Recording stopped of ${it.scheduleId}" } } - } ?: kotlin.run { + } ?: run { setObservationState(scheduleId, ScheduleState.DONE) } currentlyRunning.remove(scheduleId) @@ -205,47 +208,50 @@ class ObservationManager( stopAllInList() } - fun updateTaskStates() { - scheduleRepository.updateTaskStates(observationFactory, dataRecorder) - } - - suspend fun updateTaskStatesWithBLEDevices() { - scheduleRepository.updateTaskStatesWithBLEDevices(observationFactory, dataRecorder) + suspend fun updateTaskStates() { + repositories.schedule.updateTaskStates(observationFactory, dataRecorder) } fun hasRunningTasks() = currentlyRunning.isNotEmpty() - fun allRunningObservations() = runningObservations.toMap() - private fun stopAllInList() { Napier.d(tag = "ObservationManager::stopAllInList") { "Running Observations to be stopped: $runningObservations" } val runningObs = runningObservations.toList() runningObs.forEach { (scheduleId, observation) -> observation.stopAndFinish(scheduleId) - scheduleRepository.setCompletionStateFor(scheduleId, true) - dataPointCountRepository.delete(scheduleId) + studyScope.launch(dispatchers.io) { + repositories.schedule.setCompletionStateFor(scheduleId, true) + } + repositories.dataPointCount.delete(scheduleId) runningObservations.remove(scheduleId) - scheduleSchemaList.removeAll { it.scheduleId.toHexString() == scheduleId } + scheduleSchemaList.removeAll { it.scheduleId == scheduleId } currentlyRunning.clear() } } fun collectAllData(onCompletion: (Boolean) -> Unit) { - StudyScope.launch { + studyScope.launch(dispatchers.io) { restartStillRunning() + Napier.d(tag = "ObservationManager::collectAllData") { "Currently running observations: $runningObservations" } if (!hasRunningTasks()) { onCompletion(true) + return@launch } var counter = 0 - runningObservations.values.forEach { + val observationsToStore = runningObservations.values.toList() + if (observationsToStore.isEmpty()) { + onCompletion(true) + return@launch + } + observationsToStore.forEach { upToDateTimestamps[it.observationType.observationType]?.let { lastTimestamp -> - it.store(lastTimestamp.epochSeconds, Clock.System.now().epochSeconds) { - if (++counter == runningObservations.size) { + it.store(lastTimestamp, Clock.System.now().epochSeconds) { + if (++counter == observationsToStore.size) { onCompletion(true) } } } ?: run { - if (++counter == runningObservations.size) { + if (++counter == observationsToStore.size) { onCompletion(true) } } @@ -253,36 +259,39 @@ class ObservationManager( } } - private suspend fun findOrCreateObservation(scheduleId: String): ScheduleSchema? { - return scheduleRepository.scheduleWithId(scheduleId).firstOrNull()?.let { - val fixedScheduleSchema = it.copyFromRealm() + private suspend fun findOrCreateObservation(scheduleId: String): ScheduleEntity? { + return repositories.schedule.scheduleWithId(scheduleId).firstOrNull()?.let { Napier.d(tag = "ObservationManager::findOrCreateObservation") { "Found Schema $it" } - if (findOrCreateObservation(fixedScheduleSchema)) fixedScheduleSchema else null + if (findOrCreateObservation(it)) it else null } } - private fun findOrCreateObservation(schedule: ScheduleSchema): Boolean { - return (runningObservations[schedule.scheduleId.toHexString()] + private fun findOrCreateObservation(schedule: ScheduleEntity): Boolean { + return (runningObservations[schedule.scheduleId] ?: observationFactory.observation(schedule.observationType) ?.let { observation -> Napier.d(tag = "ObservationManager::findOrCreateObservation") { "Found Observation for ScheduleSchema: $schedule : $observation" } - runningObservations[schedule.scheduleId.toHexString()] = observation + runningObservations[schedule.scheduleId] = observation scheduleSchemaList.add(schedule) schedule }) != null } - private fun setObservationState(schedule: ScheduleSchema, state: ScheduleState) { + private fun setObservationState(schedule: ScheduleEntity, state: ScheduleState) { Napier.i(tag = "ObservationManager::setObservationState") { "New Schedule State for Schema: $schedule; ${schedule.state} -> $state" } - setObservationState(schedule.scheduleId.toHexString(), state) + setObservationState(schedule.scheduleId, state) } private fun setObservationState(scheduleId: String, state: ScheduleState) { - if (state != ScheduleState.DONE) { - scheduleRepository.setRunningStateFor(scheduleId, state) - } else { - scheduleRepository.setCompletionStateFor(scheduleId, true) - dataPointCountRepository.delete(scheduleId) + studyScope.launch(dispatchers.io) { + if (state != ScheduleState.DONE) { + repositories.schedule.setRunningStateFor(scheduleId, state) + } else { + repositories.schedule.setCompletionStateFor(scheduleId, true) + repositories.dataPointCount.delete(scheduleId) + } + }.second.invokeOnCompletion { + Napier.d(tag = "ObservationManager::setObservationState") { "Schedule state updated for $scheduleId to $state" } } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt new file mode 100644 index 000000000..1552db49c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt @@ -0,0 +1,24 @@ +package io.redlink.more.observations + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object ObservationStates { + private val _observationErrors = MutableStateFlow>>(emptyMap()) + + @NativeCoroutines + val observationErrors: StateFlow>> = _observationErrors + + fun updateObservationErrors(observationType: String, errors: Set) { + if (errors.isEmpty()) { + _observationErrors.value = _observationErrors.value - observationType + } else { + _observationErrors.value = _observationErrors.value + (observationType to errors) + } + } + + fun resetAll() { + _observationErrors.value = emptyMap() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt new file mode 100644 index 000000000..ab58cd0a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt @@ -0,0 +1,343 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations.appUsage + +import io.github.aakira.napier.Napier +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.logging.EventCollection +import io.redlink.more.logging.EventObserver +import io.redlink.more.observations.Observation +import io.redlink.more.observations.appUsage.model.EventModel +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.longRunningObservation.InMemoryLongRunningObservationStorage +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.services.store.PermissionRepository +import io.redlink.more.services.store.PermissionType +import kotlinx.datetime.Clock + +class AppUsageObservation( + repos: MainRepository, + private val permissionRepository: PermissionRepository +) : Observation(repos, AppUsageObservationType()), EventObserver { + + private sealed class BufferedData { + data class Instant(val data: EventModel, val timestamp: Long) : BufferedData() + data class Range( + val eventKey: String, + val identifier: String, + val startTimestamp: Long, + val endTimestamp: Long, + val storeWithoutApproval: Boolean = false + ) : BufferedData() + } + + private val dataBuffer = mutableListOf() + + private val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { data, timestamp -> + if (data is EventModel) { + val logEvent = data.eventType + if ((trackingApproval == PermissionApprovalState.GRANTED || logEvent.storeWithoutApproval) && observationIds.isNotEmpty() && scheduleIds.isNotEmpty()) { + storeImmediate(data, timestamp) + } else { + dataBuffer.add(BufferedData.Instant(data, timestamp)) + persistBuffer() + } + } + }, + onFinish = { data, identifier, startTimestamp, endTimestamp -> + val dataList = data as? List<*> + val firstEventModel = dataList?.firstOrNull() as? Pair<*, *> + val firstEvent = (firstEventModel?.first as? EventModel)?.eventType + val eventKey = firstEvent?.family?.key ?: firstEvent?.key ?: "unknown" + val logEvent = firstEvent + if ((trackingApproval == PermissionApprovalState.GRANTED || logEvent?.storeWithoutApproval == true) && observationIds.isNotEmpty() && scheduleIds.isNotEmpty()) { + storeRange(eventKey, identifier, startTimestamp, endTimestamp) + } else { + dataBuffer.add( + BufferedData.Range( + eventKey, + identifier, + startTimestamp, + endTimestamp, + logEvent?.storeWithoutApproval == true + ) + ) + persistBuffer() + } + } + ) + private var trackingApproval: PermissionApprovalState = PermissionApprovalState.NOT_SET + + init { + setLongRunningObservationStorage(storage) + trackingApproval = permissionRepository.getPermission(PermissionType.APP_TRACKING) + loadBuffer() + EventCollection.addObserver(this) + } + + private fun loadBuffer() { + permissionRepository.loadValue(BUFFER_KEY)?.let { json -> + try { + val lines = json.split("\n").filter { it.isNotBlank() } + lines.forEach { line -> + val parts = line.split("|") + if (parts.size >= 2) { + when (parts[0]) { + "INSTANT" -> { + if (parts.size >= 5) { + val timestamp = parts[1].toLongOrNull() ?: 0L + val eventType = LogEvent.entries.find { it.key == parts[2] } + ?: LogEvent.OBSERVATION_EVENT + val eventTimestamp = parts[3].toLongOrNull() ?: 0L + val identifier = parts[4].takeIf { it != "null" } + val eventData = + identifier?.let { mapOf("identifier" to it) } ?: emptyMap() + dataBuffer.add( + BufferedData.Instant( + EventModel(eventTimestamp, eventType, eventData), + timestamp + ) + ) + } + } + + "RANGE" -> { + if (parts.size >= 6) { + val eventKey = parts[1] + val identifier = parts[2] + val startTimestamp = parts[3].toLongOrNull() ?: 0L + val endTimestamp = parts[4].toLongOrNull() ?: 0L + val storeWithoutApproval = parts[5].toBoolean() + dataBuffer.add( + BufferedData.Range( + eventKey, + identifier, + startTimestamp, + endTimestamp, + storeWithoutApproval + ) + ) + } else if (parts.size >= 5) { + val eventKey = parts[1] + val identifier = parts[2] + val startTimestamp = parts[3].toLongOrNull() ?: 0L + val endTimestamp = parts[4].toLongOrNull() ?: 0L + dataBuffer.add( + BufferedData.Range( + eventKey, + identifier, + startTimestamp, + endTimestamp + ) + ) + } + } + } + } + } + } catch (e: Exception) { + Napier.e(tag = "AppUsageObservation::loadBuffer") { "Failed to load buffer: ${e.message}" } + } + } + } + + private fun persistBuffer() { + val json = dataBuffer.joinToString("\n") { + when (it) { + is BufferedData.Instant -> { + val identifier = it.data.eventData["identifier"] as? String ?: "null" + "INSTANT|${it.timestamp}|${it.data.eventType.key}|${it.data.timestamp}|$identifier" + } + + is BufferedData.Range -> "RANGE|${it.eventKey}|${it.identifier}|${it.startTimestamp}|${it.endTimestamp}|${it.storeWithoutApproval}" + } + } + permissionRepository.storeValue(BUFFER_KEY, json) + } + + override fun start(): Boolean { + flushBufferIfPossible() + return true + } + + override fun stop(onCompletion: () -> Unit) { + flushOpenRanges() + onCompletion() + } + + override fun applyObservationConfig(settings: Map) { + } + + override fun observerErrors(): Set { + val errors = mutableSetOf() + if (hasPermission() == PermissionApprovalState.DECLINED) { + Napier.e { "App tracking declined!" } + errors.add("app_usage_tracking_declined") + } + return errors + } + + override fun onStudyExit() { + (longRunningStorage as? InMemoryLongRunningObservationStorage)?.clear() + dataBuffer.clear() + permissionRepository.removeValue(BUFFER_KEY) + } + + override fun onEvent( + event: LogEvent, + message: String? + ) { + if (event == LogEvent.APP_TRACKING_ACCEPTED) { + if (trackingApproval != PermissionApprovalState.GRANTED) { + setTrackingApproval(true) + } else { + return + } + } + + if (event == LogEvent.APP_TRACKING_DECLINED) { + if (trackingApproval == PermissionApprovalState.GRANTED) { + flushOpenRanges() + } + if (trackingApproval != PermissionApprovalState.DECLINED) { + setTrackingApproval(false) + } + } + + val identifier = message?.takeIf { it.isNotBlank() } + val endTimestamp = Clock.System.now().toEpochMilliseconds() + val eventModel = EventModel( + timestamp = endTimestamp, + eventType = event, + eventData = identifier?.let { mapOf("identifier" to it) } ?: emptyMap() + ) + + when { + event.shouldStoreImmediately() -> { + storeInstant( + eventModel, + endTimestamp + ) + } + + event.isRangeStart() -> { + startLongRunningObservation(eventModel, identifier ?: event.key, endTimestamp) + } + + event.inRange() -> { + val startEvent = event.matchingStartEvent() ?: event + inRangeLongRunningObservation( + eventModel, + identifier ?: startEvent.key, + endTimestamp + ) + } + + event.isRangeEnd() -> { + val startEvent = event.matchingStartEvent() ?: event + finishLongRunningObservation(eventModel, identifier ?: startEvent.key, endTimestamp) + } + } + } + + private fun flushOpenRanges() { + val now = Clock.System.now().toEpochMilliseconds() + (longRunningStorage as? InMemoryLongRunningObservationStorage)?.flush(now) + } + + private fun setTrackingApproval(approval: Boolean) { + Napier.i { "Set app tracking approval to: $approval" } + permissionRepository.updatePermission(PermissionType.APP_TRACKING, approval) + trackingApproval = permissionRepository.getPermission(PermissionType.APP_TRACKING) + flushBufferIfPossible() + } + + private fun flushBufferIfPossible() { + if (observationIds.isNotEmpty() && scheduleIds.isNotEmpty()) { + val pendingData = dataBuffer.toList() + val remaining = mutableListOf() + val toStore = mutableListOf() + + pendingData.forEach { + val canStore = when (it) { + is BufferedData.Instant -> trackingApproval == PermissionApprovalState.GRANTED || it.data.eventType.storeWithoutApproval + is BufferedData.Range -> trackingApproval == PermissionApprovalState.GRANTED || it.storeWithoutApproval + } + if (canStore) toStore.add(it) else remaining.add(it) + } + + if (toStore.isNotEmpty()) { + dataBuffer.clear() + dataBuffer.addAll(remaining) + if (dataBuffer.isEmpty()) { + permissionRepository.removeValue(BUFFER_KEY) + } else { + persistBuffer() + } + toStore.forEach { + when (it) { + is BufferedData.Instant -> storeImmediate(it.data, it.timestamp) + is BufferedData.Range -> storeRange( + it.eventKey, + it.identifier, + it.startTimestamp, + it.endTimestamp + ) + } + } + } + } + } + + private fun storeImmediate(data: EventModel, timestamp: Long) { + val logEvent = data.eventType + val eventKey = logEvent.family?.key ?: logEvent.key + storeData( + mapOf( + DATA_KEY to mapOf( + "eventKey" to eventKey, + "identifier" to (data.eventData["identifier"] as? String ?: logEvent.key), + "timestamp" to data.timestamp + ) + ), + timestamp / 1000 + ) {} + } + + private fun storeRange( + eventKey: String, + identifier: String, + startTimestamp: Long, + endTimestamp: Long + ) { + storeData( + mapOf( + DATA_KEY to mapOf( + "eventKey" to eventKey, + "identifier" to identifier, + "startTimestamp" to startTimestamp, + "endTimestamp" to endTimestamp + ) + ), + endTimestamp / 1000 + ) {} + } + + + companion object { + private const val DATA_KEY = "USER_ACTION" + private const val BUFFER_KEY = "app_usage_data_buffer" + } +} + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SettingsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt similarity index 65% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SettingsModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt index a74b7ed8a..ca5676890 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SettingsModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt @@ -8,12 +8,13 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.models +package io.redlink.more.observations.appUsage.model -data class SettingsModel ( - val studyTitle: String, - val observationConsent: List, - val settingsTitle: String, - val settingsDescription: String, +import kotlinx.datetime.Clock + +data class EventModel( + val timestamp: Long = Clock.System.now().toEpochMilliseconds(), + val eventType: LogEvent, + val eventData: Map = emptyMap() ) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Greeting.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt similarity index 67% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Greeting.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt index 6a38eb7a3..bd4159e8b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Greeting.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt @@ -8,12 +8,14 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform -class Greeting { - private val platform: Platform = getPlatform() +package io.redlink.more.observations.appUsage.model - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file +import kotlinx.serialization.Serializable + +@Serializable +data class InstantObservationPayload( + val eventKey: String, + val identifier: String, + val timestamp: Long +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt new file mode 100644 index 000000000..b12482097 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt @@ -0,0 +1,107 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations.appUsage.model + +enum class EventStorageMode { + INSTANT, + RANGE_START, + IN_RANGE, + RANGE_END +} + +enum class EventFamily(val key: String) { + APP_VISIBILITY("app_visibility"), + VIEW("view_visibility") +} + +enum class LogEvent( + val key: String, + val storageMode: EventStorageMode, + val family: EventFamily? = null, + val storeWithoutApproval: Boolean = false +) { + APP_IN_FOREGROUND( + key = "app_in_foreground", + storageMode = EventStorageMode.RANGE_START, + family = EventFamily.APP_VISIBILITY, + ), + APP_IN_BACKGROUND( + key = "app_in_background", + storageMode = EventStorageMode.RANGE_END, + family = EventFamily.APP_VISIBILITY, + ), + VIEW_OPEN( + key = "view_open", + storageMode = EventStorageMode.RANGE_START, + family = EventFamily.VIEW + ), + VIEW_CLOSED( + key = "view_closed", + storageMode = EventStorageMode.RANGE_END, + family = EventFamily.VIEW + ), + BUTTON_PRESS( + key = "button_press", + storageMode = EventStorageMode.INSTANT, + ), + NOTIFICATION_INTERACTION( + key = "notification_interaction", + storageMode = EventStorageMode.INSTANT + ), + URL_OPEN( + key = "url_open", + storageMode = EventStorageMode.INSTANT + ), + APP_TRACKING_ACCEPTED( + key = "app_tracking_accepted", + storageMode = EventStorageMode.INSTANT, + storeWithoutApproval = true + ), + OBSERVATION_EVENT( + key = "observation_event", + storageMode = EventStorageMode.INSTANT + ), + APP_TRACKING_DECLINED( + key = "app_tracking_declined", + storageMode = EventStorageMode.INSTANT, + storeWithoutApproval = true + ); + + fun aggregateKey(identifier: String): String = + "${family?.name ?: key}:$identifier" + + fun isRangeEvent(): Boolean = storageMode != EventStorageMode.INSTANT + + fun isRangeStart(): Boolean = storageMode == EventStorageMode.RANGE_START + + fun inRange(): Boolean = storageMode == EventStorageMode.IN_RANGE + fun isRangeEnd(): Boolean = storageMode == EventStorageMode.RANGE_END + + fun shouldStoreImmediately(): Boolean = storageMode == EventStorageMode.INSTANT + + fun matchingStartEvent(): LogEvent? = entries.firstOrNull { + it.family == family && it.storageMode == EventStorageMode.RANGE_START + } + + fun matchingEndEvent(): LogEvent? = entries.firstOrNull { + it.family == family && it.storageMode == EventStorageMode.RANGE_END + } + + fun canBeStored(openFamilies: Set): Boolean { + if (storeWithoutApproval) return true + return when (storageMode) { + EventStorageMode.INSTANT -> true + EventStorageMode.RANGE_START, EventStorageMode.IN_RANGE -> false + EventStorageMode.RANGE_END -> family != null && family in openFamilies + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt new file mode 100644 index 000000000..59655560a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt @@ -0,0 +1,22 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations.appUsage.model + +import kotlinx.serialization.Serializable + +@Serializable +data class RangeObservationPayload( + val eventKey: String, + val identifier: String, + val startTimestamp: Long, + val endTimestamp: Long +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/garmin/GarminObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/garmin/GarminObservation.kt new file mode 100644 index 000000000..36f878b41 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/garmin/GarminObservation.kt @@ -0,0 +1,20 @@ +package io.redlink.more.observations.garmin + +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.GarminType + +class GarminObservation(repos: MainRepository) : + Observation(repos, GarminType()) { + override fun start(): Boolean { + return true + } + + override fun stop(onCompletion: () -> Unit) { + onCompletion() + } + + override fun applyObservationConfig(settings: Map) { + + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/limesurvey/LimeSurveyObservation.kt similarity index 70% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/limesurvey/LimeSurveyObservation.kt index c2f709b94..afc0db449 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/limesurvey/LimeSurveyObservation.kt @@ -8,18 +8,20 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations.limesurvey +package io.redlink.more.observations.limesurvey import io.github.aakira.napier.Napier import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol import io.ktor.http.parametersOf -import io.redlink.more.more_app_mutliplatform.extensions.setNullable -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.LimeSurveyType +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.setNullable +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.LimeSurveyType import kotlinx.coroutines.flow.MutableStateFlow -class LimeSurveyObservation : Observation(observationType = LimeSurveyType()) { +class LimeSurveyObservation(repos: MainRepository) : + Observation(repos, observationType = LimeSurveyType()) { val limeURL = MutableStateFlow(null) override fun start(): Boolean { @@ -35,10 +37,7 @@ class LimeSurveyObservation : Observation(observationType = LimeSurveyType()) { val limeSurveyId = settings[LIMESURVEY_ID]?.toString()?.trim('\"') val token = settings[LIMESURVEY_TOKEN]?.toString()?.trim('\"') val limeSurveyLink = (settings[LIMESURVEY_URL]?.toString()?.trim('\"') - ?: "https://lime.platform-test.more.redlink.io").replaceFirst( - Regex("^(http://|https://)"), - "" - ) + ?: "https://lime.platform-test.umm.redlink.io") if (token != null && limeSurveyId != null) { val url = configToLink(limeSurveyLink, limeSurveyId, token) Napier.i { "LimeSurvey link: $url" } @@ -55,9 +54,17 @@ class LimeSurveyObservation : Observation(observationType = LimeSurveyType()) { } private fun configToLink(url: String, surveyId: String, token: String): String { + val protocol = when { + url.startsWith("http://", ignoreCase = true) -> URLProtocol.HTTP + url.startsWith("https://", ignoreCase = true) -> URLProtocol.HTTPS + else -> URLProtocol.HTTPS + } + + val cleanUrl = url.replaceFirst(Regex("^(http://|https://)", RegexOption.IGNORE_CASE), "") + return URLBuilder( - URLProtocol.HTTPS, - url, + protocol, + cleanUrl, pathSegments = listOf(surveyId), parameters = parametersOf("token", token) ).build().toString() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt new file mode 100644 index 000000000..68e10809a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt @@ -0,0 +1,128 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations.longRunningObservation + +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.appUsage.model.LogEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Default implementation of LongRunningObservationStorage using AggregatedObservationDataRepository. + */ +class DefaultLongRunningObservationStorage( + private val repos: MainRepository, + private val observationId: String, + private val observationType: String, + private val toDataString: (Any) -> String +) : LongRunningObservationStorage { + + private val scope = CoroutineScope(Dispatchers.IO) + + override fun storeInstant(data: T, timestamp: Long) { + scope.launch { + if (data != null) { + val observationDataEntity = ObservationDataEntity( + observationId = observationId, + observationType = observationType, + dataValue = toDataString(data), + timestamp = timestamp + ) + repos.observationData.addData(listOf(observationDataEntity)) + } + } + } + + override fun startObservation(data: T, identifier: String, timestamp: Long) { + scope.launch { + if (data != null) { + val metadata = + if (data is LogEvent) data.aggregateKey(identifier ?: "") else identifier ?: "" + val entity = AggregatedObservationDataEntity.fromData( + observationId = observationId, + observationType = observationType, + data = data, + startTimestamp = timestamp, + endTimestamp = timestamp, + metadata = metadata + ) + repos.aggregatedObservationData.insert(entity) + } + } + } + + private suspend fun getOpenObservationEntity( + data: T, + identifier: String + ): AggregatedObservationDataEntity? { + val openEntities = repos.aggregatedObservationData.getByObservationId(observationId) + if (data is LogEvent) { + val key = data.aggregateKey(identifier) + val exactMatch = openEntities.firstOrNull { it.metadata == key } + if (exactMatch != null) return exactMatch + + if (data.family != null) { + val familyPrefix = "${data.family.name}:" + return openEntities.firstOrNull { it.metadata.startsWith(familyPrefix) } + } + } + return if (openEntities.any { it.metadata == identifier }) { + openEntities.first { it.metadata == identifier } + } else { + openEntities.firstOrNull() + } + } + + override fun updateObservation( + data: T, + identifier: String, + timestamp: Long + ) { + scope.launch { + getOpenObservationEntity(data, identifier)?.let { entity -> + if (data != null) { + repos.aggregatedObservationData.update( + entity.update( + data = data, + metadata = entity.metadata, + timestamp = timestamp + ) + ) + } + } + } + } + + override fun inRangeObservation(data: T, identifier: String, timestamp: Long) { + updateObservation(data, identifier, timestamp) + } + + override fun finishObservation(data: T, identifier: String, timestamp: Long) { + scope.launch { + getOpenObservationEntity(data, identifier)?.let { entity -> + if (data != null) { + val updatedEntity = entity.update( + data = data, + metadata = entity.metadata, + timestamp = timestamp + ) + repos.observationData.addData(listOf(updatedEntity.toObservationDataEntity>>())) + repos.aggregatedObservationData.delete(entity) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt new file mode 100644 index 000000000..78432d1e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt @@ -0,0 +1,89 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations.longRunningObservation + +/** + * In-memory implementation of LongRunningObservationStorage. + * Stores data into a map and provides simple lifecycle management for long-running observations. + */ +class InMemoryLongRunningObservationStorage( + private val onStoreInstant: (data: Any, timestamp: Long) -> Unit, + private val onFinish: (data: Any, identifier: String, startTimestamp: Long, endTimestamp: Long) -> Unit +) : LongRunningObservationStorage { + + private val openObservations = mutableMapOf>, Long>>() + + override fun storeInstant(data: T, timestamp: Long) { + if (data != null) { + onStoreInstant(data, timestamp) + } + } + + override fun startObservation(data: T, identifier: String, timestamp: Long) { + if (!openObservations.containsKey(identifier) && data != null) { + val list: MutableList> = mutableListOf((data as Any) to timestamp) + openObservations[identifier] = list to timestamp + } + } + + private fun findOpenKey(identifier: String): String? { + return if (openObservations.containsKey(identifier)) { + identifier + } else { + openObservations.keys.firstOrNull() + } + } + + override fun updateObservation( + data: T, + identifier: String, + timestamp: Long + ) { + findOpenKey(identifier)?.let { key -> + openObservations[key]?.let { (dataList, _) -> + if (data != null) { + dataList.add((data as Any) to timestamp) + } + } + } + } + + override fun inRangeObservation(data: T, identifier: String, timestamp: Long) { + updateObservation(data, identifier, timestamp) + } + + override fun finishObservation(data: T, identifier: String, timestamp: Long) { + val key = findOpenKey(identifier) + if (key != null) { + openObservations.remove(key)?.let { (dataList, startTimestamp) -> + if (data != null) { + dataList.add((data as Any) to timestamp) + } + onFinish(dataList, key, startTimestamp, timestamp) + } + } + } + + fun flush(timestamp: Long) { + val pending = openObservations.toMap() + openObservations.clear() + pending.forEach { (identifier, pair) -> + onFinish(pair.first, identifier, pair.second, timestamp) + } + } + + fun clear() { + openObservations.clear() + } + + fun hasOpenObservation(identifier: String): Boolean = openObservations.containsKey(identifier) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt new file mode 100644 index 000000000..5009d4e58 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt @@ -0,0 +1,65 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations.longRunningObservation + +/** + * Interface for managing long-running observations. + * @param T The type of the observation data or identifier. + */ +interface LongRunningObservationStorage { + /** + * Stores instant data. + * @param data The observation data. + * @param timestamp The timestamp in epoch milliseconds. + */ + fun storeInstant(data: T, timestamp: Long) + + /** + * Starts a long-running observation. + * @param data The observation data or event triggering the start. + * @param identifier A unique identifier for the observation range. + * @param timestamp The start timestamp in epoch milliseconds. + */ + fun startObservation(data: T, identifier: String, timestamp: Long) + + /** + * Updates an ongoing long-running observation. + * @param data The observation data or event. + * @param identifier A unique identifier for the observation range. + * @param timestamp The update timestamp in epoch milliseconds. + */ + fun updateObservation( + data: T, + identifier: String, + timestamp: Long + ) + + /** + * Adds an in-range event to an ongoing long-running observation. + * @param data The observation data or event. + * @param identifier A unique identifier for the observation range. + * @param timestamp The update timestamp in epoch milliseconds. + */ + fun inRangeObservation( + data: T, + identifier: String, + timestamp: Long + ) + + /** + * Finishes a long-running observation and persists the result. + * @param data The observation data or event triggering the finish. + * @param identifier A unique identifier for the observation range. + * @param timestamp The end timestamp in epoch milliseconds. + */ + fun finishObservation(data: T, identifier: String, timestamp: Long) +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/AccelerometerType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AccelerometerType.kt similarity index 88% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/AccelerometerType.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AccelerometerType.kt index b95eaf304..986a25431 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/AccelerometerType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AccelerometerType.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes +package io.redlink.more.observations.observationTypes class AccelerometerType(sensorPermissions: Set) : ObservationType("acc-mobile-observation", sensorPermissions) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/HeartRateListener.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt similarity index 75% rename from androidApp/src/main/java/io/redlink/more/app/android/observations/HR/HeartRateListener.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt index b32f13831..cf4214c93 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/HeartRateListener.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt @@ -8,9 +8,8 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.app.android.observations.HR -interface HeartRateListener { - fun onHeartRateUpdate(hr: Int) - fun onHeartRateReady() +package io.redlink.more.observations.observationTypes + +class AppUsageObservationType : ObservationType("app-usage-observation", setOf("appTracking")) { } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GPSType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GPSType.kt similarity index 88% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GPSType.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GPSType.kt index 3d3888650..cc5583a9f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GPSType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GPSType.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes +package io.redlink.more.observations.observationTypes class GPSType(sensorPermissions: Set) : ObservationType("gps-mobile-observation", sensorPermissions) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GarminType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GarminType.kt new file mode 100644 index 000000000..8cf25a527 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GarminType.kt @@ -0,0 +1,7 @@ +package io.redlink.more.observations.observationTypes + +class GarminType : ObservationType("garmin-observation", emptySet(), prefix = PREFIX) { + companion object { + const val PREFIX = "garmin-" + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/LimeSurveyType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/LimeSurveyType.kt similarity index 75% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/LimeSurveyType.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/LimeSurveyType.kt index f82705a96..de2959bd6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/LimeSurveyType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/LimeSurveyType.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes +package io.redlink.more.observations.observationTypes -class LimeSurveyType: ObservationType("lime-survey-observation", emptySet()) { +class LimeSurveyType : ObservationType("lime-survey-observation", emptySet()) { } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt new file mode 100644 index 000000000..854010267 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt @@ -0,0 +1,30 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.observations.observationTypes + +open class ObservationType( + val observationType: String, + val sensorPermissions: Set, + val prefix: String? = null, + val suffix: String? = null, + val includes: String? = null +) { + + + fun matches(type: String): Boolean { + return type == observationType + || (prefix != null && type.startsWith(prefix)) + || (suffix != null && type.endsWith(suffix)) + || (includes != null && type.contains(includes)) + } + + fun matchesAny(types: Set): Boolean = types.any { matches(it) } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/PolarVerityHeartRateType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/PolarVerityHeartRateType.kt new file mode 100644 index 000000000..83e3c40b3 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/PolarVerityHeartRateType.kt @@ -0,0 +1,15 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.observations.observationTypes + +class PolarVerityHeartRateType(sensorPermissions: Set) : + ObservationType("polar-verity-observation", sensorPermissions) { +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/QuestionType.kt similarity index 74% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/QuestionType.kt index 40f16be44..39d012f88 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/QuestionType.kt @@ -8,8 +8,8 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes +package io.redlink.more.observations.observationTypes -class SimpleQuestionType () : - ObservationType("question-observation", setOf()){ +class QuestionType : + ObservationType("question-observation", setOf(), suffix = "question-observation") { } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/questionObservation/QuestionObservation.kt similarity index 69% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/questionObservation/QuestionObservation.kt index ff3e90799..fcb161482 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/questionObservation/QuestionObservation.kt @@ -8,12 +8,14 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations.simpleQuestionObservation +package io.redlink.more.observations.questionObservation -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.SimpleQuestionType +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.QuestionType -class SimpleQuestionObservation : Observation(observationType = SimpleQuestionType()) { +class QuestionObservation(repos: MainRepository) : + Observation(repos, observationType = QuestionType()) { override fun start(): Boolean { return true } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt new file mode 100644 index 000000000..88eaad8c6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt @@ -0,0 +1,166 @@ +package io.redlink.more.registration + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.ktor.util.encodeBase64 +import io.ktor.utils.io.core.toByteArray +import io.redlink.more.Shared +import io.redlink.more.app.android.services.network.errors.NetworkServiceError +import io.redlink.more.getPlatform +import io.redlink.more.models.CredentialModel +import io.redlink.more.models.LoginModel +import io.redlink.more.scopes.Scope +import io.redlink.more.services.network.openapi.model.ObservationConsent +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.services.network.openapi.model.StudyConsent +import io.redlink.more.services.store.EndpointRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.kotlincrypto.hash.md.MD5 + +open class RegistrationService( + private val shared: Shared, +) { + private val _validLoginModel = MutableStateFlow(null) + + @NativeCoroutines + val validLoginModel: StateFlow = _validLoginModel + private val _study = MutableStateFlow(null) + + @NativeCoroutines + val study: StateFlow = _study + + private val _error = MutableStateFlow(null) + + @NativeCoroutines + val error: StateFlow = _error + private val _isLoading = MutableStateFlow(false) + + @NativeCoroutines + val isLoading: StateFlow = _isLoading + + private val _connected = MutableStateFlow(false) + + @NativeCoroutines + val connected: StateFlow = _connected + + init { + Scope.launch(Dispatchers.IO) { + shared.connectionStatusFlow.collect { + _connected.value = it + Napier.i("Device connected: $it") + } + } + } + + fun getEndpointRepository(): EndpointRepository = shared.endpointRepository + + open fun clearError() { + _error.value = null + } + + open fun sendRegistrationToken( + loginModel: LoginModel + ) { + clearError() + _isLoading.value = true + Scope.launch { + val (result, networkError) = shared.networkService.validateRegistrationToken(loginModel) + result?.let { + _study.value = it + _validLoginModel.value = loginModel + addObservationPermissions(it) + } + _error.value = networkError + if (networkError != null) { + Napier.e(tag = "RegistrationService::sendRegistrationToken") { "Error sending registration token: $networkError" } + } + }.second.invokeOnCompletion { + _isLoading.value = false + } + } + + fun acceptConsent( + uniqueDeviceId: String, + ) { + clearError() + validLoginModel.value?.let { loginModel -> + study.value?.let { study -> + val studyConsent = StudyConsent( + consent = true, + observations = study.observations.map { + ObservationConsent( + observationId = it.observationId, + active = true + ) + }, + consentInfoMD5 = MD5().digest(study.consentInfo.toByteArray()) + .encodeBase64(), + deviceId = "${getPlatform().productName}#$uniqueDeviceId" + ) + sendConsent(studyConsent) + } + } + } + + private fun sendConsent( + studyConsent: StudyConsent, + ) { + _isLoading.value = true + Scope.launch(Dispatchers.IO) { + val (config, networkError) = shared.networkService.sendConsent( + _validLoginModel.value!!, + studyConsent + ) + _error.value = networkError + if (config != null) { + shared.credentialRepository.remove() + shared.removeStudyData() + + config.endpoint?.let { + shared.endpointRepository.storeEndpoint(it) + } + val credentialModel = + CredentialModel(config.credentials.apiId, config.credentials.apiKey) + val (study, error) = shared.networkService.getStudyConfig(credentialModel) + _error.value = error + study?.let { study -> + shared.observationFactory.clearNeededObservationTypes() + if (shared.credentialRepository.store(credentialModel)) { + shared.repositories.study.upsert(study) + shared.newLogin() + } else { + _error.value = NetworkServiceError(null, "Could not store credentials") + } + } ?: run { + if (_error.value == null) { + _error.value = NetworkServiceError(null, "Could not get study") + } + } + + } + }.second.invokeOnCompletion { + _isLoading.value = false + Scope.launch(Dispatchers.IO) { + if (shared.credentialRepository.hasCredentials.value) { + clearError() + _validLoginModel.value = null + _study.value = null + } + } + } + } + + fun declineConsent() { + _study.value = null + _validLoginModel.value = null + clearError() + } + + private fun addObservationPermissions(study: Study) { + shared.observationFactory + .addNeededObservationTypes(study.observations.map { it.observationType }.toSet()) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreDispatchers.kt b/shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreDispatchers.kt new file mode 100644 index 000000000..63b9558e9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreDispatchers.kt @@ -0,0 +1,37 @@ +package io.redlink.more.scopes + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +interface MoreDispatchers { + val default: CoroutineDispatcher + val main: CoroutineDispatcher + val io: CoroutineDispatcher +} + +object AppDispatchers : MoreDispatchers { + private var _default: CoroutineDispatcher = Dispatchers.Default + private var _main: CoroutineDispatcher = Dispatchers.Main + private var _io: CoroutineDispatcher = Dispatchers.IO + + override val default: CoroutineDispatcher get() = _default + override val main: CoroutineDispatcher get() = _main + override val io: CoroutineDispatcher get() = _io + + fun set( + default: CoroutineDispatcher = Dispatchers.Default, + main: CoroutineDispatcher = Dispatchers.Main, + io: CoroutineDispatcher = Dispatchers.IO + ) { + _default = default + _main = main + _io = io + } + + fun reset() { + _default = Dispatchers.Default + _main = Dispatchers.Main + _io = Dispatchers.IO + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreScope.kt b/shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreScope.kt new file mode 100644 index 000000000..1bf926de4 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreScope.kt @@ -0,0 +1,25 @@ +package io.redlink.more.scopes + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlin.coroutines.CoroutineContext + +interface MoreScope : CoroutineScope { + fun launch( + coroutineContext: CoroutineContext = AppDispatchers.default, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Pair + + fun repeatedLaunch( + intervalMillis: Long, + coroutineContext: CoroutineContext = AppDispatchers.default, + initalDelay: Long = 0, + block: suspend CoroutineScope.() -> Unit + ): Pair + + fun cancel(uuid: String) + fun cancel(uuids: Collection) + fun cancel() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt b/shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt new file mode 100644 index 000000000..e504d4cfa --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt @@ -0,0 +1,184 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.scopes + +import io.github.aakira.napier.Napier +import io.redlink.more.extensions.repeatEveryFewSeconds +import io.redlink.more.util.createUUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext + +object Scope : MoreScope { + private val mutex = Mutex() + private val rootJob = SupervisorJob() + private val exceptionHandler = CoroutineExceptionHandler { _, exception -> + Napier.e(throwable = exception, message = "Caught $exception in CoroutineExceptionHandler") + } + private val scope = CoroutineScope(rootJob + AppDispatchers.default + exceptionHandler) + override val coroutineContext: CoroutineContext = scope.coroutineContext + private val jobs = mutableMapOf() + + override fun launch( + coroutineContext: CoroutineContext, + start: CoroutineStart, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val uuid = createUUID() + val job = scope.launch(coroutineContext + exceptionHandler, start, block) + + scope.launch { + mutex.withLock { + jobs[uuid] = job + } + } + + job.invokeOnCompletion { cause -> + scope.launch { + mutex.withLock { + try { + jobs.remove(uuid) + cause?.let { + if (it !is CancellationException) { + Napier.w(throwable = it) { "Job with UUID: $uuid completed with exception" } + } + } + } catch (e: Exception) { + if (e !is CancellationException) { + Napier.e(tag = "Scope::launch::cleanup") { e.stackTraceToString() } + } + } + } + } + } + + return Pair(uuid, job) + } + + fun create(): Pair { + val uuid = createUUID() + val job = Job(rootJob) + + scope.launch { + mutex.withLock { + jobs[uuid] = job + } + } + + job.invokeOnCompletion { cause -> + scope.launch { + mutex.withLock { + try { + jobs.remove(uuid) + cause?.let { + if (it !is CancellationException) { + Napier.w(throwable = it) { "Job with UUID: $uuid was completed with exception" } + } + } + } catch (e: Exception) { + if (e !is CancellationException) { + Napier.e(tag = "Scope::create::cleanup") { e.stackTraceToString() } + } + } + } + } + } + + return Pair(uuid, job) + } + + fun isActive(uuid: String) = jobs[uuid]?.isActive ?: false + + override fun repeatedLaunch( + intervalMillis: Long, + coroutineContext: CoroutineContext, + initalDelay: Long, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val uuid = createUUID() + val job = scope.repeatEveryFewSeconds(intervalMillis, initalDelay, coroutineContext, block) + + scope.launch { + mutex.withLock { + jobs[uuid] = job + } + } + + job.invokeOnCompletion { cause -> + scope.launch { + mutex.withLock { + try { + jobs.remove(uuid) + cause?.let { + if (it !is CancellationException) { + Napier.w(throwable = it) { "Repeated job with UUID: $uuid completed with exception" } + } + } + } catch (e: Exception) { + if (e !is CancellationException) { + Napier.e(tag = "Scope::repeatedLaunch::cleanup") { e.stackTraceToString() } + } + } + } + } + } + + return Pair(uuid, job) + } + + override fun cancel(uuid: String) { + scope.launch { + mutex.withLock { + jobs[uuid]?.cancel() + } + } + } + + override fun cancel(uuids: Collection) { + if (uuids.isEmpty()) return + + scope.launch { + mutex.withLock { + try { + uuids.forEach { uuid -> + jobs[uuid]?.cancel() + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + Napier.e(tag = "Scope::cancel::batch") { exception.stackTraceToString() } + } + } + } + } + } + + override fun cancel() { + scope.launch { + mutex.withLock { + try { + rootJob.cancelChildren() + } catch (exception: Exception) { + if (exception !is CancellationException) { + Napier.e(tag = "Scope::cancel::all") { exception.stackTraceToString() } + } + } + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyMoreScope.kt b/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyMoreScope.kt new file mode 100644 index 000000000..e57118dac --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyMoreScope.kt @@ -0,0 +1,7 @@ +package io.redlink.more.scopes + +interface StudyMoreScope : MoreScope { + override fun cancel(uuid: String) + override fun cancel(uuids: Collection) + override fun cancel() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt b/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt new file mode 100644 index 000000000..74ea40078 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt @@ -0,0 +1,97 @@ +package io.redlink.more.scopes + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext + +object StudyScope : StudyMoreScope { + private val mutex = Mutex() + private val studyJobs = mutableSetOf() + override val coroutineContext: CoroutineContext = Scope.coroutineContext + + override fun launch( + coroutineContext: CoroutineContext, + start: CoroutineStart, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val result = Scope.launch(coroutineContext, start, block) + + Scope.launch { + mutex.withLock { + studyJobs.add(result.first) + } + } + + result.second.invokeOnCompletion { + Scope.launch { + mutex.withLock { + studyJobs.remove(result.first) + } + } + } + + return result + } + + override fun repeatedLaunch( + intervalMillis: Long, + coroutineContext: CoroutineContext, + initalDelay: Long, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val result = Scope.repeatedLaunch(intervalMillis, coroutineContext, initalDelay, block) + + Scope.launch { + mutex.withLock { + studyJobs.add(result.first) + } + } + + result.second.invokeOnCompletion { + Scope.launch { + mutex.withLock { + studyJobs.remove(result.first) + } + } + } + + return result + } + + override fun cancel(uuid: String) { + Scope.cancel(uuid) + } + + override fun cancel(uuids: Collection) { + Scope.cancel(uuids) + } + + override fun cancel() { + val jobsToCancel = mutex.tryLock().let { acquired -> + if (acquired) { + try { + studyJobs.toList() + } finally { + mutex.unlock() + } + } else { + Scope.launch { + mutex.withLock { + val snapshot = studyJobs.toList() + if (snapshot.isNotEmpty()) { + Scope.cancel(snapshot) + } + } + } + return + } + } + + if (jobsToCancel.isNotEmpty()) { + Scope.cancel(jobsToCancel) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt new file mode 100644 index 000000000..e71e2530c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt @@ -0,0 +1,59 @@ +package io.redlink.more.services + +import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.models.ScheduleState +import io.redlink.more.services.notification.NotificationManager +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimePeriod +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus + +class ObservationService( + private val repositories: MainRepository, + private val notificationManager: NotificationManager, + private val schedulingLimit: Int? = null +) { + suspend fun scheduleObservationReminder() { + Napier.i(tag = "ObservationService::scheduleObservationReminder") { "Starting scheduleObservationReminder()" } + val now = Clock.System.now() + val daysFromNow: Instant = + now.plus(DateTimePeriod(days = DAYS_INTO_FUTURE), TimeZone.currentSystemDefault()) + repositories.schedule.getSchedulesWithReminder( + setOf(ScheduleState.DEACTIVATED), + now, + daysFromNow, + schedulingLimit ?: MAX_SCHEDULE_COUNT + ).firstOrNull() + ?.filter { it.start != null } + ?.let { schedules: List -> + if (schedules.isNotEmpty()) { + notificationManager.scheduleObservationReminders(schedules) + Napier.i(tag = "ObservationService::scheduleObservationReminder") { "Scheduled ${schedules.size} notifications!" } + } else { + Napier.i(tag = "ObservationService::scheduleObservationReminder") { "No schedules with reminder found." } + } + } + } + + suspend fun rescheduleObservationRemindersAfterBoot() { + Napier.i(tag = "ObservationService::rescheduleObservationRemindersAfterBoot") { "Starting rescheduleObservationRemindersAfterBoot()" } + val scheduledNotifications = repositories.notification.scheduledNotifications() + if (scheduledNotifications.isNotEmpty()) { + notificationManager.rescheduleNotifications(scheduledNotifications) + Napier.i(tag = "ObservationService::rescheduleObservationRemindersAfterBoot") { "Rescheduled ${scheduledNotifications.size} notifications after boot." } + } + } + + suspend fun clearReminders() { + notificationManager.clearScheduledNotifications() + } + + companion object { + private const val MAX_SCHEDULE_COUNT = 15 + private const val DAYS_INTO_FUTURE = 7 + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothConnector.kt similarity index 81% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothConnector.kt index d241844fa..4d494d123 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothConnector.kt @@ -8,18 +8,15 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.bluetooth +package io.redlink.more.services.bluetooth import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.BluetoothDeviceEntity interface BluetoothConnector : BluetoothConnectorObserver, Closeable { var observer: MutableSet - var bluetoothState: BluetoothState - - var scanning: Boolean - val specificBluetoothConnectors: MutableMap fun addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) @@ -30,13 +27,11 @@ interface BluetoothConnector : BluetoothConnectorObserver, Closeable { fun updateObserver(action: (BluetoothConnectorObserver) -> Unit) - fun replayStates() - fun scan() - fun connect(device: BluetoothDevice): Error? + fun connect(device: BluetoothDeviceEntity): Error? - fun disconnect(device: BluetoothDevice) + fun disconnect(device: BluetoothDeviceEntity) fun stopScanning() override fun close() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothConnectorObserver.kt similarity index 61% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothConnectorObserver.kt index 0cc844de1..7d46cc3ff 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothConnectorObserver.kt @@ -8,23 +8,23 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.bluetooth +package io.redlink.more.services.bluetooth -interface BluetoothConnectorObserver { +import io.redlink.more.database.entities.BluetoothDeviceEntity - fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) +interface BluetoothConnectorObserver { - fun didConnectToDevice(bluetoothDevice: BluetoothDevice) + fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) - fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) + fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) - fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) + fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) - fun didDiscoverDevice(device: BluetoothDevice) + fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) - fun removeDiscoveredDevice(device: BluetoothDevice) + fun didDiscoverDevice(device: BluetoothDeviceEntity) - fun isScanning(boolean: Boolean) + fun removeDiscoveredDevice(device: BluetoothDeviceEntity) - fun onBluetoothStateChange(bluetoothState: BluetoothState) + fun resetAll() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothState.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothState.kt similarity index 88% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothState.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothState.kt index 7b1c0f5b8..6fd6121a0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothState.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothState.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.bluetooth +package io.redlink.more.services.bluetooth enum class BluetoothState { ON, diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt new file mode 100644 index 000000000..2e307e1dd --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt @@ -0,0 +1,163 @@ +package io.redlink.more.services.bluetooth + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.BluetoothDeviceEntity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object BluetoothStateManagement { + private val _bluetoothActive = MutableStateFlow(false) + + @NativeCoroutines + val bluetoothActive: StateFlow = _bluetoothActive + + private val _scanning = MutableStateFlow(false) + + @NativeCoroutines + val scanning: StateFlow = _scanning + + private val _connectedDevices: MutableStateFlow> = + MutableStateFlow(emptySet()) + + @NativeCoroutines + val connectedDevices: StateFlow> = _connectedDevices + private val _discoveredDevices: MutableStateFlow> = + MutableStateFlow(emptySet()) + + @NativeCoroutines + val discoveredDevices: StateFlow> = _discoveredDevices + private val _pairedDevices: MutableStateFlow> = + MutableStateFlow(emptySet()) + + @NativeCoroutines + val pairedDevices: StateFlow> = _pairedDevices + private val _devicesCurrentlyConnecting: MutableStateFlow> = + MutableStateFlow( + emptySet() + ) + + @NativeCoroutines + val devicesCurrentlyConnecting: StateFlow> = + _devicesCurrentlyConnecting + + private val _uiOverride = MutableStateFlow(false) + + @NativeCoroutines + val uiOverride: StateFlow = _uiOverride + + private val _bgScanningActive = MutableStateFlow(false) + + @NativeCoroutines + val bgScanningActive: StateFlow = _bgScanningActive + + fun setBluetoothState(active: Boolean) { + _bluetoothActive.value = active + if (!active) { + resetAll() + } + Napier.i(tag = "BluetoothStateManagement::setBluetoothState") { "BLE powered on: $active" } + } + + fun isScanning(scan: Boolean) { + _scanning.value = scan + + Napier.i(tag = "BluetoothStateManagement::isScanning") { "BLE scanning active: $scan" } + } + + fun uiOverrides(override: Boolean) { + _uiOverride.value = override + Napier.i { "BLE UI overrides: $override" } + } + + fun enableBgScanning(): Boolean { + if (bgScanningActive.value) { + return false + } + _bgScanningActive.value = true + Napier.i { "BLE Background Scan enabled" } + return true + } + + fun disableBgScanning() { + if (bgScanningActive.value) { + Napier.i { "BLE Background Scan disabled" } + } + _bgScanningActive.value = false + } + + fun addConnectedDevices(devices: Set) { + _connectedDevices.value += devices + addPairedDeviceIds(devices.filter { it !in pairedDevices.value }.toSet()) + removeDiscoveredDevices(devices) + removeConnectingDevices(devices) + } + + fun removeConnectedDevices(devices: Set) { + _connectedDevices.value -= devices + _discoveredDevices.value -= devices + _connectedDevices.value -= devices + } + + fun addDiscoveredDevices(devices: Set) { + _discoveredDevices.value += devices.filter { !connectedDevices.value.contains(it) } + } + + fun removeDiscoveredDevices(devices: Set) { + _discoveredDevices.value -= devices + } + + fun addPairedDeviceIds(deviceIds: Set) { + _pairedDevices.value += deviceIds + } + + fun removePairedDeviceIds(deviceIds: Set) { + _pairedDevices.value -= deviceIds + } + + fun addConnectingDevices(devices: Set) { + _devicesCurrentlyConnecting.value += devices.filter { !connectedDevices.value.contains(it) } + } + + fun removeConnectingDevices(devices: Set) { + _devicesCurrentlyConnecting.value -= devices + } + + // Used in iOS + fun removeDiscoveredDeviceIds(deviceIds: Set) { + _discoveredDevices.value = + discoveredDevices.value.filterTo(mutableSetOf()) { it.deviceId !in deviceIds } + } + + // Used in iOS + fun removeConnectingDeviceIds(deviceIds: Set) { + _devicesCurrentlyConnecting.value = + devicesCurrentlyConnecting.value.filterTo(mutableSetOf()) { it.deviceId !in deviceIds } + } + + // Used in iOS + fun removeConnectedDeviceIds(deviceIds: Set) { + _connectedDevices.value = + connectedDevices.value.filterTo(mutableSetOf()) { it.deviceId !in deviceIds } + } + + fun resetAll() { + clearDiscovered() + clearConnected() + clearConnectingDevices() + _scanning.value = false + _bgScanningActive.value = false + } + + fun clearDiscovered() { + this._discoveredDevices.value = setOf() + } + + fun clearConnected() { + this._connectedDevices.value = setOf() + } + + fun clearConnectingDevices() { + this._devicesCurrentlyConnecting.value = setOf() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/ScanMode.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/ScanMode.kt new file mode 100644 index 000000000..27b82dfdd --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/ScanMode.kt @@ -0,0 +1,7 @@ +package io.redlink.more.services.bluetooth + +enum class ScanMode { + Stopped, + Foreground, + Background +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/polar/PolarStates.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/polar/PolarStates.kt new file mode 100644 index 000000000..4218b0274 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/polar/PolarStates.kt @@ -0,0 +1,20 @@ +package io.redlink.more.services.bluetooth.polar + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object PolarStates { + private val _hrFeatureReady = MutableStateFlow(true) + + @NativeCoroutines + val hrFeatureReady: StateFlow = _hrFeatureReady + + fun hrFeatureReady(ready: Boolean) { + _hrFeatureReady.value = ready + } + + fun resetAll() { + _hrFeatureReady.value = false + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt similarity index 91% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt index d26729582..57e1dfa36 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.network +package io.redlink.more.services.network import io.ktor.client.HttpClient import io.ktor.client.plugins.logging.DEFAULT diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkClients.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkClients.kt new file mode 100644 index 000000000..7b44150fb --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkClients.kt @@ -0,0 +1,220 @@ +package io.redlink.more.services.network + +import io.github.aakira.napier.Napier +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BasicAuthCredentials +import io.ktor.client.plugins.auth.providers.basic +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.DEFAULT +import io.ktor.client.plugins.logging.Logger +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.date.getTimeMillis +import io.ktor.utils.io.core.Closeable +import io.redlink.more.models.CredentialModel +import io.redlink.more.services.network.openapi.api.ConfigurationApi +import io.redlink.more.services.network.openapi.api.DataApi +import io.redlink.more.services.network.openapi.api.GarminRegistrationApi +import io.redlink.more.services.network.openapi.api.NotificationsApi +import io.redlink.more.services.network.openapi.api.RegistrationApi +import io.redlink.more.services.store.CredentialRepository +import io.redlink.more.services.store.EndpointRepository +import kotlinx.serialization.json.Json + +class NetworkClients( + private val credentialRepository: CredentialRepository, + private val endpointRepository: EndpointRepository, + private val clientTimeoutMs: Long = 5 * 60 * 1000L // 5 minutes by default +) : Closeable { + private var httpClient: HttpClient? = null + private var registrationApi: RegistrationApi? = null + private var configurationApi: ConfigurationApi? = null + private var dataApi: DataApi? = null + private var notificationApi: NotificationsApi? = null + private var garminRegistrationApi: GarminRegistrationApi? = null + private var registrationBaseUrl: String? = null + + private var httpClientLastUsed: Long = 0L + private var registrationLastUsed: Long = 0L + private var configurationLastUsed: Long = 0L + private var dataLastUsed: Long = 0L + private var notificationLastUsed: Long = 0L + private var garminLastUsed: Long = 0L + + private var lastCredentialsKey: Pair? = null + + private fun currentCredentialsKey(): Pair? = + credentialRepository.credentials.value?.let { c -> + c.apiId to c.apiKey + } + + private fun ensureCredentialsUpToDate() { + val current = currentCredentialsKey() + if (current != lastCredentialsKey) { + Napier.i(tag = "NetworkClients::ensureCredentialsUpToDate") { + "Credentials changed. Clearing cached HTTP clients & APIs." + } + clearData() + lastCredentialsKey = current + } + } + + private fun now(): Long = getTimeMillis() + + private fun isExpired(lastUsed: Long): Boolean = + lastUsed != 0L && (now() - lastUsed) > clientTimeoutMs + + private fun getHttpClientWithAuth(credentials: CredentialModel? = null): HttpClient? { + ensureCredentialsUpToDate() + + val baseClient = getHttpClient() ?: return null + val creds = credentials ?: credentialRepository.credentials.value + + return baseClient.config { + install(Auth) { + creds?.let { authCredentials -> + basic { + credentials { + BasicAuthCredentials( + username = authCredentials.apiId, + password = authCredentials.apiKey + ) + } + sendWithoutRequest { true } + } + } ?: run { + Napier.i(tag = "NetworkService::getHttpClientWithAuth ") { + "No credentials available. Using anonymous client" + } + } + } + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) + } + } + } + + fun getHttpClient(): HttpClient? { + ensureCredentialsUpToDate() + + val now = now() + val current = httpClient + + if (current == null || isExpired(httpClientLastUsed)) { + current?.close() + httpClient = getHttpClient(Logger.DEFAULT) + } + + httpClientLastUsed = now + return httpClient + } + + fun getConfigApi(credentials: CredentialModel? = null): ConfigurationApi? { + ensureCredentialsUpToDate() + + val now = now() + if (credentials != null || configurationApi == null || isExpired(configurationLastUsed)) { + configurationApi = getHttpClientWithAuth(credentials)?.let { client -> + ConfigurationApi(baseUrl(), client) + } + } + configurationLastUsed = now + return configurationApi + } + + fun getDataApi(): DataApi? { + ensureCredentialsUpToDate() + + val now = now() + if (dataApi == null || isExpired(dataLastUsed)) { + dataApi = getHttpClientWithAuth()?.let { client -> + DataApi(baseUrl(), client) + } + } + dataLastUsed = now + return dataApi + } + + fun getRegistrationApi(baseUrl: String? = null): RegistrationApi? { + ensureCredentialsUpToDate() + + val now = now() + val effectiveBase = baseUrl ?: baseUrl() + + if ( + registrationApi == null || + registrationBaseUrl != effectiveBase || + isExpired(registrationLastUsed) + ) { + registrationApi = getHttpClientWithAuth()?.let { client -> + RegistrationApi(effectiveBase, client) + } + registrationBaseUrl = effectiveBase + } + + registrationLastUsed = now + return registrationApi + } + + fun getNotificationApi(): NotificationsApi? { + ensureCredentialsUpToDate() + + val now = now() + if (notificationApi == null || isExpired(notificationLastUsed)) { + notificationApi = getHttpClientWithAuth()?.let { client -> + NotificationsApi(baseUrl(), client) + } + } + notificationLastUsed = now + return notificationApi + } + + fun getGarminRegistrationApi(): GarminRegistrationApi? { + ensureCredentialsUpToDate() + + val now = now() + if (garminRegistrationApi == null || isExpired(garminLastUsed)) { + garminRegistrationApi = getHttpClientWithAuth()?.let { client -> + GarminRegistrationApi(baseUrl(), client) + } + } + garminLastUsed = now + return garminRegistrationApi + } + + fun basicAuthHeader(): String? { + ensureCredentialsUpToDate() + return credentialRepository.credentials.value?.basicAuthHeader() + } + + fun baseUrl(): String = endpointRepository.endpoint() + + private fun clearData() { + registrationBaseUrl = null + registrationApi = null + configurationApi = null + dataApi = null + notificationApi = null + garminRegistrationApi = null + + httpClient?.close() + httpClient = null + + Napier.d(tag = "NetworkClients::clearData") { "Cleared the Http engine" } + + httpClientLastUsed = 0L + registrationLastUsed = 0L + configurationLastUsed = 0L + dataLastUsed = 0L + notificationLastUsed = 0L + garminLastUsed = 0L + } + + override fun close() { + Napier.d(tag = "NetworkClients::close") { "Clearing the Http engine..." } + clearData() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkService.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkService.kt new file mode 100644 index 000000000..6f1cf973b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkService.kt @@ -0,0 +1,52 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.services.network + +import io.ktor.http.Url +import io.redlink.more.app.android.services.network.errors.NetworkServiceError +import io.redlink.more.models.CredentialModel +import io.redlink.more.models.LoginModel +import io.redlink.more.services.network.openapi.model.AppConfiguration +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.network.openapi.model.PushNotification +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.services.network.openapi.model.StudyConsent + +interface NetworkService { + + fun baseUrl(): String + + suspend fun deleteParticipation(): Pair + + suspend fun validateRegistrationToken(loginModel: LoginModel): Pair + + suspend fun sendConsent( + loginModel: LoginModel, studyConsent: StudyConsent + ): Pair + + suspend fun getStudyConfig(credentials: CredentialModel? = null): Pair + + suspend fun sendNotificationToken(token: String): Pair + + suspend fun sendData(data: DataBulk): Pair, NetworkServiceError?> + + suspend fun downloadMissedNotifications(): List + + fun getBasicAuthHeader(): String? + + fun getGarminSSOUrl(): Url? + + fun garminSSOCallbackUrl(): Url? + + suspend fun garminSSOCallback(code: String, status: String): Boolean + + suspend fun deletePushNotification(msgId: String) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt new file mode 100644 index 000000000..19dc41a45 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt @@ -0,0 +1,287 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.services.network + +import io.github.aakira.napier.Napier +import io.ktor.client.call.body +import io.ktor.client.statement.HttpResponse +import io.ktor.http.Url +import io.redlink.more.app.android.services.network.errors.NetworkServiceError +import io.redlink.more.models.CredentialModel +import io.redlink.more.models.LoginModel +import io.redlink.more.services.network.openapi.model.AppConfiguration +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.network.openapi.model.PushNotification +import io.redlink.more.services.network.openapi.model.PushNotificationServiceType +import io.redlink.more.services.network.openapi.model.PushNotificationToken +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.services.network.openapi.model.StudyConsent +import io.redlink.more.services.store.CredentialRepository +import io.redlink.more.services.store.EndpointRepository + +private const val TAG = "NetworkService" + +class NetworkServiceImpl( + endpointRepository: EndpointRepository, + credentialRepository: CredentialRepository, +) : NetworkService { + + private val networkClients = NetworkClients(credentialRepository, endpointRepository) + + override fun baseUrl() = networkClients.baseUrl() + + override suspend fun deleteParticipation(): Pair { + try { + Napier.i(tag = "NetworkService::deleteParticipation") { "Deleting Participation..." } + val client = networkClients.getRegistrationApi() ?: return Pair( + false, + NetworkServiceError(null, "Failed to init HTTP client") + ) + + val response = client.unregisterFromStudy() + + Napier.i(response.toString(), tag = TAG) + if (response.success) { + Napier.i(tag = "NetworkService::deleteParticipation") { "Participation deleted!" } + return Pair(true, null) + } + Napier.e(tag = "NetworkService::deleteParticipation") { "Error; Code: ${response.status}" } + val error = createErrorBody(response.status, response.response) + return Pair(false, error) + } catch (err: Exception) { + Napier.e(tag = "NetworkService::deleteParticipation") { err.stackTraceToString() } + return Pair(false, getException(err)) + } + } + + override suspend fun validateRegistrationToken(loginModel: LoginModel): Pair { + try { + Napier.i(tag = "NetworkService::validateRegistrationToken") { "Validating Registration token..." } + val client = networkClients.getRegistrationApi(loginModel.endpoint) ?: return Pair( + null, + NetworkServiceError(null, "HTTP client not initialized") + ) + + val response = client.getStudyRegistrationInfo(loginModel.token) + + Napier.i(response.toString(), tag = TAG) + if (response.success) { + val study: Study = response.body() + Napier.i(tag = "NetworkService::validateRegistrationToken") { "Registration token valid!" } + return Pair(study, null) + } + val error = createErrorBody(response.status, response.response) + return Pair(null, error) + + } catch (err: Exception) { + Napier.e(tag = "NetworkService::validateRegistrationToken") { err.stackTraceToString() } + return Pair(null, getException(err)) + } + } + + override suspend fun sendConsent( + loginModel: LoginModel, studyConsent: StudyConsent + ): Pair { + try { + Napier.i(tag = "NetworkService::sendConsent") { "Sending Consent..." } + + val client = networkClients.getRegistrationApi(loginModel.endpoint) ?: return Pair( + null, + NetworkServiceError(null, "HTTP client not initialized") + ) + + val response = client.registerForStudy(loginModel.token, studyConsent) + + if (response.success) { + val appConfig: AppConfiguration = response.body() + Napier.i(tag = "NetworkService::sendConsent") { "Credentials received!" } + return Pair(appConfig, null) + } + return Pair(null, createErrorBody(response.status, response.response)) + } catch (e: Exception) { + Napier.e(tag = "NetworkService::sendConsent") { e.stackTraceToString() } + return Pair(null, getException(e)) + } + } + + override suspend fun getStudyConfig(credentials: CredentialModel?): Pair { + try { + Napier.i(tag = "NetworkService::getStudyConfig") { "Downloading study data..." } + val client = networkClients.getConfigApi(credentials) ?: return Pair( + null, + NetworkServiceError(null, "Failed to init HTTP client") + ) + + val response = client.getStudyConfiguration() + + if (response.success) { + val study: Study = response.body() + Napier.i(tag = "NetworkService::getStudyConfig") { "Loading study data success!" } + return Pair(study, null) + } + return Pair(null, createErrorBody(response.status, response.response)) + } catch (e: Exception) { + Napier.e(tag = "NetworkService::getStudyConfig") { e.stackTraceToString() } + return Pair(null, getException(e)) + } + } + + override suspend fun sendNotificationToken(token: String): Pair { + try { + Napier.i(tag = "NetworkService::sendNotificationToken") { "Sending notification token..." } + val client = networkClients.getConfigApi() ?: return Pair( + false, + NetworkServiceError(null, "Failed to init HTTP client") + ) + val pushToken = + PushNotificationToken(token = token) + + val response = + client.setPushNotificationToken( + PushNotificationServiceType.FCM, + pushToken + ) + + if (response.success) { + Napier.i(tag = "NetworkService::sendNotificationToken") { "Uploading notification token success!" } + return Pair(true, null) + } + return Pair(false, createErrorBody(response.status, response.response)) + } catch (err: Exception) { + Napier.e(tag = "NetworkService::sendNotificationToken") { err.stackTraceToString() } + return Pair(false, getException(err)) + } + } + + override suspend fun sendData(data: DataBulk): Pair, NetworkServiceError?> { + try { + Napier.i(tag = "NetworkService::sendData") { "Sending bulk ${data.bulkId} with ${data.dataPoints.size} datapoints with first being ${data.dataPoints.first()}..." } + val client = networkClients.getDataApi() ?: return Pair( + emptySet(), + NetworkServiceError(null, "Failed to init HTTP client") + ) + + val response = client.storeBulk(data) + + if (response.success) { + val result: List = response.body() + Napier.i(tag = "NetworkService::sendData") { "Sent data!" } + return Pair(result.toSet(), null) + } + return Pair(emptySet(), createErrorBody(response.status, response.response)) + } catch (e: Exception) { + Napier.e(tag = "NetworkService::sendData") { e.stackTraceToString() } + return Pair(emptySet(), getException(e)) + } + } + + override suspend fun downloadMissedNotifications(): List { + return try { + Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloading missed notifications from the Server..." } + val client = networkClients.getNotificationApi() ?: return emptyList() + + val response = client.listPushNotifications() + + if (response.success) { + val notifications: List = response.body() + Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloaded Messages list: $notifications" } + notifications + } else { + Napier.d(tag = "NetworkService::downloadMissedNotifications") { "No notifications received from the server" } + emptyList() + } + } catch (e: Exception) { + Napier.e(tag = "NetworkService::downloadMissedNotifications") { "Notification List error: $e" } + emptyList() + } + } + + override fun getBasicAuthHeader(): String? = networkClients.basicAuthHeader() + + override fun getGarminSSOUrl(): Url? { + try { + return Url("${baseUrl()}/registration/garmin") + } catch (e: Exception) { + Napier.e(tag = "NetworkService::getGarminSSOUrl") { "Error getting Garmin SSO Url: $e" } + } + return null + } + + override fun garminSSOCallbackUrl(): Url? { + return getGarminSSOUrl()?.let { + Url("$it/callback") + } + } + + override suspend fun garminSSOCallback(code: String, status: String): Boolean { + try { + Napier.d(tag = "NetworkService::garminSSOCallback") { "Received callback with code: $code and status: $status" } + val client = networkClients.getGarminRegistrationApi() ?: run { + Napier.e(tag = "NetworkService::garminSSOCallback") { "Garmin SSO callback failed: No client available" } + return false + } + val response = client.handleGarminCallback(code, status) + if (response.success) { + Napier.d(tag = "NetworkService::garminSSOCallback") { "Garmin SSO callback successful" } + } else { + Napier.e(tag = "NetworkService::garminSSOCallback") { "Garmin SSO callback failed: ${response.response}" } + } + return response.success + } catch (e: Exception) { + Napier.e(tag = "NetworkService::garminSSOCallback") { "Garmin SSO callback failed: $e" } + return false + } + } + + override suspend fun deletePushNotification(msgId: String) { + try { + val client = networkClients.getNotificationApi() ?: return + + val response = client.deleteNotification(msgId) + + if (response.success) { + Napier.d(tag = "NetworkService::deletePushNotification") { "Successfully deleted notification with id: $msgId" } + } else { + Napier.d(tag = "NetworkService::deletePushNotification") { "Push notification not found with msgID: $msgId. Could not delete!" } + } + } catch (e: Exception) { + Napier.e(tag = "NetworkService::deletePushNotification") { "Notification deletion error: $e" } + } + } + + private suspend fun createErrorBody( + code: Int, + responseBody: HttpResponse? + ): NetworkServiceError { + return try { + if (responseBody == null) { + return NetworkServiceError(code = code, message = "Error") + } + val error: Error? = try { + responseBody.body() + } catch (_: Exception) { + null + } + + NetworkServiceError(code = code, message = error?.message ?: "Error") + } catch (e: Exception) { + getException(e, code) + } + } + + private fun getException(exception: Exception, code: Int? = null): NetworkServiceError { + val errorResponse = "System error!" + Napier.e("Exception: ${exception.stackTraceToString()}", tag = TAG) + exception.printStackTrace() + return NetworkServiceError(code, errorResponse) + } + +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/errors/NetworkServiceError.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/network/errors/NetworkServiceError.kt similarity index 100% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/errors/NetworkServiceError.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/network/errors/NetworkServiceError.kt diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt new file mode 100644 index 000000000..c26d8f918 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt @@ -0,0 +1,6 @@ +package io.redlink.more.services.notification + +enum class NotificationActionHandler { + NON, + DEEPLINK +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt new file mode 100644 index 000000000..032a2affb --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt @@ -0,0 +1,421 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.services.notification + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.mapQueryParams +import io.redlink.more.extensions.toNotificationEntity +import io.redlink.more.models.NotificationStatusType +import io.redlink.more.models.ScheduleState +import io.redlink.more.models.StudyState +import io.redlink.more.navigation.DeeplinkManager +import io.redlink.more.navigation.model.DeepLinkData +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.navigation.model.NavigationRouteParameter +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.MoreDispatchers +import io.redlink.more.scopes.Scope +import io.redlink.more.services.network.NetworkService +import io.redlink.more.services.store.SharedStorageRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +interface LocalNotificationListener { + fun displayNotification(notification: NotificationEntity, badgeCount: Int = 0) + + fun clearScheduledNotifications(notifications: List) + + fun deleteNotificationFromSystem(notificationId: String) + + fun createNewFCMToken(onCompletion: (String) -> Unit) + fun clearNotifications() + fun deleteFCMToken() + fun updateBadgeCount(count: Int = 0) +} + +interface NotificationActionObserver { + fun updateStudy(oldStudyState: StudyState? = null, newStudyState: StudyState? = null) +} + +open class NotificationManager( + val repository: MainRepository, + private val localNotificationListener: LocalNotificationListener, + private val networkService: NetworkService, + private val deeplinkManager: DeeplinkManager, + private val sharedStorageRepository: SharedStorageRepository, + private val dispatchers: MoreDispatchers = AppDispatchers +) { + private val _unreadUserCount = MutableStateFlow(0) + + @NativeCoroutines + val unreadUserCount: StateFlow = _unreadUserCount + + private var actionObserver: NotificationActionObserver? = null + + fun setActionObserver(observer: NotificationActionObserver?) { + actionObserver = observer + } + + init { + Scope.launch { + repository.notification.getAllUserFacingNotifications() + .collect { notifications -> + notifications.filter { !it.read }.let { + withContext(dispatchers.main) { + _unreadUserCount.value = it.size + localNotificationListener.updateBadgeCount(it.size) + } + } + } + } + } + + fun storeAndHandleNotification( + key: String, + title: String?, + body: String?, + priority: Long = 1, + read: Boolean = false, + completed: Boolean = false, + data: Map? = null, + displayNotification: Boolean + ) { + storeAndHandleNotification( + NotificationEntity.toEntity( + notificationId = key, + channelId = null, + title = title, + notificationBody = body, + priority = priority, + read = read, + completed = completed, + userFacing = title != null, + notificationData = data + ), + displayNotification + ) + } + + fun storeAndHandleNotificationInteraction( + key: String, + title: String?, + body: String?, + priority: Long = 1, + read: Boolean = false, + completed: Boolean = false, + data: Map? = null, + handler: ((NotificationActionHandler, DeepLinkData?) -> Unit) + ) { + Scope.launch { + val notification = repository.notification.getNotification(key) ?: run { + val newNotification = NotificationEntity.toEntity( + notificationId = key, + channelId = null, + title = title, + notificationBody = body, + priority = priority, + read = read, + completed = completed, + userFacing = title != null, + notificationData = data + ) + storeAndDisplayNotification(newNotification, false) + newNotification + } + handleNotificationInteraction( + notification.notificationId, + notification.deepLink, + handler + ) + } + } + + fun storeAndHandleNotification( + notification: NotificationEntity, + displayNotification: Boolean + ) { + storeAndDisplayNotification(notification, displayNotification) + if (notification.notificationData.isNotEmpty()) { + handleNotificationDataAsync( + notification.getNotificationDataMap() + ) + } + } + + fun storeAndDisplayNotification( + notification: NotificationEntity, + displayNotification: Boolean + ) { + if (notification.title != null && notification.notificationBody != null) { + Scope.launch { + Napier.i { "Storing notification: ${notification.title} - ${notification.notificationBody}" } + repository.notification.storeNotification(notification) + if (displayNotification) { + Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } + withContext(dispatchers.main) { + localNotificationListener.displayNotification( + notification, + unreadUserCount.value + ) + } + } + } + } + } + + fun storeNotifications(notifications: List) { + Scope.launch { + repository.notification.storeNotifications(notifications) + } + } + + fun displayNotification(notification: NotificationEntity) { + localNotificationListener.displayNotification(notification, unreadUserCount.value) + } + + suspend fun downloadMissedNotifications() { + Napier.d { "Updating notifications" } + val missed = networkService.downloadMissedNotifications() + repository.notification.storeNotifications(NotificationEntity.toEntityList(missed)) + } + + fun deleteNotificationFromRepository(notificationId: String) { + deleteNotificationFromSystemTray(notificationId) + repository.notification.deleteNotification(notificationId) + } + + fun deleteNotificationFromServer(msgID: String) { + Napier.i { "Deleting notification with msgID $msgID from server..." } + Scope.launch(dispatchers.io) { + networkService.deletePushNotification(msgID) + } + } + + fun deleteNotificationFromSystemTray(notificationId: String) { + localNotificationListener.deleteNotificationFromSystem(notificationId = notificationId) + } + + open fun markNotificationAsRead(notificationId: String) { + repository.notification.setNotificationReadStatus(notificationId, true) + deleteNotificationFromSystemTray(notificationId) + } + + open fun markNotificationAsCompleted(notificationId: String) { + repository.notification.setNotificationCompletedStatus(notificationId, true) + deleteNotificationFromSystemTray(notificationId) + } + + fun handleNotificationDataAsync(data: Map) { + handleNotificationData( + data + ) + } + + fun handleNotificationData( + data: Map + ) { + if (data.isNotEmpty()) { + if (data[MAIN_DATA_KEY] == STUDY_CHANGED) { + updateStudy(data) + } + data[MSG_ID]?.let { + deleteNotificationFromServer(it) + } + } + } + + open fun handleNotificationInteraction( + notificationId: String, + deeplink: String? = null + ) { + if (deeplink == null || deeplink.contains(NavigationRoute.SCHEDULE_DETAILS.route) || deeplink.contains( + NavigationRoute.OBSERVATION_DETAILS.route + ) + ) { + markNotificationAsRead(notificationId) + } + } + + + open fun handleNotificationInteraction( + notificationId: String, + deepLink: String?, + handler: ((NotificationActionHandler, DeepLinkData?) -> Unit) + ) { + deepLink?.let { + Scope.launch { + + val state = checkIfCompletedOrRead(deepLink).cancellable().firstOrNull() + + if (state != null) { + if (NotificationStatusType.READ == state) repository.notification.setNotificationReadStatus( + notificationId, + true + ) + if (NotificationStatusType.COMPLETED == state) repository.notification.setNotificationCompletedStatus( + notificationId, + true + ) + } + + deeplinkManager.modifyDeepLink(deepLink) + .firstOrNull() + ?.let { modifiedDeepLink -> + if (modifiedDeepLink.route.contains(NavigationRoute.SCHEDULE_DETAILS.route) || modifiedDeepLink.route.contains( + NavigationRoute.OBSERVATION_DETAILS.route + ) + ) { + withContext(dispatchers.main) { + markNotificationAsRead(notificationId) + } + } + withContext(dispatchers.main) { + handler(NotificationActionHandler.DEEPLINK, modifiedDeepLink) + } + } ?: run { + withContext(dispatchers.main) { + markNotificationAsRead(notificationId) + } + } + } + } ?: run { + markNotificationAsRead(notificationId) + Scope.launch { + deeplinkManager.getNotificationViewDeepLink(notificationId).firstOrNull()?.let { + handler(NotificationActionHandler.DEEPLINK, it) + } + } + } + } + + fun checkIfCompletedOrRead( + notificationDeeplink: String, + ): Flow = flow { + + var state: NotificationStatusType? + + val queryParams = notificationDeeplink.mapQueryParams() + val observationId = queryParams[NavigationRouteParameter.OBSERVATION_ID.key] + if (observationId.isNullOrEmpty() + || repository.observation.observationById(observationId.first()) + .firstOrNull() == null + ) { + emit(null) + return@flow + } + val schedule = + repository.schedule.firstScheduleAvailableForObservationId(observationId.first()) + .cancellable().firstOrNull() + + // ScheduleState.DEACTIVATED -> ACTIVE -> PAUSE/RUNNING -> ENDED/COMPLETED + // if the ScheduleState is DEACTIVATED and it has a repeat, the Notification will go to the next Instance of Observation + // ACTIVE, PAUSED, RUNNING, ENDED -> is only read + // COMPLETED gets a check + // after Observation has ended, we don't have any means to determine anything anymore, because the Scheduler is deleted (null) from the object, so it will be set to comppleted + + state = if (schedule?.state == ScheduleState.DONE.toString() || schedule?.state == null) { + NotificationStatusType.COMPLETED + } else { + NotificationStatusType.READ + } + emit(state) + } + + fun newFCMToken(token: String? = null) { + sharedStorageRepository.remove(FCM_TOKEN_UPLOADED) + token?.let { storeAndUploadToken(it) } + ?: run { + localNotificationListener.createNewFCMToken { storeAndUploadToken(it) } + } + } + + private fun storeAndUploadToken(newToken: String) { + Scope.launch(dispatchers.io) { + val (successful, _) = networkService.sendNotificationToken(newToken) + sharedStorageRepository.store(FCM_TOKEN_UPLOADED, successful) + } + } + + fun deleteFCMToken() { + sharedStorageRepository.remove(FCM_TOKEN_UPLOADED) + localNotificationListener.deleteFCMToken() + } + + fun createNewFCMIfNecessary() { + if (!sharedStorageRepository.load(FCM_TOKEN_UPLOADED, false)) { + newFCMToken() + } + } + + fun clearAllNotifications() { + localNotificationListener.clearNotifications() + localNotificationListener.updateBadgeCount(0) + } + + suspend fun scheduleObservationReminders(schedules: List) { + val notifications = schedules.map { + it.toNotificationEntity(true, deeplinkManager.createDeeplinkForSchedule(it)) + } + withContext(dispatchers.main) { + storeNotifications(notifications) + notifications.forEach { + Napier.i { "Scheduling notification ${it.notificationId} at ${it.timestamp} with deeplink: ${it.deepLink}" } + displayNotification(it) + } + } + } + + suspend fun rescheduleNotifications(notifications: List) { + withContext(dispatchers.main) { + notifications.forEach { + displayNotification(it) + } + } + } + + suspend fun clearScheduledNotifications() { + try { + val notifications = repository.notification.scheduledNotifications() + localNotificationListener.clearScheduledNotifications(notifications) + } catch (e: Exception) { + Napier.e { e.toString() } + } + } + + private fun updateStudy(data: Map) { + val oldStudyState = + data[STUDY_OLD_STATE]?.let { StudyState.getState(it) } + val newStudyState = + data[STUDY_NEW_STATE]?.let { StudyState.getState(it) } + actionObserver?.updateStudy(oldStudyState, newStudyState) + } + + companion object { + private const val MAIN_DATA_KEY = "key" + private const val STUDY_CHANGED = "STUDY_STATE_CHANGED" + private const val STUDY_OLD_STATE = "oldState" + private const val STUDY_NEW_STATE = "newState" + + const val FCM_TOKEN_UPLOADED = "FCM_TOKEN_UPLOADED" + + const val DEEP_LINK = "deepLink" + const val MSG_ID = "MSG_ID" + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepository.kt new file mode 100644 index 000000000..6845fbe7b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.services.store + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.models.CredentialModel +import kotlinx.coroutines.flow.StateFlow + +interface CredentialRepository { + @NativeCoroutines + val credentialsLoaded: StateFlow + + @NativeCoroutines + val credentials: StateFlow + + @NativeCoroutines + val hasCredentials: StateFlow + + fun store(credentials: CredentialModel): Boolean + + fun remove() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepositoryImpl.kt similarity index 55% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepositoryImpl.kt index ab3b2c39c..ca540fcad 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepositoryImpl.kt @@ -8,22 +8,40 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.services.store -import io.redlink.more.more_app_mutliplatform.models.CredentialModel +import io.redlink.more.extensions.mapState +import io.redlink.more.models.CredentialModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -class CredentialRepository(private val sharedStorageRepository: SharedStorageRepository) { - private var cache: CredentialModel? = null +class CredentialRepositoryImpl(private val sharedStorageRepository: SharedStorageRepository) : + CredentialRepository { + private val _credentialsLoaded = MutableStateFlow(false) + + override val credentialsLoaded: StateFlow = _credentialsLoaded + private var _cache = MutableStateFlow(null) + + override val credentials: StateFlow = _cache + + override val hasCredentials: StateFlow = + credentials.mapState(CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), false) { + it != null + } init { - cache = load() + _cache.value = load() + _credentialsLoaded.value = true } - fun store(credentials: CredentialModel): Boolean { + override fun store(credentials: CredentialModel): Boolean { if (credentials.apiId.isNotEmpty() && credentials.apiKey.isNotEmpty()) { sharedStorageRepository.store(CREDENTIAL_ID, credentials.apiId) sharedStorageRepository.store(CREDENTIAL_KEY, credentials.apiKey) - cache = credentials + _cache.value = credentials return true } return false @@ -38,18 +56,14 @@ class CredentialRepository(private val sharedStorageRepository: SharedStorageRep return null } - fun remove() { + override fun remove() { sharedStorageRepository.remove(CREDENTIAL_ID) sharedStorageRepository.remove(CREDENTIAL_KEY) - cache = null + _cache.value = null } - fun credentials() = cache ?: load() - - fun hasCredentials() = credentials() != null - companion object { private const val CREDENTIAL_ID = "sharedStorageCredentialID" private const val CREDENTIAL_KEY = "sharedStorageCredentialKey" } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateListener.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepository.kt similarity index 74% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateListener.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepository.kt index 8451b46aa..5dfeb5221 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateListener.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepository.kt @@ -8,8 +8,12 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.bluetooth +package io.redlink.more.services.store -interface BluetoothStateListener { - fun onBluetoothStateChange(bluetoothState: BluetoothState) +interface EndpointRepository { + fun storeEndpoint(endpoint: String) + + fun removeEndpoint() + + fun endpoint(): String } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EndpointRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepositoryImpl.kt similarity index 57% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EndpointRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepositoryImpl.kt index b20edc916..45567ab47 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EndpointRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepositoryImpl.kt @@ -8,34 +8,39 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.services.store -class EndpointRepository(private val sharedStorageRepository: SharedStorageRepository) { +import io.redlink.more.util.validateAndNormalizeUrl + +class EndpointRepositoryImpl(private val sharedStorageRepository: SharedStorageRepository) : + EndpointRepository { private var cache: String = "" init { cache = loadEndpoint() } - fun storeEndpoint(endpoint: String) { - sharedStorageRepository.store(ENDPOINT_KEY, endpoint) - cache = endpoint + override fun storeEndpoint(endpoint: String) { + val validEndpoint = endpoint.validateAndNormalizeUrl()?.ifBlank { DATA_BASE_PATH_ENDPOINT } + ?: DATA_BASE_PATH_ENDPOINT + sharedStorageRepository.store(ENDPOINT_KEY, validEndpoint) + cache = validEndpoint } private fun loadEndpoint(): String { return sharedStorageRepository.load(ENDPOINT_KEY, cache) } - fun removeEndpoint() { + override fun removeEndpoint() { cache = "" sharedStorageRepository.remove(ENDPOINT_KEY) } - fun endpoint(): String = cache.ifEmpty { DATA_BASE_PATH_ENDPOINT } + override fun endpoint(): String = cache.ifEmpty { DATA_BASE_PATH_ENDPOINT } companion object { private const val ENDPOINT_KEY = "sharedStorageEndpointKey" private const val DATA_BASE_PATH_ENDPOINT: String = - "https://data.more-health.at/api/v1" + "https://data.platform-test.more.redlink.io/api/v1" } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.kt new file mode 100644 index 000000000..ac0ab7bd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.kt @@ -0,0 +1,33 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.services.store + +interface PermissionRepository { + fun updatePermission(permissionType: PermissionType, granted: Boolean) + + fun getPermission(permissionType: PermissionType): PermissionApprovalState + + fun removePermission(permissionType: PermissionType) + + fun storeValue(key: String, value: String) + + fun loadValue(key: String): String? + + fun removeValue(key: String) +} + +enum class PermissionApprovalState { + GRANTED, DECLINED, NOT_SET +} + +enum class PermissionType(val key: String) { + APP_TRACKING("app_tracking") +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt new file mode 100644 index 000000000..ac16784a8 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt @@ -0,0 +1,49 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.services.store + +class PermissionRepositoryImpl(private val sharedStorageRepository: SharedStorageRepository) : + PermissionRepository { + + override fun updatePermission(permissionType: PermissionType, granted: Boolean) { + sharedStorageRepository.store(permissionType.key, granted.toString()) + } + + override fun getPermission(permissionType: PermissionType): PermissionApprovalState { + val rawValue = sharedStorageRepository.load(permissionType.key, NOT_SET_STRING) + return when (rawValue) { + "true" -> PermissionApprovalState.GRANTED + "false" -> PermissionApprovalState.DECLINED + else -> PermissionApprovalState.NOT_SET + } + } + + override fun removePermission(permissionType: PermissionType) { + sharedStorageRepository.remove(permissionType.key) + } + + override fun storeValue(key: String, value: String) { + sharedStorageRepository.store(key, value) + } + + override fun loadValue(key: String): String? { + val value = sharedStorageRepository.load(key, NOT_SET_STRING) + return if (value == NOT_SET_STRING) null else value + } + + override fun removeValue(key: String) { + sharedStorageRepository.remove(key) + } + + companion object { + private const val NOT_SET_STRING = "NOT_SET" + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedStorageRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/SharedStorageRepository.kt similarity index 94% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedStorageRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/store/SharedStorageRepository.kt index f5301763f..8388293b2 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedStorageRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/SharedStorageRepository.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.services.store interface SharedStorageRepository { fun store(key: String, value: String) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt new file mode 100644 index 000000000..6d12199fe --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt @@ -0,0 +1,30 @@ +package io.redlink.more.util + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import kotlinx.datetime.Clock + +/** + * Emits "now" every [periodMs], aligned so the first tick happens exactly on the next boundary. + * + * Example: + * - periodMs=1_000 -> next full second + * - periodMs=60_000 -> next full minute + */ +fun alignedNowFlow(periodMs: Long = 1000L): Flow = flow { + require(periodMs > 0) { "periodMs must be > 0" } + + emit(Clock.System.now().epochSeconds) + val nowMs = Clock.System.now().toEpochMilliseconds() + val initialDelay = ((periodMs - (nowMs % periodMs)) % periodMs) + + if (initialDelay != 0L) delay(initialDelay) + + while (currentCoroutineContext().isActive) { + emit(Clock.System.now().epochSeconds) + delay(periodMs) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/GreetingTests.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt similarity index 87% rename from shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/GreetingTests.kt rename to shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt index 06495dfd6..8aff0e3e3 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/GreetingTests.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform -class GreetingTests { -} \ No newline at end of file +package io.redlink.more.util + +expect fun openSystemSettings() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt similarity index 70% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt rename to shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt index 1bdd46e0b..e5abc161b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt @@ -1,8 +1,8 @@ -package io.redlink.more.more_app_mutliplatform.util +package io.redlink.more.util class RegexData { companion object { - val url = + const val url = """https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)""" } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt new file mode 100644 index 000000000..c19276229 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt @@ -0,0 +1,95 @@ +package io.redlink.more.util + +/** + * Validates and normalizes a URL string by adding https:// scheme if no scheme is present + * and removing trailing slash if present. + * + * @return The normalized URL with https:// scheme and no trailing slash, or null if the URL is invalid + */ +fun String?.validateAndNormalizeUrl(): String? { + if (this.isNullOrBlank()) { + return null + } + + var trimmedUrl = this.trim() + + if (!trimmedUrl.contains("://")) { + trimmedUrl = "https://$trimmedUrl" + } + + if (!trimmedUrl.isValidUrlFormat()) { + return null + } + + return if (trimmedUrl.endsWith("/") && trimmedUrl.count { it == '/' } > 2) { + trimmedUrl.dropLast(1) + } else { + trimmedUrl + } +} + +/** + * Enhanced URL format validation that checks scheme, domain, and structure + */ +private fun String.isValidUrlFormat(): Boolean { + return try { + val schemeIndex = this.indexOf("://") + if (schemeIndex == -1) return false + + val scheme = this.substring(0, schemeIndex).lowercase() + if (scheme !in listOf("http", "https", "ftp", "ftps")) return false + + val afterScheme = this.substring(schemeIndex + 3) + if (afterScheme.isEmpty() || afterScheme.startsWith("/")) return false + + val pathIndex = afterScheme.indexOf('/') + val hostPart = if (pathIndex == -1) afterScheme else afterScheme.substring(0, pathIndex) + + if (!hostPart.isValidHostPart()) return false + + true + } catch (e: Exception) { + false + } +} + +/** + * Validates the host part of a URL (domain with optional port) + */ +private fun String.isValidHostPart(): Boolean { + if (this.isEmpty()) return false + + val parts = this.split(':') + if (parts.size > 2) return false + + val host = parts[0] + val port = if (parts.size == 2) parts[1] else null + + if (!host.isValidDomain()) return false + + if (port != null) { + val portNumber = port.toIntOrNull() + if (portNumber == null || portNumber < 1 || portNumber > 65535) return false + } + + return true +} + +/** + * Basic domain name validation + */ +private fun String.isValidDomain(): Boolean { + if (this.isEmpty() || this.length > 253) return false + if (this.startsWith(".") || this.endsWith(".")) return false + + val labels = this.split('.') + if (labels.isEmpty()) return false + + for (label in labels) { + if (label.isEmpty() || label.length > 63) return false + if (label.startsWith("-") || label.endsWith("-")) return false + if (!label.all { it.isLetterOrDigit() || it == '-' }) return false + } + + return true +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/UUID.kt similarity index 90% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt rename to shared/src/commonMain/kotlin/io/redlink/more/util/UUID.kt index 3d0c4c1f7..589019b12 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/UUID.kt @@ -8,6 +8,6 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.util +package io.redlink.more.util expect fun createUUID(): String \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt new file mode 100644 index 000000000..27c8e9dbe --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt @@ -0,0 +1,59 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels + +import io.github.aakira.napier.Napier +import io.ktor.utils.io.core.Closeable +import io.redlink.more.logging.event +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.scopes.AppDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +abstract class CoreViewModel : Closeable { + protected val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + abstract fun viewIdentifier(): String + + open fun viewOpened() { + Napier.event(LogEvent.VIEW_OPEN, viewIdentifier()) + } + + open fun viewClosed() { + Napier.event(LogEvent.VIEW_CLOSED, viewIdentifier()) + } + + open fun viewDidAppear() { + viewOpened() + } + + open fun viewDidDisappear() { + viewClosed() + } + + fun launchScope( + coroutineContext: CoroutineContext? = null, + block: suspend CoroutineScope.() -> Unit + ) { + viewModelScope.launch( + coroutineContext ?: AppDispatchers.default, + block = block + ) + } + + override fun close() { + viewModelScope.cancel() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt new file mode 100644 index 000000000..c533a374f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt @@ -0,0 +1,177 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.viewModels + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.redlink.more.logging.event +import io.redlink.more.observations.Observation +import io.redlink.more.observations.appUsage.model.LogEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object ViewManager { + + private sealed class ViewRequest { + object Bluetooth : ViewRequest() + object GarminConnect : ViewRequest() + } + + private val pendingViewRequests = ArrayDeque() + + private val _studyIsUpdating = MutableStateFlow(false) + private val _showBluetoothView = MutableStateFlow(false) + private val _showGarminConnectView = MutableStateFlow(false) + private val _showSettingsView = MutableStateFlow(false) + private val _studyLoadingError = MutableStateFlow(false) + private val _appInForeground = MutableStateFlow(false) + private val _bleViewOpen = MutableStateFlow(false) + private val _activeStudy = MutableStateFlow(false) + + @NativeCoroutines + val studyLoadingError: StateFlow = _studyLoadingError + + @NativeCoroutines + val studyIsUpdating: StateFlow = _studyIsUpdating + + @NativeCoroutines + val bleViewActive: StateFlow = _showBluetoothView + + @NativeCoroutines + val appInForeground: StateFlow = _appInForeground + + @NativeCoroutines + val showGarminConnectView: StateFlow = _showGarminConnectView + + @NativeCoroutines + val showSettingsView: StateFlow = _showSettingsView + + @NativeCoroutines + val activeStudy: StateFlow = _activeStudy + + private fun canOpenNewView(): Boolean { + return _activeStudy.value && + !_studyIsUpdating.value && + !_bleViewOpen.value && + !_showBluetoothView.value && + !_showGarminConnectView.value + } + + private fun tryProcessNextInQueue() { + if (!canOpenNewView() || pendingViewRequests.isEmpty()) return + + when (pendingViewRequests.removeFirst()) { + ViewRequest.Bluetooth -> { + _showBluetoothView.value = true + } + + ViewRequest.GarminConnect -> { + _showGarminConnectView.value = true + } + } + } + + fun currentStudyActive(state: Boolean) { + _activeStudy.value = state + if (!state) { + pendingViewRequests.clear() + _showBluetoothView.value = false + _showGarminConnectView.value = false + _bleViewOpen.value = false + } else { + tryProcessNextInQueue() + } + } + + fun studyIsUpdating(state: Boolean) { + _studyIsUpdating.value = state + + if (state) { + _showBluetoothView.value = false + _showGarminConnectView.value = false + } else { + tryProcessNextInQueue() + } + } + + fun showBLEView(state: Boolean): Boolean { + if (!state) { + _showBluetoothView.value = false + return false + } + + if (canOpenNewView()) { + _showBluetoothView.value = true + return true + } else if (_showBluetoothView.value) { + return true + } + + pendingViewRequests.addLast(ViewRequest.Bluetooth) + return false + } + + fun bleViewOpen(state: Boolean) { + _bleViewOpen.value = state + if (!state) { + tryProcessNextInQueue() + } + } + + fun studyError(hasError: Boolean) { + _studyLoadingError.value = hasError + } + + fun appIsInForeground(state: Boolean) { + if (state) { + Napier.event(LogEvent.APP_IN_FOREGROUND) + Observation.resetRequestedPermissions() + } else { + Napier.event(LogEvent.APP_IN_BACKGROUND) + } + _appInForeground.value = state + } + + fun requestGarminConnectView(state: Boolean): Boolean { + Napier.d(tag = "ViewManager::requestGarminConnectView") { "Requesting Garmin Connect View: $state" } + if (!state) { + _showGarminConnectView.value = false + tryProcessNextInQueue() + return false + } + + if (canOpenNewView()) { + Napier.d(tag = "ViewManager::requestGarminConnectView") { "Can open Garmin Connect View" } + _showGarminConnectView.value = true + return true + } else if (showGarminConnectView.value) { + return true + } + + pendingViewRequests.addLast(ViewRequest.GarminConnect) + return false + } + + fun showSettingsView(state: Boolean) { + _showSettingsView.value = state + } + + fun resetAll() { + _studyIsUpdating.value = false + _showBluetoothView.value = false + _showGarminConnectView.value = false + _showSettingsView.value = false + _studyLoadingError.value = false + _bleViewOpen.value = false + pendingViewRequests.clear() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt new file mode 100644 index 000000000..2eecf559c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt @@ -0,0 +1,230 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.bluetoothConnection + +import io.github.aakira.napier.Napier +import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.repository.BluetoothDeviceRepository +import io.redlink.more.extensions.anyNameIn +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.scopes.Scope +import io.redlink.more.services.bluetooth.BluetoothConnector +import io.redlink.more.services.bluetooth.BluetoothConnectorObserver +import io.redlink.more.services.bluetooth.BluetoothStateManagement +import io.redlink.more.services.bluetooth.ScanMode +import io.redlink.more.viewModels.CoreViewModel +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.withContext + +class BluetoothController( + private val bluetoothDeviceRepository: BluetoothDeviceRepository, + private val bluetoothConnector: BluetoothConnector, + private val scanDuration: Long = 5000, + private val scanInterval: Long = 10000, + observationFactory: ObservationFactory +) : CoreViewModel(), BluetoothConnectorObserver, Closeable { + private val bleManager = BluetoothStateManagement + private val supervisor = SupervisorJob() + + private var periodicScanJob: Job? = null + + init { + bluetoothConnector.addObserver(this) + + Scope.launch { + bluetoothDeviceRepository.pairedDevices().distinctUntilChanged().cancellable().collect { + bleManager.addPairedDeviceIds(it.toSet()) + } + } + + Scope.launch(supervisor) { + combine( + bleManager.bluetoothActive, + bleManager.uiOverride, + bleManager.pairedDevices, + bleManager.bgScanningActive, + bleManager.devicesCurrentlyConnecting + ) { btOn, uiOverride, paired, bgScanningActive, connectingDevices -> + if (!btOn || connectingDevices.isNotEmpty() || !bgScanningActive && !uiOverride) ScanMode.Stopped + else if (bgScanningActive && !uiOverride && paired.isNotEmpty()) ScanMode.Background + else ScanMode.Foreground + } + .distinctUntilChanged() + .collectLatest { mode -> + when (mode) { + ScanMode.Stopped -> stopPeriodicScan() + ScanMode.Foreground -> startPeriodicScan(scanDuration, scanInterval) + ScanMode.Background -> startPeriodicScan( + BACKGROUND_SCAN_DURATION, + BACKGROUND_SCAN_INTERVAL + ) + } + } + } + + Scope.launch { + bleManager.connectedDevices.collectLatest { + observationFactory.updateObservationErrors() + } + } + } + + fun observerDeviceAccessible(bleDevices: Set): Boolean { + if (bleDevices.anyNameIn(bleManager.pairedDevices.value)) { + if (bleDevices.anyNameIn(bleManager.connectedDevices.value)) { + disableBackgroundScanner() + return true + } else { + enableBackgroundScanner() + } + } else { + if (!bleManager.uiOverride.value && !ViewManager.bleViewActive.value) { + ViewManager.showBLEView(true) + } + } + return false + } + + private fun enableBackgroundScanner() { + bleManager.enableBgScanning() + } + + private fun disableBackgroundScanner() { + bleManager.disableBgScanning() + } + + override fun viewIdentifier(): String { + return NavigationRoute.BLUETOOTH_CONNECTION.viewIdentifier + } + + override fun viewDidAppear() { + bleManager.uiOverrides(true) + } + + override fun viewDidDisappear() { + bleManager.uiOverrides(false) + } + + private fun startPeriodicScan(duration: Long, interval: Long) { + if (periodicScanJob?.isActive == true || bleManager.scanning.value || bleManager.devicesCurrentlyConnecting.value.isNotEmpty()) { + return + } + periodicScanJob = Scope.repeatedLaunch(interval, supervisor) { + if (!bleManager.uiOverride.value) { + while (!ViewManager.appInForeground.value) { + delay(BACKGROUND_SCAN_DURATION) + } + if (bleManager.bgScanningActive.value) { + delay(BACKGROUND_SCAN_DURATION) + } + } + Napier.d { "Scanning for Bluetooth Devices..." } + bluetoothConnector.scan() + delay(duration) + Napier.d { "Stopping BLE Scan" } + bluetoothConnector.stopScanning() + }.second + } + + private fun stopPeriodicScan() { + periodicScanJob?.cancel() + periodicScanJob = null + bluetoothConnector.stopScanning() + } + + suspend fun connectToDevice(device: BluetoothDeviceEntity): Boolean { + return withContext(Dispatchers.IO) { + if (!bleManager.connectedDevices.value.contains(device)) { + Napier.i(tag = "BluetoothController::connectToDevice") { "Connecting to $device" } + bleManager.addConnectingDevices(setOf(device)) + return@withContext bluetoothConnector.connect(device) == null + } + return@withContext true + } + } + + fun unpairFromDevice(device: BluetoothDeviceEntity) { + Napier.i(tag = "BluetoothController::disconnectFromDevice") { "Disconnecting from $device" } + bluetoothConnector.disconnect(device) + bluetoothDeviceRepository.unpairDevice(device) + bleManager.removePairedDeviceIds(setOf(device)) + } + + override fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) { + bleManager.addConnectingDevices(setOf(bluetoothDevice)) + } + + override fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { + bleManager.addConnectedDevices(setOf(bluetoothDevice)) + + bluetoothDeviceRepository.storePairedDevice(bluetoothDevice) + } + + override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { + Napier.i(tag = "BluetoothController::didDisconnectFromDevice") { "Disconnected from $bluetoothDevice" } + bleManager.removeConnectedDevices(setOf(bluetoothDevice)) + } + + override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { + Napier.e(tag = "BluetoothController::didFailToConnectToDevice") { "Failed to connect to $bluetoothDevice" } + bleManager.removeConnectingDevices(setOf(bluetoothDevice)) + } + + override fun didDiscoverDevice(device: BluetoothDeviceEntity) { + if (!bleManager.connectedDevices.value.contains(device)) { + Napier.i(tag = "BluetoothController::didDiscoverDevice") { "Discovered device: $device" } + bleManager.addDiscoveredDevices(setOf(device)) + if (bleManager.pairedDevices.value.contains(device)) { + Scope.launch { + connectToDevice(device) + } + } + } + } + + override fun removeDiscoveredDevice(device: BluetoothDeviceEntity) { + Napier.i(tag = "BluetoothController::removeDiscoveredDevice") { "Removed discovered device: $device" } + bleManager.removeDiscoveredDevices(setOf(device)) + } + + override fun resetAll() { + Napier.i(tag = "BluetoothController::resetAll") { "Resetting Bluetooth data!" } + disableBackgroundScanner() + stopPeriodicScan() + bleManager.uiOverrides(false) + bleManager.clearDiscovered() + bluetoothConnector.resetAll() + } + + override fun close() { + resetAll() + supervisor.cancel() + } + + companion object { + // Energy-optimized scanning intervals + private const val BACKGROUND_SCAN_DURATION = 1500L + private const val BACKGROUND_SCAN_INTERVAL = 30000L + private const val MAX_BACKGROUND_SCAN_INTERVAL = 300000L // Max 5 minutes between scans + } +} + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/PolarController.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/PolarController.kt new file mode 100644 index 000000000..48a9589d9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/PolarController.kt @@ -0,0 +1,22 @@ +package io.redlink.more.viewModels.bluetoothConnection + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.services.bluetooth.polar.PolarStates +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged + +class PolarController( + repos: MainRepository +) { + @NativeCoroutines + val hrFeatureChange: Flow> = repos.study.studyState + .combine(PolarStates.hrFeatureReady) { studyState, hrReady -> + Pair( + studyState.isActive(), + hrReady + ) + } + .distinctUntilChanged() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt similarity index 80% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt index bc31a2d82..c74e37de0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt @@ -8,16 +8,17 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.dashboard - -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.models.DateFilter -import io.redlink.more.more_app_mutliplatform.models.DateFilterModel -import io.redlink.more.more_app_mutliplatform.models.ScheduleModel -import io.redlink.more.more_app_mutliplatform.util.Scope -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +package io.redlink.more.viewModels.dashboard + +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.asClosure +import io.redlink.more.extensions.set +import io.redlink.more.models.DateFilter +import io.redlink.more.models.DateFilterModel +import io.redlink.more.models.ScheduleModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.scopes.Scope +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.transform @@ -25,23 +26,19 @@ import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.plus -class CoreDashboardFilterViewModel : CoreViewModel() { +open class CoreDashboardFilterViewModel(repository: MainRepository) : CoreViewModel() { val currentTypeFilter = MutableStateFlow(emptyMap()) val currentDateFilter = MutableStateFlow( DateFilterModel.entries.associateWith { it == DateFilterModel.ENTIRE_TIME }) init { Scope.launch { - ObservationRepository().observationTypes().firstOrNull()?.let { + repository.observation.observationTypes().firstOrNull()?.let { currentTypeFilter.set(it.associateWith { false }) } } } - override fun viewDidAppear() { - - } - fun hasAnyTypes() = currentTypeFilter.value.values.any() fun toggleTypeFilter(type: String) { @@ -82,8 +79,8 @@ class CoreDashboardFilterViewModel : CoreViewModel() { fun filterActive() = activeDateFilter() || activeTypeFilter() - fun applyFilter(scheduleModelList: Collection): Collection { - var schedules = scheduleModelList + open fun applyFilter(scheduleModelList: Collection): Collection { + var schedules = scheduleModelList.toList() if (filterActive()) { if (activeTypeFilter()) { val activeTypes = currentTypeFilter.value.filterValues { it }.keys @@ -114,4 +111,8 @@ class CoreDashboardFilterViewModel : CoreViewModel() { currentDateFilter.transform { emit(it.mapKeys { it.key.asDataClass() }) } .asClosure(provideNewState) + override fun viewIdentifier(): String { + return NavigationRoute.OBSERVATION_FILTER.viewIdentifier + } + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt new file mode 100644 index 000000000..243b6fdb1 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt @@ -0,0 +1,98 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.viewModels.garminConnectOAuth + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.ktor.http.Url +import io.redlink.more.extensions.overlaps +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.services.network.NetworkService +import io.redlink.more.services.store.SharedStorageRepository +import io.redlink.more.viewModels.CoreViewModel +import io.redlink.more.viewModels.ViewManager +import kotlinx.coroutines.flow.MutableStateFlow + +class CoreGarminConnectViewModel( + private val networkService: NetworkService, + private val sharedStorageRepository: SharedStorageRepository +) : CoreViewModel() { + + private val _isLoading = MutableStateFlow(false) + + @NativeCoroutines + val isLoading: MutableStateFlow = _isLoading + + fun garminSSOUrl() = networkService.getGarminSSOUrl() + + fun loading(state: Boolean) { + _isLoading.value = state + } + + fun basicAuthHeader(forUrl: String): String? { + val requestUrl = Url(forUrl) + if (requestUrl.host.overlaps(networkService.baseUrl())) { + return networkService.getBasicAuthHeader() + } + return null + } + + fun checkIfUrlIsCallback(url: String): Boolean { + Napier.d(tag = "CoreGarminConnectViewModel::checkIfUrlIsCallback") { "Checking URL: $url" } + val requestUrl = Url(url) + if (requestUrl.host != networkService.garminSSOCallbackUrl()?.host || requestUrl.encodedPath != networkService.garminSSOCallbackUrl()?.encodedPath) { + return false + } + val code = requestUrl.parameters[GARMIN_CALLBACK_CODE_PARAMETER] + val status = requestUrl.parameters[GARMIN_CALLBACK_STATUS_PARAMETER] + Napier.d(tag = "CoreGarminConnectViewModel::checkIfUrlIsCallback") { "Code: $code, Status: $status" } + return code != null + } + + suspend fun sendCallback(url: String): Boolean { + if (!checkIfUrlIsCallback(url)) { + return false + } + Napier.d(tag = "CoreGarminConnectViewModel::sendCallback") { "Handling callback: $url" } + val requestUrl = Url(url) + val code = requestUrl.parameters[GARMIN_CALLBACK_CODE_PARAMETER]!! + val status = requestUrl.parameters[GARMIN_CALLBACK_STATUS_PARAMETER] ?: "" + return networkService.garminSSOCallback( + code = code, + status = status + ) + } + + fun setLoading(state: Boolean) { + _isLoading.value = state + } + + fun onSuccess() { + sharedStorageRepository.store(GARMIN_CONNECT_SUCCESSFUL_LOGIN, true) + ViewManager.requestGarminConnectView(false) + } + + fun closeView() { + ViewManager.requestGarminConnectView(false) + } + + override fun viewIdentifier(): String { + return NavigationRoute.GARMIN_CONNECT.viewIdentifier + } + + companion object { + const val GARMIN_CONNECT_SUCCESSFUL_LOGIN = "GARMIN_CONNECT_SUCCESSFUL_LOGIN" + + private const val GARMIN_CALLBACK_CODE_PARAMETER = "code" + private const val GARMIN_CALLBACK_STATUS_PARAMETER = "state" + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt new file mode 100644 index 000000000..1df355cf9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt @@ -0,0 +1,116 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.limeSurvey + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.set +import io.redlink.more.logging.event +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.limesurvey.LimeSurveyObservation +import io.redlink.more.viewModels.CoreViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class CoreLimeSurveyViewModel( + private val repositories: MainRepository, + observationFactory: ObservationFactory, + private var scheduleId: String? = null, + notificationId: String? = null, + private val observationId: String? = null +) : + CoreViewModel() { + private var observation: LimeSurveyObservation = + observationFactory.observation("lime-survey-observation") as? LimeSurveyObservation + ?: throw IllegalStateException("No lime-survey-observation found in ObservationFactory") + + @NativeCoroutines + val limeSurveyLink: StateFlow = observation.limeURL + private val _dataLoading = MutableStateFlow(false) + + @NativeCoroutines + val dataLoading: StateFlow = _dataLoading + override fun viewIdentifier(): String { + return NavigationRoute.LIMESURVEY.viewIdentifier + } + + init { + viewModelScope.launch { + if (scheduleId == null && observationId != null) { + scheduleId = + repositories.schedule.firstScheduleIdAvailableForObservationId(observationId) + .cancellable() + .firstOrNull() + } + + scheduleId?.let { scheduleId -> + Napier.i { "Setting scheduleId: $scheduleId for LimeSurvey" } + if (scheduleId.isNotEmpty() || scheduleId.isNotBlank()) { + _dataLoading.update { true } + repositories.schedule.scheduleWithId(scheduleId).cancellable() + .transform { scheduleSchema -> + emit(scheduleSchema?.let { + repositories.observation.observationById(it.observationId) + .cancellable().firstOrNull() + }) + }.cancellable().firstOrNull().let { observationSchema -> + observationSchema?.let { + observation.observationConfig(it.configAsMap()) + observation.start(it.observationId, scheduleId, notificationId) + } + _dataLoading.set(false) + } + } + } + } + } + + override fun viewDidDisappear() { + super.viewDidDisappear() + clear() + } + + fun finish() { + scheduleId?.let { + observation.storeData() + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Limesurvey Study answered for: $limeSurveyLink" + ) + observation.stopAndSetDone(it) + } + clear() + } + + fun cancel() { + scheduleId?.let { + observation.stop(it) + } + clear() + } + + fun clear() { + _dataLoading.value = false + } + + override fun close() { + super.close() + clear() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt new file mode 100644 index 000000000..9450b417d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.login + +import io.redlink.more.models.LoginModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.registration.RegistrationService +import io.redlink.more.viewModels.CoreViewModel + +open class CoreLoginViewModel(private val registrationService: RegistrationService) : + CoreViewModel() { + + fun sendRegistrationToken( + loginModel: LoginModel + ) { + if (loginModel.valid()) { + registrationService.sendRegistrationToken( + loginModel + ) + } + } + + fun clearError() { + registrationService.clearError() + } + + override fun viewIdentifier(): String { + return NavigationRoute.LOGIN.viewIdentifier + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt similarity index 59% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationFilterViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt index fcffe3c2e..c763f7bbf 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -8,28 +8,39 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.notifications +package io.redlink.more.viewModels.notifications -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.models.NotificationFilterTypeModel -import io.redlink.more.more_app_mutliplatform.models.NotificationModel -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.extensions.mapState +import io.redlink.more.extensions.set +import io.redlink.more.models.NotificationFilterTypeModel +import io.redlink.more.models.NotificationModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -class CoreNotificationFilterViewModel : CoreViewModel() { +open class CoreNotificationFilterViewModel : CoreViewModel() { private var highPriority: Long = 2 - val filters = MutableStateFlow>(mapOf()) + private val _filters = MutableStateFlow>(mapOf()) + + @NativeCoroutines + val filters: StateFlow> = _filters + + @NativeCoroutines + val activeTypes: StateFlow> = filters.mapState(viewModelScope) { + it.filter { it.value }.map { it.key.type }.toSet() + } init { val map = getEnumAsList().associateWith { false }.toMutableMap() map[NotificationFilterTypeModel.ALL] = true - filters.set(map) + _filters.set(map) } fun toggleFilter(filter: NotificationFilterTypeModel) { - var filterMap = filters.value.toMutableMap() + var filterMap = _filters.value.toMutableMap() if (filter == NotificationFilterTypeModel.ALL) { filterMap = filterMap.mapValues { false }.toMutableMap() filterMap[NotificationFilterTypeModel.ALL] = true @@ -46,25 +57,21 @@ class CoreNotificationFilterViewModel : CoreViewModel() { } else { filterMap[filter] = true } - filters.set(filterMap) - } - - override fun viewDidAppear() { - + _filters.set(filterMap) } fun setPlatformHighPriority(priority: Long) { highPriority = priority } - fun applyFilter(notificationList: List): List { + open fun applyFilter(notificationList: List): List { return if (filterActive()) { notificationList.filter { notification -> - if (filters.value[NotificationFilterTypeModel.IMPORTANT] == true) { + if (_filters.value[NotificationFilterTypeModel.IMPORTANT] == true) { notification.priority == highPriority } else { true - } && if (filters.value[NotificationFilterTypeModel.UNREAD] == true) { + } && if (_filters.value[NotificationFilterTypeModel.UNREAD] == true) { !notification.read } else { true @@ -73,14 +80,13 @@ class CoreNotificationFilterViewModel : CoreViewModel() { } else notificationList } - fun filterActive() = filters.value[NotificationFilterTypeModel.ALL] == false + open fun filterActive() = _filters.value[NotificationFilterTypeModel.ALL] == false private fun getEnumAsList(): List { return NotificationFilterTypeModel.entries } - fun getActiveTypes() = filters.value.filter { it.value }.map { it.key.type }.toSet() - - fun onFilterChange(provideNewstate: (Map) -> Unit) = - filters.asClosure(provideNewstate) + override fun viewIdentifier(): String { + return NavigationRoute.NOTIFICATION_FILTER.viewIdentifier + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt similarity index 50% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt index ddd23d66a..3d26a1dee 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt @@ -8,66 +8,70 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.notifications +package io.redlink.more.viewModels.notifications -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.models.NotificationModel -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationActionHandler -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.extensions.set +import io.redlink.more.models.NotificationModel +import io.redlink.more.navigation.model.DeepLinkData +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.services.notification.NotificationActionHandler +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable -class CoreNotificationViewModel( +open class CoreNotificationViewModel( private val coreFilterModel: CoreNotificationFilterViewModel, private val notificationManager: NotificationManager, - private val protocolReplacement: String? = null, - private val hostReplacement: String? = null ) : CoreViewModel() { private val originalNotificationList = mutableListOf() - val notificationList: MutableStateFlow> = MutableStateFlow(listOf()) + private val _notificationList: MutableStateFlow> = + MutableStateFlow(listOf()) - override fun viewDidAppear() { + @NativeCoroutines + val notificationList: StateFlow> = _notificationList + + init { launchScope { coreFilterModel.filters.collect { if (originalNotificationList.isNotEmpty()) { if (coreFilterModel.filterActive()) { - notificationList.set(coreFilterModel.applyFilter(originalNotificationList)) + _notificationList.set(coreFilterModel.applyFilter(originalNotificationList)) } else { - notificationList.set(originalNotificationList.toList()) + _notificationList.set(originalNotificationList.toList()) } } } } launchScope { - notificationManager.notificationRepository.getAllUserFacingNotifications().cancellable() + notificationManager.repository.notification.getAllUserFacingNotifications() + .cancellable() .collect { originalNotificationList.clear() originalNotificationList.addAll(NotificationModel.createModelsFrom(it)) if (originalNotificationList.isNotEmpty() && coreFilterModel.filterActive()) { - notificationList.set(coreFilterModel.applyFilter(originalNotificationList)) + _notificationList.set(coreFilterModel.applyFilter(originalNotificationList)) } else { - notificationList.set(originalNotificationList.toList()) + _notificationList.set(originalNotificationList.toList()) } } } } - fun onNotificationLoad(provideNewState: ((List) -> Unit)): Closeable { - return notificationList.asClosure(provideNewState) - } - fun handleNotificationAction( notification: NotificationModel, - handler: ((NotificationActionHandler, String) -> Unit) + handler: ((NotificationActionHandler, DeepLinkData?) -> Unit) ) { notificationManager.handleNotificationInteraction( - notification, - protocolReplacement, - hostReplacement, + notification.notificationId, + notification.deepLink, handler ) } + + override fun viewIdentifier(): String { + return NavigationRoute.NOTIFICATIONS.viewIdentifier + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt similarity index 56% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt index 6709cc7c9..e02be5587 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt @@ -8,30 +8,31 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.observationDetails +package io.redlink.more.viewModels.observationDetails import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.models.ObservationDetailsModel -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.asClosure +import io.redlink.more.models.ObservationDetailsModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine class CoreObservationDetailsViewModel( + private val repository: MainRepository, private val observationId: String ) : CoreViewModel() { - private val scheduleRepository: ScheduleRepository = ScheduleRepository() - private val observationRepository: ObservationRepository = ObservationRepository() - val observationDetailsModel = MutableStateFlow(null) + override fun viewIdentifier(): String { + return NavigationRoute.OBSERVATION_DETAILS.viewIdentifier + } override fun viewDidAppear() { launchScope { - observationRepository.observationById(observationId) - .combine(scheduleRepository.getFirstAndLastDate(observationId)) { observation, pair -> + repository.observation.observationById(observationId) + .combine(repository.schedule.getFirstAndLastDate(observationId)) { observation, pair -> Triple( observation, pair.first, @@ -39,11 +40,12 @@ class CoreObservationDetailsViewModel( ) }.cancellable().collect { triple -> triple.first?.let { observation -> - observationDetailsModel.value = ObservationDetailsModel.createModelFrom( - observation, - triple.second, - triple.third - ) + observationDetailsModel.value = + ObservationDetailsModel.createModelFrom( + observation, + triple.second, + triple.third + ) } } } @@ -51,9 +53,7 @@ class CoreObservationDetailsViewModel( override fun viewDidDisappear() { super.viewDidDisappear() - launchScope { - observationDetailsModel.emit(null) - } + observationDetailsModel.value = null } fun onLoadObservationDetails(provideNewState: ((ObservationDetailsModel?) -> Unit)): Closeable { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt new file mode 100644 index 000000000..8f9f99c21 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.permission + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.extensions.mapState +import io.redlink.more.models.PermissionModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.registration.RegistrationService +import io.redlink.more.viewModels.CoreViewModel +import kotlinx.coroutines.flow.StateFlow + +class CoreConsentViewModel( + registrationService: RegistrationService, + private val studyConsentTitle: String +) : CoreViewModel() { + @NativeCoroutines + val permissions: StateFlow = + registrationService.study.mapState(viewModelScope, null) { study -> + study?.let { PermissionModel.create(it, studyConsentTitle) } + } + + override fun viewIdentifier(): String { + return NavigationRoute.CONSENT.viewIdentifier + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt new file mode 100644 index 000000000..41bd41e42 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt @@ -0,0 +1,198 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.schedules + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.mapState +import io.redlink.more.extensions.time +import io.redlink.more.models.DateFilterModel +import io.redlink.more.models.ScheduleListType +import io.redlink.more.models.ScheduleModel +import io.redlink.more.models.ScheduleState +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.DataRecorder +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationStates +import io.redlink.more.viewModels.CoreViewModel +import io.redlink.more.viewModels.dashboard.CoreDashboardFilterViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +open class CoreScheduleViewModel( + private val repos: MainRepository, + private val dataRecorder: DataRecorder, + private val scheduleListType: ScheduleListType, + val coreFilterModel: CoreDashboardFilterViewModel, +) : CoreViewModel() { + private val scheduleStates = mutableSetOf() + private var originalScheduleList = emptySet() + + private val _schedulesByDate = MutableStateFlow>>(emptyMap()) + + @NativeCoroutines + val schedulesByDate: StateFlow>> = _schedulesByDate + + private val parentJob = SupervisorJob() + + @NativeCoroutines + val observationErrors: StateFlow>> = + ObservationStates.observationErrors.mapState( + CoroutineScope(parentJob) + ) { + it.mapValues { entry -> + entry.value.filter { it != Observation.ERROR_DEVICE_NOT_CONNECTED } + .toSet() + } + } + + @NativeCoroutines + val numberOfErrors: StateFlow = observationErrors.mapState(CoroutineScope(parentJob)) { + it.values.flatten().toSet().count() + } + + private val sortedSchedulesCache = mutableMapOf>() + private var cacheVersion = 0L + + init { + scheduleStates.addAll( + when (scheduleListType) { + ScheduleListType.MANUALS -> setOf( + ScheduleState.DEACTIVATED, ScheduleState.ACTIVE, + ScheduleState.RUNNING, ScheduleState.PAUSED + ) + + ScheduleListType.RUNNING -> { + setOf(ScheduleState.RUNNING) + } + + else -> { + setOf(ScheduleState.DONE, ScheduleState.ENDED) + } + } + ) + + launchScope { + coreFilterModel.currentTypeFilter + .combine(coreFilterModel.currentDateFilter) { typeFilter, dateFilter -> + typeFilter.any { it.value } + || (dateFilter[DateFilterModel.ENTIRE_TIME] == false && dateFilter.any { it.value }) + } + .cancellable().collect { applyFilter -> + if (applyFilter) { + updateSchedulesFromSnapshot( + coreFilterModel.applyFilter(originalScheduleList).toSet() + ) + } else { + updateSchedulesFromSnapshot(originalScheduleList) + } + } + } + + launchScope { + repos.schedule.allSchedulesWithStates(scheduleStates) + .cancellable() + .collectLatest { schedules -> + val newList = when (scheduleListType) { + ScheduleListType.COMPLETED -> createCompletedModels(schedules) + ScheduleListType.RUNNING -> createRunningModels(schedules) + ScheduleListType.MANUALS -> createManualTasks(schedules) + else -> createModels(schedules) + } + + originalScheduleList = newList.toSet() + + val modified = if (coreFilterModel.filterActive()) { + coreFilterModel.applyFilter(newList) + } else { + newList + }.toSet() + updateSchedulesFromSnapshot(modified) + } + } + } + + fun start(scheduleId: String) { + dataRecorder.start(scheduleId) + } + + fun pause(scheduleId: String) { + dataRecorder.pause(scheduleId) + } + + fun stop(scheduleId: String) { + dataRecorder.stop(scheduleId) + } + + private fun createModels(scheduleList: List): List { + return scheduleList + .mapNotNull { ScheduleModel.createModel(it) } + } + + private fun createCompletedModels(scheduleList: List): List { + return createModels(scheduleList.filter { it.getState().completed() }) + } + + private fun createRunningModels(scheduleList: List): List { + return createModels(scheduleList.filter { it.getState().running() }) + } + + private fun createManualTasks(scheduleList: List): List { + return createModels(scheduleList.filter { !it.hidden }) + } + + private fun updateSchedulesFromSnapshot(newSchedules: Collection) { + val newMap: Map> = + newSchedules + .groupBy { schedule -> + Instant.fromEpochSeconds(schedule.start) + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + .time() + } + .mapValues { (_, schedules) -> + schedules + .distinctBy { it.scheduleId } + .sortedWith(compareBy { it.start }.thenBy { it.scheduleId }) + } + + if (newMap != _schedulesByDate.value) { + _schedulesByDate.update { newMap } + invalidateCache() + } + } + + private fun invalidateCache() { + sortedSchedulesCache.clear() + cacheVersion++ + } + + override fun viewIdentifier(): String { + return when (scheduleListType) { + ScheduleListType.RUNNING -> NavigationRoute.RUNNING_SCHEDULES.viewIdentifier + ScheduleListType.COMPLETED -> NavigationRoute.COMPLETED_SCHEDULES.viewIdentifier + ScheduleListType.MANUALS -> NavigationRoute.DASHBOARD.viewIdentifier + else -> "Other Schedules" + } + } +} + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt new file mode 100644 index 000000000..2e573dcf7 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt @@ -0,0 +1,143 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.settings + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.StringDesc +import io.github.aakira.napier.Napier +import io.redlink.more.SharedRes +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel +import io.redlink.more.getPlatform +import io.redlink.more.logging.event +import io.redlink.more.models.PermissionModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.observationTypes.AppUsageObservationType +import io.redlink.more.services.store.PermissionApprovalState +import io.redlink.more.services.store.PermissionRepositoryImpl +import io.redlink.more.services.store.PermissionType +import io.redlink.more.services.store.SharedStorageRepository +import io.redlink.more.util.openSystemSettings +import io.redlink.more.viewModels.CoreViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.combine + +interface ExitStudyListener { + fun exitStudy(onComplete: () -> Unit) +} + +class CoreSettingsViewModel( + mainRepository: MainRepository, + sharedStorageRepository: SharedStorageRepository, + private val customViewIdentifier: String? = null +) : CoreViewModel() { + private val permissionRepository = PermissionRepositoryImpl(sharedStorageRepository) + private val _dataDeleted = MutableStateFlow(false) + + @NativeCoroutines + val dataDeleted: StateFlow = _dataDeleted + + private val _study = MutableStateFlow(null) + + @NativeCoroutines + val study: StateFlow = _study + private val _permissionModel = MutableStateFlow(null) + + @NativeCoroutines + val permissionModel: StateFlow = _permissionModel + + private val _needsTracking = MutableStateFlow(false) + + @NativeCoroutines + val needsTracking: StateFlow = _needsTracking + + private val _allowTracking = MutableStateFlow(false) + + @NativeCoroutines + val allowTracking: StateFlow = _allowTracking + + private var exitStudyObserver: ExitStudyListener? = null + + init { + _allowTracking.value = + permissionRepository.getPermission(PermissionType.APP_TRACKING) == PermissionApprovalState.GRANTED + launchScope { + mainRepository.study.study.combine(mainRepository.observation.observations()) { study, observations -> + Pair(study, observations) + }.cancellable().collect { (study, observations) -> + if (study?.active == true) { + _study.value = study + _permissionModel.value = PermissionModel.createFromSchema(study, observations) + _needsTracking.value = observations.map { it.observationType } + .contains( + AppUsageObservationType().observationType + ) + } + } + } + } + + fun setExitStudyObserver(observer: ExitStudyListener?) { + exitStudyObserver = observer + } + + fun setTrackingPermission(allow: Boolean) { + _allowTracking.value = allow + if (allow) { + Napier.event(LogEvent.APP_TRACKING_ACCEPTED) + } else { + Napier.event(LogEvent.APP_TRACKING_DECLINED) + } + } + + fun exitStudy() { + exitStudyObserver?.exitStudy { + _dataDeleted.value = true + } + } + + fun openSettings() { + openSystemSettings() + } + + // Needed for iOS + fun showAppTrackingPermissionDialog() { + val isAndroid = getPlatform().name.lowercase().contains("android") + val messageRes = if (isAndroid) { + SharedRes.strings.app_usage_tracking_disabled_message_android + } else { + SharedRes.strings.app_usage_tracking_disabled_message_ios + } + val model = AlertDialogModel( + title = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_title), + message = StringDesc.Resource(messageRes), + confirmLabel = StringDesc.Resource(SharedRes.strings.app_usage_tracking_disabled_confirm), + cancelLabel = StringDesc.Resource(SharedRes.strings.app_tracking_dialog_negative_button), + onConfirm = { openSystemSettings() } + ) + AlertController.openAlertDialog(model) + } + + override fun viewIdentifier(): String { + return customViewIdentifier ?: NavigationRoute.SETTINGS.viewIdentifier + } + + override fun close() { + setExitStudyObserver(null) + super.close() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt new file mode 100644 index 000000000..118f81a48 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt @@ -0,0 +1,98 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.simpleQuestion + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.logging.event +import io.redlink.more.models.QuestionModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.observationTypes.QuestionType +import io.redlink.more.viewModels.CoreViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update + +class QuestionCoreViewModel( + private val repository: MainRepository, + observationFactory: ObservationFactory, + private var scheduleId: String? = null, + private val notificationId: String? = null, + private val observationId: String? = null +) : CoreViewModel() { + private val _questionModel = MutableStateFlow(null) + + @NativeCoroutines + val questionModel: StateFlow = _questionModel + private var observation: Observation? = + observationFactory.observation(QuestionType().observationType) + + init { + launchScope { + if (scheduleId == null && observationId != null) { + scheduleId = repository + .schedule + .firstScheduleIdAvailableForObservationId(observationId) + .cancellable() + .firstOrNull() + } + scheduleId?.let { scheduleId -> + repository.schedule.scheduleWithId(scheduleId).cancellable().firstOrNull() + ?.let { scheduleSchema -> + repository.observation.observationById(scheduleSchema.observationId) + .cancellable().firstOrNull()?.let { observationSchema -> + _questionModel.update { + QuestionModel.createModelFrom( + observationSchema, + scheduleId + ) + } + } + } + } + } + } + + fun finishQuestion(data: Any) { + _questionModel.value?.let { questionModel -> + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Questionnaire answered, but not yet sent, for Observation ID: $observationId" + ) + observation?.let { observation -> + observation.start( + questionModel.observationId, + questionModel.scheduleId, + notificationId + ) + observation.storeData(mapOf(questionModel.type.observationDataResponseKey to data)) { + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Questionnaire answer successfully sent with Observation ID: $observationId" + ) + scheduleId?.let { + observation.stopAndSetDone(it) + } + } + } + } + } + + override fun viewIdentifier(): String { + return NavigationRoute.QUESTION.viewIdentifier + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt similarity index 52% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt index bfdcb6181..260608747 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt @@ -8,37 +8,37 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.startupConnection +package io.redlink.more.viewModels.startupConnection -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection.BluetoothController +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.viewModels.CoreViewModel +import io.redlink.more.viewModels.bluetoothConnection.BluetoothController +import kotlinx.coroutines.flow.StateFlow class CoreBluetoothViewModel( observationFactory: ObservationFactory, val coreBluetooth: BluetoothController ) : CoreViewModel() { - val devicesNeededToConnectTo = observationFactory.studyObservationTypes + @NativeCoroutines + val devicesNeededToConnectTo: StateFlow> = observationFactory.studyObservationTypes + override fun viewIdentifier(): String = NavigationRoute.BLUETOOTH_CONNECTION.viewIdentifier override fun viewDidAppear() { coreBluetooth.viewDidAppear() } override fun viewDidDisappear() { - super.viewDidDisappear() coreBluetooth.viewDidDisappear() } - fun connectToDevice(device: BluetoothDevice): Boolean { + suspend fun connectToDevice(device: BluetoothDeviceEntity): Boolean { return coreBluetooth.connectToDevice(device) } - fun disconnectFromDevice(device: BluetoothDevice) { + fun disconnectFromDevice(device: BluetoothDeviceEntity) { coreBluetooth.unpairFromDevice(device) } - - fun devicesNeededChange(providedState: (Set) -> Unit) = - devicesNeededToConnectTo.asClosure(providedState) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt new file mode 100644 index 000000000..0d5a027be --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt @@ -0,0 +1,57 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.studydetails + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.Shared +import io.redlink.more.models.StudyDetailsModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.viewModels.CoreViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull + +class CoreStudyDetailsViewModel(shared: Shared, private val customViewIdentifier: String? = null) : + CoreViewModel() { + private val _studyModel = MutableStateFlow(null) + + @NativeCoroutines + val studyModel: StateFlow = _studyModel + override fun viewIdentifier(): String { + return customViewIdentifier ?: NavigationRoute.STUDY_DETAILS.viewIdentifier + } + + init { + launchScope { + shared.repositories.study.study.combine( + shared.repositories.schedule.allSchedulesWithStatus(true) + ) { study, schedules -> + Pair(study, schedules) + } + .combine(shared.repositories.observation.observations()) { (study, schedules), observations -> + val taskCount: Int = + shared.repositories.schedule.count().cancellable().firstOrNull() ?: 0 + study?.let { + StudyDetailsModel.createModelFrom( + it, + observations.sortedBy { obs -> obs.observationTitle }, + taskCount.toLong(), + schedules.size.toLong() + ) + } + }.collect { studyDetailsModel -> + _studyModel.value = studyDetailsModel + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt new file mode 100644 index 000000000..c89085146 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt @@ -0,0 +1,51 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.taskCompletionBar + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.models.TaskCompletion +import io.redlink.more.viewModels.CoreViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.combine +import kotlin.coroutines.CoroutineContext + +class CoreTaskCompletionBarViewModel( + private val repository: MainRepository, + dispatcher: CoroutineContext? = null +) : CoreViewModel() { + private val _taskCompletion: MutableStateFlow = + MutableStateFlow(TaskCompletion()) + + @NativeCoroutines + val taskCompletion: StateFlow = _taskCompletion + override fun viewIdentifier(): String { + return "Task Completion Sub View" + } + + init { + launchScope(dispatcher) { + repository.schedule.count() + .combine( + repository.schedule.allSchedulesWithStatus(true).cancellable() + ) { scheduleCount, doneSchedules -> + TaskCompletion( + doneSchedules.size, + scheduleCount + ) + }.cancellable().collect { + _taskCompletion.value = it + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt new file mode 100644 index 000000000..c02612c32 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt @@ -0,0 +1,105 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ +package io.redlink.more.viewModels.tasks + +import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.extensions.asClosure +import io.redlink.more.models.TaskDetailsModel +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.observations.DataRecorder +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationStates +import io.redlink.more.viewModels.CoreViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.firstOrNull + +class CoreTaskDetailsViewModel( + private val repository: MainRepository, + private val dataRecorder: DataRecorder, + private var scheduleId: String +) : CoreViewModel() { + + private val _taskDetailsModel = MutableStateFlow(null) + val taskDetailsModel: StateFlow = _taskDetailsModel + private val _dataCount = MutableStateFlow(0) + val dataCount: StateFlow = _dataCount + private val _observationErrors = MutableStateFlow>>(emptyMap()) + val observationErrors: StateFlow>> = _observationErrors + private val _taskObservationErrors = MutableStateFlow>(emptyList()) + val taskObservationErrors: StateFlow> = _taskObservationErrors + private val _taskObservationErrorActions = MutableStateFlow>(emptyList()) + val taskObservationErrorActions: StateFlow> = _taskObservationErrorActions + + init { + launchScope { + repository.schedule.scheduleWithId(scheduleId).cancellable().collect { schedule -> + schedule?.let { schedule -> + repository.observation.observationById(schedule.observationId).cancellable() + .firstOrNull()?.let { + _taskDetailsModel.emit( + TaskDetailsModel.createModelFrom( + it, + schedule + ) + ) + } + } + } + } + launchScope { + repository.dataPointCount.get(scheduleId).cancellable().collect { + it?.let { + _dataCount.emit(it.count) + } + } + } + launchScope { + ObservationStates.observationErrors.collect { errors -> + _observationErrors.value = errors + taskDetailsModel.value?.let { taskDetails -> + if (taskDetails.observationType != "") { + _taskObservationErrors.value = emptyList() + _taskObservationErrorActions.value = emptyList() + observationErrors.value[taskDetails.observationType]?.let { errors -> + val (actions, messages) = errors.partition { it == Observation.ERROR_DEVICE_NOT_CONNECTED } + _taskObservationErrors.value = messages.toList() + _taskObservationErrorActions.value = actions.toList() + } + } + } + } + } + } + + fun onLoadTaskDetails(provideNewState: ((TaskDetailsModel?) -> Unit)): Closeable = + _taskDetailsModel.asClosure(provideNewState) + + fun onNewDataCount(provideNewState: (Long?) -> Unit) = _dataCount.asClosure(provideNewState) + + fun startObservation() { + dataRecorder.start(scheduleId) + } + + fun stopObservation() { + dataRecorder.stop(scheduleId) + } + + fun pauseObservation() { + dataRecorder.pause(scheduleId) + } + + override fun viewIdentifier(): String { + return "${NavigationRoute.SCHEDULE_DETAILS.viewIdentifier}: ${taskDetailsModel.value}" + } +} \ No newline at end of file diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml new file mode 100644 index 000000000..2a021ec59 --- /dev/null +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -0,0 +1,26 @@ + + Please open the app and start the observation! + + app + io.redlink.more + App Tracking Permission + This study uses app usage data to understand how + participants use their phones. Allowing this permission helps us collect the data required + for the study. + + Allow + Deny + App Tracking Disabled + App usage tracking is disabled. Please + enable it in the study settings. If it's already enabled there, please check your general + iOS privacy settings for "Tracking". + + App usage tracking is disabled. + Please enable it in the study settings. + + Settings + + All Notifications + Unread + Important + diff --git a/shared/src/commonMain/moko-resources/de/strings.xml b/shared/src/commonMain/moko-resources/de/strings.xml new file mode 100644 index 000000000..f6270691f --- /dev/null +++ b/shared/src/commonMain/moko-resources/de/strings.xml @@ -0,0 +1,28 @@ + + + + Bitte öffne die App und starte die Beobachtung! + Berechtigung zur App-Nutzungsverfolgung + Diese Studie verwendet App-Nutzungsdaten, um zu + verstehen, wie + Teilnehmende ihre Smartphones verwenden. Wenn Sie diese Berechtigung erlauben, helfen Sie + uns, + die für die Studie erforderlichen Daten zu erfassen. + + Erlauben + Verweigern + App-Tracking deaktiviert + Die App-Nutzungsverfolgung ist + deaktiviert. Bitte aktivieren Sie diese in den Studieneinstellungen. Wenn sie dort bereits + aktiviert ist, überprüfen Sie bitte Ihre allgemeinen iOS-Datenschutzeinstellungen für + "Tracking". + + Die App-Nutzungsverfolgung ist + deaktiviert. Bitte aktivieren Sie diese in den Studieneinstellungen. + + Einstellungen + + Alle Nachrichten + Ungelesen + Wichtig + \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt new file mode 100644 index 000000000..4f56a63e0 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt @@ -0,0 +1,39 @@ +package io.redlink.more.database.entities + +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ObservationDataEntityTest { + + @Test + fun testFromDataWithSeconds() { + val seconds = 1714925000L // around May 2024 + val entity = ObservationDataEntity.fromData(mapOf("test" to "value"), seconds) + assertEquals(seconds * 1000, entity.timestamp) + } + + @Test + fun testFromDataWithMilliseconds() { + val ms = 1714925000000L // around May 2024 in ms + val entity = ObservationDataEntity.fromData(mapOf("test" to "value"), ms) + assertEquals(ms, entity.timestamp) + } + + @Test + fun testFromDataWithDefault() { + val before = Clock.System.now().toEpochMilliseconds() + val entity = ObservationDataEntity.fromData(mapOf("test" to "value")) + val after = Clock.System.now().toEpochMilliseconds() + assertTrue(entity.timestamp in before..after) + } + + @Test + fun testFromDataWithZero() { + val before = Clock.System.now().toEpochMilliseconds() + val entity = ObservationDataEntity.fromData(mapOf("test" to "value"), 0) + val after = Clock.System.now().toEpochMilliseconds() + assertTrue(entity.timestamp in before..after) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/extensions/CollectionExtensionTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/extensions/CollectionExtensionTest.kt new file mode 100644 index 000000000..201464218 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/extensions/CollectionExtensionTest.kt @@ -0,0 +1,19 @@ +package io.redlink.more.extensions + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CollectionExtensionTest { + + @Test + fun testIsSubsetOf() { + val set1 = setOf(1, 2, 3) + val set2 = setOf(1, 2, 3, 4, 5) + val set3 = setOf(1, 2, 6) + + assertTrue(set1.isSubsetOf(set2)) + assertFalse(set3.isSubsetOf(set2)) + assertTrue(emptySet().isSubsetOf(set2)) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/extensions/StringExtensionTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/extensions/StringExtensionTest.kt new file mode 100644 index 000000000..852a1a1da --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/extensions/StringExtensionTest.kt @@ -0,0 +1,43 @@ +package io.redlink.more.extensions + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StringExtensionTest { + + @Test + fun testExtractRouteFromDeepLink() { + assertEquals("notifications", "app://host/notifications".extractRouteFromDeepLink()) + assertEquals("notifications", "app://host/notifications/123".extractRouteFromDeepLink()) + assertEquals( + "notifications", + "app://host/notifications/123?param=value".extractRouteFromDeepLink() + ) + assertEquals("profile", "app://host/profile".extractRouteFromDeepLink()) + } + + @Test + fun testDecodeURIComponent() { + assertEquals("hello world", "hello+world".decodeURIComponent()) + assertEquals("hello world", "hello%20world".decodeURIComponent()) + } + + @Test + fun testMapQueryParams() { + val query = "?param1=value1¶m2=value2¶m1=value3" + val params = query.mapQueryParams() + assertEquals(2, params.size) + assertEquals(setOf("value1", "value3"), params["param1"]) + assertEquals(setOf("value2"), params["param2"]) + } + + @Test + fun testOverlaps() { + assertTrue("hello world".overlaps("hello")) + assertTrue("hello".overlaps("hello world")) + assertFalse("hello".overlaps("world")) + assertTrue("HELLO".overlaps("hello", ignoreCase = true)) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt new file mode 100644 index 000000000..9f632913e --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt @@ -0,0 +1,44 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.mocks + +import io.redlink.more.database.AppDatabase + +// NECESSARY!! DO NOT REMOVE! +// This interface is a workaround to mock the room database, as there is a an issue within Room +interface DB { + fun clearAllTables() {} +} + +fun mockAppDatabase(): AppDatabase { + return AppDatabase_Impl() +} + +class AppDatabase_Impl : AppDatabase(), DB { + override fun studyDao() = TODO() + override fun scheduleDao() = TODO() + override fun observationDao() = TODO() + override fun observationDataDao() = TODO() + override fun notificationDao() = TODO() + override fun bluetoothDeviceDao() = TODO() + override fun dataPointDao() = TODO() + override fun aggregatedObservationDataDao() = TODO() + + override fun createInvalidationTracker(): androidx.room.InvalidationTracker { + return androidx.room.InvalidationTracker(this, emptyMap(), emptyMap(), "") + } + + // DO NOT REMOVE THIS METHOD! IT IS NECESSARY FOR MOCKING THE DATABASE! + override fun clearAllTables() { + super.clearAllTables() + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/ImMemoryStorageRepository.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/InMemoryStorageRepository.kt similarity index 69% rename from shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/ImMemoryStorageRepository.kt rename to shared/src/commonTest/kotlin/io/redlink/more/mocks/InMemoryStorageRepository.kt index 2c0c93114..555fe311e 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/ImMemoryStorageRepository.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/InMemoryStorageRepository.kt @@ -1,17 +1,8 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.mocks -class ImMemoryStorageRepository : SharedStorageRepository { +import io.redlink.more.services.store.SharedStorageRepository +class InMemoryStorageRepository : SharedStorageRepository { private val storageMap = HashMap() override fun store(key: String, value: String) { diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockBluetoothConnector.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockBluetoothConnector.kt new file mode 100644 index 000000000..cb62d92ec --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockBluetoothConnector.kt @@ -0,0 +1,28 @@ +package io.redlink.more.mocks + +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.services.bluetooth.BluetoothConnector +import io.redlink.more.services.bluetooth.BluetoothConnectorObserver + +class MockBluetoothConnector : BluetoothConnector { + override var observer: MutableSet = mutableSetOf() + override val specificBluetoothConnectors: MutableMap = + mutableMapOf() + + override fun addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) {} + override fun addObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) {} + override fun removeObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) {} + override fun updateObserver(action: (BluetoothConnectorObserver) -> Unit) {} + override fun scan() {} + override fun connect(device: BluetoothDeviceEntity): Error? = null + override fun disconnect(device: BluetoothDeviceEntity) {} + override fun stopScanning() {} + override fun close() {} + override fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) {} + override fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) {} + override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) {} + override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) {} + override fun didDiscoverDevice(device: BluetoothDeviceEntity) {} + override fun removeDiscoveredDevice(device: BluetoothDeviceEntity) {} + override fun resetAll() {} +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDataRecorder.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDataRecorder.kt new file mode 100644 index 000000000..540988d02 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDataRecorder.kt @@ -0,0 +1,29 @@ +package io.redlink.more.mocks + +import io.redlink.more.observations.DataRecorder + +open class MockDataRecorder : DataRecorder { + var startCalled = false + var startMultipleCalled = false + var pauseCalled = false + var stopCalled = false + + override fun start(scheduleId: String) { + startCalled = true + } + + override fun startMultiple(scheduleIds: Set) { + startMultipleCalled = true + } + + override fun pause(scheduleId: String) { + pauseCalled = true + } + + override fun stop(scheduleId: String) { + stopCalled = true + } + + override fun stopAll() {} + override fun restartAll() {} +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDeeplinkManager.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDeeplinkManager.kt new file mode 100644 index 000000000..90312ccc2 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDeeplinkManager.kt @@ -0,0 +1,40 @@ +package io.redlink.more.mocks + +import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.navigation.DeeplinkManager +import io.redlink.more.navigation.model.DeepLinkData +import io.redlink.more.observations.ObservationFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class MockDeeplinkManager(repos: MainRepository) : DeeplinkManager { + override val observationFactory: ObservationFactory = MockObservationFactory(repos) + + override fun addAvailableDeepLinks(deepLinks: Set) {} + + override fun setProtocol(protocolReplacement: String?) {} + + override fun setHost(hostReplacement: String?) {} + + override fun getNotificationViewDeepLink(notificationId: String): Flow = + flowOf(DeepLinkData("app://more/notifications?notificationId=$notificationId")) + + override fun modifyDeepLink(deepLink: String?): Flow = + flowOf(deepLink?.let { DeepLinkData(it) }) + + override fun modifyDeepLink( + deepLink: String?, + newState: (DeepLinkData?) -> Unit + ): Closeable = object : Closeable { + override fun close() {} + } + + override fun validateRoute(deepLink: String): Boolean = true + + override fun createDeeplinkForSchedule( + schedule: ScheduleEntity, + baseDeeplink: String? + ): String = "app://more/task-details?scheduleId=${schedule.scheduleId}" +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockLocalNotificationListener.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockLocalNotificationListener.kt new file mode 100644 index 000000000..33211df02 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockLocalNotificationListener.kt @@ -0,0 +1,26 @@ +package io.redlink.more.mocks + +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.services.notification.LocalNotificationListener + +class MockLocalNotificationListener : LocalNotificationListener { + val displayedNotifications = mutableListOf() + var clearScheduledCount = 0 + + override fun displayNotification(notification: NotificationEntity, badgeCount: Int) { + displayedNotifications.add(notification) + } + + override fun clearScheduledNotifications(notifications: List) { + clearScheduledCount++ + } + + override fun deleteNotificationFromSystem(notificationId: String) {} + override fun createNewFCMToken(onCompletion: (String) -> Unit) { + onCompletion("new_fcm_token") + } + + override fun clearNotifications() {} + override fun deleteFCMToken() {} + override fun updateBadgeCount(count: Int) {} +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNetworkService.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNetworkService.kt new file mode 100644 index 000000000..2a5f7bf91 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNetworkService.kt @@ -0,0 +1,54 @@ +package io.redlink.more.mocks + +import io.ktor.http.Url +import io.redlink.more.app.android.services.network.errors.NetworkServiceError +import io.redlink.more.models.CredentialModel +import io.redlink.more.models.LoginModel +import io.redlink.more.services.network.NetworkService +import io.redlink.more.services.network.openapi.model.AppConfiguration +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.network.openapi.model.PushNotification +import io.redlink.more.services.network.openapi.model.Study +import io.redlink.more.services.network.openapi.model.StudyConsent + +class MockNetworkService : NetworkService { + var lastSentToken: String? = null + var missedNotifications = listOf() + + override fun baseUrl(): String = "http://localhost" + + override suspend fun deleteParticipation(): Pair = + Pair(true, null) + + override suspend fun validateRegistrationToken(loginModel: LoginModel): Pair = + Pair(null, null) + + override suspend fun sendConsent( + loginModel: LoginModel, + studyConsent: StudyConsent + ): Pair = Pair(null, null) + + override suspend fun getStudyConfig(credentials: CredentialModel?): Pair = + Pair(null, null) + + override suspend fun sendNotificationToken(token: String): Pair { + lastSentToken = token + return Pair(true, null) + } + + override suspend fun sendData(data: DataBulk): Pair, NetworkServiceError?> = + Pair(data.dataPoints.map { it.observationId }.toSet(), null) + + override suspend fun downloadMissedNotifications(): List = + missedNotifications + + override fun getBasicAuthHeader(): String? = null + + override fun getGarminSSOUrl(): Url? = null + + override fun garminSSOCallbackUrl(): Url? = null + + override suspend fun garminSSOCallback(code: String, status: String): Boolean = true + + override suspend fun deletePushNotification(msgId: String) {} +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationActionObserver.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationActionObserver.kt new file mode 100644 index 000000000..fd6d92f87 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationActionObserver.kt @@ -0,0 +1,16 @@ +package io.redlink.more.mocks + +import io.redlink.more.models.StudyState +import io.redlink.more.services.notification.NotificationActionObserver + +class MockNotificationActionObserver : NotificationActionObserver { + var updateStudyCalled = false + var lastOldStudyState: StudyState? = null + var lastNewStudyState: StudyState? = null + + override fun updateStudy(oldStudyState: StudyState?, newStudyState: StudyState?) { + updateStudyCalled = true + lastOldStudyState = oldStudyState + lastNewStudyState = newStudyState + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationManager.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationManager.kt new file mode 100644 index 000000000..796cf226b --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationManager.kt @@ -0,0 +1,34 @@ +package io.redlink.more.mocks + +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.navigation.DeeplinkManager +import io.redlink.more.navigation.model.DeepLinkData +import io.redlink.more.services.network.NetworkService +import io.redlink.more.services.notification.LocalNotificationListener +import io.redlink.more.services.notification.NotificationActionHandler +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.services.store.SharedStorageRepository + +class MockNotificationManager( + repository: MainRepository, + localNotificationListener: LocalNotificationListener, + networkService: NetworkService, + deeplinkManager: DeeplinkManager, + sharedStorageRepository: SharedStorageRepository +) : NotificationManager( + repository, + localNotificationListener, + networkService, + deeplinkManager, + sharedStorageRepository +) { + var handleNotificationInteractionCalled = false + + override fun handleNotificationInteraction( + notificationId: String, + deepLink: String?, + handler: (NotificationActionHandler, DeepLinkData?) -> Unit + ) { + handleNotificationInteractionCalled = true + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt new file mode 100644 index 000000000..5664a9603 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt @@ -0,0 +1,512 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.mocks + +import io.ktor.utils.io.core.Closeable +import io.redlink.more.database.entities.AggregatedObservationDataEntity +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.DataPointEntity +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.database.repository.AggregatedObservationDataRepository +import io.redlink.more.database.repository.BluetoothDeviceRepository +import io.redlink.more.database.repository.DataPointCountRepository +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.database.repository.NotificationRepository +import io.redlink.more.database.repository.ObservationDataRepository +import io.redlink.more.database.repository.ObservationRepository +import io.redlink.more.database.repository.ScheduleRepository +import io.redlink.more.database.repository.StudyRepository +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.DataRecorder +import io.redlink.more.observations.ObservationDataManager +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.observationTypes.ObservationType +import io.redlink.more.scopes.MoreScope +import io.redlink.more.scopes.StudyMoreScope +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.network.openapi.model.Study +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.datetime.Instant +import kotlin.coroutines.CoroutineContext + +class MockMoreScope(private val testScope: TestScope) : MoreScope { + override val coroutineContext: CoroutineContext = testScope.coroutineContext + + val jobs = mutableMapOf() + + override fun launch( + coroutineContext: CoroutineContext, + start: CoroutineStart, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val uuid = "job_${jobs.size}" + val job = testScope.launch(coroutineContext, start, block) + jobs[uuid] = job + return uuid to job + } + + override fun repeatedLaunch( + intervalMillis: Long, + coroutineContext: CoroutineContext, + initalDelay: Long, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val uuid = "repeat_${jobs.size}" + val job = testScope.launch(coroutineContext) { + // Simple mock: just run it once or handle it as needed in tests + block() + } + jobs[uuid] = job + return uuid to job + } + + override fun cancel(uuid: String) { + jobs[uuid]?.cancel() + } + + override fun cancel(uuids: Collection) { + uuids.forEach { cancel(it) } + } + + override fun cancel() { + jobs.values.forEach { it.cancel() } + } +} + +class MockStudyMoreScope(private val testScope: TestScope) : StudyMoreScope { + override val coroutineContext: CoroutineContext = testScope.coroutineContext + + val jobs = mutableMapOf() + + override fun launch( + coroutineContext: CoroutineContext, + start: CoroutineStart, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val uuid = "study_job_${jobs.size}" + val job = testScope.launch(coroutineContext, start, block) + jobs[uuid] = job + return uuid to job + } + + override fun repeatedLaunch( + intervalMillis: Long, + coroutineContext: CoroutineContext, + initalDelay: Long, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val uuid = "study_repeat_${jobs.size}" + val job = testScope.launch(coroutineContext) { + block() + } + jobs[uuid] = job + return uuid to job + } + + override fun cancel(uuid: String) { + jobs[uuid]?.cancel() + } + + override fun cancel(uuids: Collection) { + uuids.forEach { cancel(it) } + } + + override fun cancel() { + jobs.values.forEach { it.cancel() } + } +} + +class MockNotificationRepository : NotificationRepository { + private val notifications = MutableStateFlow>(emptyMap()) + + override suspend fun storeNotification(notification: NotificationEntity) { + notifications.value += (notification.notificationId to notification) + } + + override suspend fun storeNotifications(notifications: List) { + this.notifications.value += notifications.associateBy { it.notificationId } + } + + override suspend fun getNotification(notificationId: String): NotificationEntity? { + return notifications.value[notificationId] + } + + override fun setNotificationReadStatus(key: String, read: Boolean) { + notifications.value[key]?.let { + notifications.value += (key to it.copy(read = read)) + } + } + + override fun setNotificationCompletedStatus(key: String, completed: Boolean) { + notifications.value[key]?.let { + notifications.value += (key to it.copy(completed = completed)) + } + } + + override fun deleteNotification(notificationId: String) { + notifications.value -= notificationId + } + + override suspend fun scheduledNotifications(): List { + return notifications.value.values.filter { !it.userFacing } + } + + override fun getAllUserFacingNotifications(): Flow> { + return notifications.map { it.values.filter { n -> n.userFacing }.toList() } + } + + override suspend fun deleteAll() { + notifications.value = emptyMap() + } + + override suspend fun update(notificationId: String, read: Boolean?, priority: Long?) { + notifications.value[notificationId]?.let { + notifications.value += (notificationId to it.copy( + read = read ?: it.read, + priority = priority ?: it.priority + )) + } + } + + override suspend fun scheduledNotificationCount(): Int = scheduledNotifications().size +} + +class MockObservationRepository : ObservationRepository { + private val observations = + MutableStateFlow>(emptyMap()) + + val timestamps = MutableStateFlow>(emptyMap()) + + override fun observationById(observationId: String): Flow { + return observations.map { it[observationId] } + } + + fun storeObservation(observation: ObservationEntity) { + observations.value += (observation.observationId to observation) + } + + override suspend fun getCount(): Int = observations.value.size + + override fun observations(): Flow> = + observations.map { it.values.toList() } + + override fun observationWithUndoneSchedules(): Flow>> = + flowOf(emptyMap()) + + override suspend fun updateLastCollection(type: String, timestamp: Long) {} + + override suspend fun updateLastCollection(types: Set, timestamp: Long) {} + + override fun collectionTimestamp(type: String): Flow = timestamps.map { it[type] } + + override fun collectAllTimestamps(): Flow> = timestamps + + override fun collectTimestampForObservationIds(observationIds: Set): Flow = + flowOf(0L) + + override fun collectTimestampOfType( + type: String, + newState: (Long?) -> Unit + ): Closeable = object : Closeable { + override fun close() {} + } + + override fun collectAllTimestamps(newState: (Map) -> Unit): Closeable = + object : Closeable { + override fun close() {} + } + + override fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable = + object : Closeable { + override fun close() {} + } + + override fun observationTypes(): Flow> = + observations.map { it.values.map { o -> o.observationType }.toSet() } + + override suspend fun getObservationByObservationId(observationId: String): ObservationEntity? = + observations.value[observationId] +} + +class MockScheduleRepository : ScheduleRepository { + private val _schedules = MutableStateFlow>(emptyMap()) + val schedules: StateFlow> = _schedules + var scheduleWithIdResult: Flow? = null + var firstScheduleAvailableForObservationIdResult: Flow? = null + + override fun scheduleWithId(id: String): Flow = + scheduleWithIdResult ?: _schedules.map { it[id] } + + override fun firstScheduleAvailableForObservationId(observationId: String): Flow = + firstScheduleAvailableForObservationIdResult + ?: _schedules.map { it.values.find { s -> s.observationId == observationId } } + + fun storeSchedule(schedule: ScheduleEntity) { + _schedules.value += (schedule.scheduleId to schedule) + } + + override fun count(): Flow = _schedules.map { it.size } + + override fun allSchedulesWithStatus(done: Boolean): Flow> = + _schedules.map { it.values.filter { s -> s.done == done } } + + override fun allSchedulesWithStates(states: Set): Flow> = + _schedules.map { it.values.filter { s -> s.getState() in states } } + + override fun getSchedulesWithReminder( + states: Set, + minTimestamp: Instant, + maxTimestamp: Instant, + limit: Int + ): Flow> = flowOf(emptyList()) + + override fun allScheduleWithRunningState(scheduleState: ScheduleState): Flow> = + _schedules.map { it.values.filter { s -> s.getState() == scheduleState } } + + override fun allSchedulesToday(observationType: ObservationType): Flow> = + flowOf(emptyList()) + + override fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = + firstScheduleAvailableForObservationId(observationId).map { it?.scheduleId } + + override fun observationTypesForScheduleIds(scheduleIds: Set): Flow> = + _schedules.map { + it.values.filter { s -> s.scheduleId in scheduleIds }.map { s -> s.observationType } + .toSet() + } + + var firstAndLastDateResult: Flow>? = null + override fun getFirstAndLastDate(observationId: String): Flow> = + firstAndLastDateResult ?: flowOf(null to null) + + var lastSetRunningState: Pair? = null + override suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) { + lastSetRunningState = id to scheduleState + _schedules.value[id]?.let { + _schedules.value += (id to it.copy(state = scheduleState.name)) + } + } + + var lastSetCompletionState: Pair? = null + override suspend fun setCompletionStateFor(id: String, wasDone: Boolean) { + lastSetCompletionState = id to wasDone + _schedules.value[id]?.let { + _schedules.value += (id to it.copy(done = wasDone)) + } + } + + override suspend fun updateTaskStates( + observationFactory: ObservationFactory, + dataRecorder: DataRecorder + ) { + } +} + +class MockObservationDataRepository : ObservationDataRepository { + val addedData = mutableListOf() + var storeCalled = false + var count = 0 + var deletedIds = setOf() + + override fun addData(dataList: List) { + addedData.addAll(dataList) + } + + override suspend fun store() { + storeCalled = true + } + + override suspend fun getCount(): Int = count + + override suspend fun allAsBulk(): DataBulk? = null + + override suspend fun deleteAllWithId(idSet: Set) { + deletedIds = idSet + } +} + +class MockDataPointCountRepository : DataPointCountRepository { + val increments = mutableListOf, Long>>() + val deletedScheduleIds = mutableListOf() + var dataPointResults = mutableMapOf>() + + override fun count(): Flow = flowOf(0L) + + override fun incrementCount(scheduleIdSet: Set, addCount: Long) { + increments.add(scheduleIdSet to addCount) + } + + override fun get(scheduleId: String): Flow = + dataPointResults[scheduleId] ?: flowOf(null) + + override fun delete(scheduleId: String) { + deletedScheduleIds.add(scheduleId) + } +} + +class MockBluetoothDeviceRepository : BluetoothDeviceRepository { + override fun storePairedDevice(bluetoothDevice: BluetoothDeviceEntity) {} + override fun unpairDevice(bluetoothDevice: BluetoothDeviceEntity) {} + override fun pairedDevices(): Flow> = flowOf(emptyList()) +} + +class MockStudyRepository : StudyRepository { + private val _study = MutableStateFlow(null) + override val study: StateFlow = _study + + private val _studyState = MutableStateFlow(io.redlink.more.models.StudyState.NONE) + override val studyState: StateFlow = _studyState + + private val _finishText = MutableStateFlow(null) + override val finishText: StateFlow = _finishText + + override suspend fun upsert(study: Study) { + _study.value = StudyEntity.fromStudy(study) + } + + suspend fun upsert(study: StudyEntity) { + _study.value = study + } + + override fun getStudy(): Flow = study + + override suspend fun updateStudyState(state: io.redlink.more.models.StudyState) { + _studyState.value = state + } + + override suspend fun deleteStudy() { + _study.value = null + } +} + + +class MockAggregatedObservationDataRepository : AggregatedObservationDataRepository { + private val data = MutableStateFlow>(emptyMap()) + + override suspend fun insert(entity: AggregatedObservationDataEntity) { + data.value += (entity.id to entity) + } + + override suspend fun insertAll(entities: List) { + data.value += entities.associateBy { it.id } + } + + override suspend fun update(entity: AggregatedObservationDataEntity) { + data.value += (entity.id to entity) + } + + override suspend fun delete(entity: AggregatedObservationDataEntity) { + data.value -= entity.id + } + + override suspend fun deleteById(id: String) { + data.value -= id + } + + override suspend fun deleteByObservationId(observationId: String) { + data.value = data.value.filterValues { it.observationId != observationId } + } + + override suspend fun deleteAll() { + data.value = emptyMap() + } + + override suspend fun getById(id: String): AggregatedObservationDataEntity? = data.value[id] + + override fun getByIdFlow(id: String): Flow = + data.map { it[id] } + + override suspend fun getByObservationId(observationId: String): List = + data.value.values.filter { it.observationId == observationId } + + override fun getByObservationIdFlow(observationId: String): Flow> = + data.map { it.values.filter { e -> e.observationId == observationId } } + + override suspend fun getByObservationType(observationType: String): List = + data.value.values.filter { it.observationType == observationType } + + override fun getByObservationTypeFlow(observationType: String): Flow> = + data.map { it.values.filter { e -> e.observationType == observationType } } + + override suspend fun getAll(): List = + data.value.values.toList() + + override fun getAllFlow(): Flow> = + data.map { it.values.toList() } + + override suspend fun getCount(): Int = data.value.size + + override suspend fun getCountByObservationId(observationId: String): Int = + data.value.values.count { it.observationId == observationId } +} + +class MockMainRepository() : MainRepository { + private val _mockSchedule = MockScheduleRepository() + private val _mockNotification = MockNotificationRepository() + private val _mockObservation = MockObservationRepository() + private val _mockObservationData = MockObservationDataRepository() + private val _mockDataPointCount = MockDataPointCountRepository() + private val _mockBluetoothDevice = MockBluetoothDeviceRepository() + private val _mockStudy = MockStudyRepository() + private val _mockAggregatedObservationData = MockAggregatedObservationDataRepository() + + override val schedule: ScheduleRepository get() = _mockSchedule + override val notification: NotificationRepository get() = _mockNotification + override val observation: ObservationRepository get() = _mockObservation + override val observationData: ObservationDataRepository get() = _mockObservationData + override val dataPointCount: DataPointCountRepository get() = _mockDataPointCount + override val bluetoothDevice: BluetoothDeviceRepository get() = _mockBluetoothDevice + override val study: StudyRepository get() = _mockStudy + override val aggregatedObservationData: AggregatedObservationDataRepository get() = _mockAggregatedObservationData + + val mockSchedule: MockScheduleRepository get() = _mockSchedule + val mockNotification: MockNotificationRepository get() = _mockNotification + val mockObservation: MockObservationRepository get() = _mockObservation + val mockObservationData: MockObservationDataRepository get() = _mockObservationData + val mockDataPointCount: MockDataPointCountRepository get() = _mockDataPointCount + val mockStudy: MockStudyRepository get() = _mockStudy + val mockAggregatedObservationData: MockAggregatedObservationDataRepository get() = _mockAggregatedObservationData + + override suspend fun deleteAll() { + _mockStudy.deleteStudy() + _mockNotification.deleteAll() + _mockAggregatedObservationData.deleteAll() + } +} + +class MockObservationFactory( + repository: MainRepository = MockMainRepository(), + sharedStorageRepository: MockSharedStorageRepository = MockSharedStorageRepository(), + dataManager: ObservationDataManager? = null +) : ObservationFactory( + repository, + sharedStorageRepository, + dataManager ?: mockObservationDataManager(repository) +) { + var matchingObservationTypes: Set = emptySet() + + override fun getMatchingObservationTypes(types: Set): Set = + matchingObservationTypes +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt new file mode 100644 index 000000000..6e81f1f2d --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt @@ -0,0 +1,66 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.mocks + +import dev.tmapps.konnection.Konnection +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.ObservationDataManager + + +import io.redlink.more.services.store.SharedStorageRepository + +fun mockObservationDataManager(repository: MainRepository = MockMainRepository()): ObservationDataManager { + return object : ObservationDataManager(repository) { + override val konnection: Konnection? = null + override fun isConnected(): Boolean = false + override fun sendData(immediately: Boolean, onCompletion: (Boolean) -> Unit) {} + } +} + +class MockSharedStorageRepository : SharedStorageRepository { + private val data = mutableMapOf() + + override fun store(key: String, value: String) { + data[key] = value + } + + override fun store(key: String, value: Boolean) { + data[key] = value + } + + override fun store(key: String, value: Int) { + data[key] = value + } + + override fun store(key: String, value: Float) { + data[key] = value + } + + override fun store(key: String, value: Double) { + data[key] = value + } + + override fun store(key: String, value: Long) { + data[key] = value + } + + override fun load(key: String, default: String): String = data[key] as? String ?: default + override fun load(key: String, default: Boolean): Boolean = data[key] as? Boolean ?: default + override fun load(key: String, default: Int): Int = data[key] as? Int ?: default + override fun load(key: String, default: Float): Float = data[key] as? Float ?: default + override fun load(key: String, default: Double): Double = data[key] as? Double ?: default + override fun load(key: String, default: Long): Long = data[key] as? Long ?: default + + override fun remove(key: String) { + data.remove(key) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepositoryTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepositoryTest.kt deleted file mode 100644 index aaa01b430..000000000 --- a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepositoryTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more - * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute - * for Digital Health and Prevention -- A research institute of the - * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur - * Förderung der wissenschaftlichen Forschung). - * Licensed under the Apache 2.0 license with Commons Clause - * (see https://www.apache.org/licenses/LICENSE-2.0 and - * https://commonsclause.com/). - */ -package io.redlink.more.more_app_mutliplatform.services.store - -import io.redlink.more.more_app_mutliplatform.models.CredentialModel -import kotlin.test.* - - -class CredentialRepositoryTest { - - - @Test - fun testStore() { - val storage = ImMemoryStorageRepository() - - val repo = CredentialRepository(storage) - assertFalse("Empty Start") { repo.hasCredentials() } - assertNull(repo.credentials()) - - val credentials = CredentialModel("secretApiId", "secretApiKey") - assertTrue { repo.store(credentials) } - assertEquals(credentials, repo.credentials()) - - val newRepo = CredentialRepository(storage) - assertTrue { newRepo.hasCredentials() } - assertEquals(credentials, newRepo.credentials()) - - } - -} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/navigation/DeeplinkManagerTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/navigation/DeeplinkManagerTest.kt new file mode 100644 index 000000000..75344c008 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/navigation/DeeplinkManagerTest.kt @@ -0,0 +1,181 @@ +package io.redlink.more.navigation + +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockObservationFactory +import io.redlink.more.mocks.MockScheduleRepository +import io.redlink.more.models.ScheduleState +import io.redlink.more.navigation.model.NavigationRoute +import io.redlink.more.navigation.model.NavigationRouteParameter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.datetime.Clock +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DeeplinkManagerTest { + + private lateinit var deeplinkManager: DeeplinkManager + + private lateinit var mainRepository: MockMainRepository + + private lateinit var scheduleRepository: MockScheduleRepository + + private lateinit var observationFactory: MockObservationFactory + + private val testDispatcher = StandardTestDispatcher() + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + mainRepository = MockMainRepository() + scheduleRepository = mainRepository.mockSchedule + observationFactory = MockObservationFactory() + } + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testAddAvailableDeepLinksAndRouteValidation() = runTest { + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + + val links = setOf("app://more/dashboard", "notifications") + deeplinkManager.addAvailableDeepLinks(links) + + assertTrue(deeplinkManager.validateRoute("app://more/dashboard")) + assertTrue(deeplinkManager.validateRoute("notifications")) + assertTrue(deeplinkManager.validateRoute("/dashboard")) + } + + @Test + fun testSetProtocolAndSetHost() = runTest { + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + deeplinkManager.setProtocol("https") + deeplinkManager.setHost("redlink.io") + + val deepLink = "app://oldhost/dashboard" + val result = deeplinkManager.modifyDeepLink(deepLink).first() + + assertNotNull(result) + assertEquals("https://redlink.io/dashboard", result.route) + } + + @Test + fun testGetNotificationViewDeepLink() = runTest { + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + val notificationId = "123" + val result = deeplinkManager.getNotificationViewDeepLink(notificationId).first() + + assertNotNull(result) + // Now using robust construction with defaults "app" and "more" + val expected = + "app://more/${NavigationRoute.NOTIFICATIONS.route}?${NavigationRouteParameter.NOTIFICATION_ID.key}=$notificationId" + assertEquals(expected, result.route) + assertEquals(notificationId, result.params[NavigationRouteParameter.NOTIFICATION_ID.key]) + } + + @Test + fun testModifyDeepLinkWithScheduleId() = runTest { + val scheduleId = "sched1" + val observationId = "obs1" + val now = Clock.System.now().epochSeconds + val schedule = ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = "testType", + start = now - 100, + end = now + 100, + state = ScheduleState.ACTIVE.name + ) + + scheduleRepository.scheduleWithIdResult = flowOf(schedule) + + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + + val deepLink = "app://host/task-details?scheduleId=$scheduleId" + val result = deeplinkManager.modifyDeepLink(deepLink).first() + + assertNotNull(result) + assertEquals(scheduleId, result.params[NavigationRouteParameter.SCHEDULE_ID.key]) + assertEquals(observationId, result.params[NavigationRouteParameter.OBSERVATION_ID.key]) + } + + @Test + fun testModifyDeepLinkWithObservationId() = runTest { + val observationId = "obs1" + val scheduleId = "sched1" + val now = Clock.System.now().epochSeconds + val schedule = ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = "testType", + start = now - 100, + end = now + 100, + state = ScheduleState.ACTIVE.name + ) + + scheduleRepository.firstScheduleAvailableForObservationIdResult = flowOf(schedule) + + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + + val deepLink = "app://host/observation-details?observationId=$observationId" + val result = deeplinkManager.modifyDeepLink(deepLink).first() + + assertNotNull(result) + assertEquals(observationId, result.params[NavigationRouteParameter.OBSERVATION_ID.key]) + assertEquals(scheduleId, result.params[NavigationRouteParameter.SCHEDULE_ID.key]) + } + + @Test + fun testRouteMatchesWithSuffixes() = runTest { + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + + val registeredRoute = "question-observation" + deeplinkManager.addAvailableDeepLinks(setOf("app://host/$registeredRoute")) + + val incomingDeepLink = "app://host/${registeredRoute}_response?observationId=123" + val result = deeplinkManager.modifyDeepLink(incomingDeepLink).first() + + assertNotNull(result) + assertTrue(result.route.contains(registeredRoute)) + } + + @Test + fun testModifyDeepLinkNullInput() = runTest { + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + val result = deeplinkManager.modifyDeepLink(null).first() + assertNull(result) + } + + @Test + fun testCreateDeeplinkForSchedule() { + observationFactory.matchingObservationTypes = setOf("question-observation") + + deeplinkManager = DeeplinkManagerImpl(mainRepository, observationFactory) + val schedule = ScheduleEntity( + scheduleId = "s1", + observationId = "o1", + observationType = "question-observation" + ) + + val result = deeplinkManager.createDeeplinkForSchedule(schedule) + assertTrue(result.contains("scheduleId=s1")) + assertTrue(result.contains("observationId=o1")) + assertTrue(result.contains("question-observation")) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt new file mode 100644 index 000000000..0ac2dbbe9 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations + +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.observations.longRunningObservation.InMemoryLongRunningObservationStorage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class InMemoryLongRunningObservationStorageTest { + + @Test + fun testStoreInstant() { + var storedData: Any? = null + var storedTimestamp: Long? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { data, timestamp -> + storedData = data + storedTimestamp = timestamp + }, + onFinish = { _, _, _, _ -> } + ) + + val data = "instantData" + val timestamp = 123456789L + storage.storeInstant(data, timestamp) + + assertEquals(data, storedData) + assertEquals(timestamp, storedTimestamp) + } + + @Test + fun testStartAndFinishObservation() { + var finishedData: Any? = null + var finishedIdentifier: String? = null + var finishedStartTimestamp: Long? = null + var finishedEndTimestamp: Long? = null + + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { data, identifier, startTimestamp, endTimestamp -> + finishedData = data + finishedIdentifier = identifier + finishedStartTimestamp = startTimestamp + finishedEndTimestamp = endTimestamp + } + ) + + val startData = "startData" + val identifier = "obs1" + val startTimestamp = 1000L + val endTimestamp = 2000L + + storage.startObservation(startData, identifier, startTimestamp) + assertTrue(storage.hasOpenObservation(identifier)) + + storage.finishObservation(startData, identifier, endTimestamp) + assertFalse(storage.hasOpenObservation(identifier)) + + assertTrue(finishedData is List<*>) + val finishedList = finishedData as List<*> + assertEquals(2, finishedList.size) + val first = finishedList[0] as Pair<*, *> + val second = finishedList[1] as Pair<*, *> + assertEquals(startData, first.first) + assertEquals(startTimestamp, first.second) + assertEquals(startData, second.first) + assertEquals(endTimestamp, second.second) + assertEquals(identifier, finishedIdentifier) + assertEquals(startTimestamp, finishedStartTimestamp) + assertEquals(endTimestamp, finishedEndTimestamp) + } + + @Test + fun testUpdateObservation() { + var finishedData: Any? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { data, _, _, _ -> + finishedData = data + } + ) + + val startData = "startData" + val updateData = "updateData" + val identifier = "obs1" + val startTimestamp = 1000L + val updateTimestamp = 1500L + val endTimestamp = 2000L + + storage.startObservation(startData, identifier, startTimestamp) + storage.updateObservation(updateData, identifier, updateTimestamp) + storage.finishObservation(updateData, identifier, endTimestamp) + + assertTrue(finishedData is List<*>) + val finishedList = finishedData as List<*> + assertEquals(3, finishedList.size) + val first = finishedList[0] as Pair<*, *> + val second = finishedList[1] as Pair<*, *> + val third = finishedList[2] as Pair<*, *> + assertEquals(startData, first.first) + assertEquals(startTimestamp, first.second) + assertEquals(updateData, second.first) + assertEquals(updateTimestamp, second.second) + assertEquals(updateData, third.first) + assertEquals(endTimestamp, third.second) + } + + @Test + fun testFlush() { + val finishedObservations = mutableListOf() + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedObservations.add(identifier) + } + ) + + storage.startObservation("data1", "obs1", 1000L) + storage.startObservation("data2", "obs2", 1100L) + + assertTrue(storage.hasOpenObservation("obs1")) + assertTrue(storage.hasOpenObservation("obs2")) + + storage.flush(2000L) + + assertFalse(storage.hasOpenObservation("obs1")) + assertFalse(storage.hasOpenObservation("obs2")) + assertEquals(2, finishedObservations.size) + assertTrue(finishedObservations.contains("obs1")) + assertTrue(finishedObservations.contains("obs2")) + } + + @Test + fun testFinishWithoutExactIdentifier() { + var finishedIdentifier: String? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedIdentifier = identifier + } + ) + + storage.startObservation("data1", "obs1", 1000L) + storage.finishObservation("data1", "somethingElse", 2000L) + + assertEquals("obs1", finishedIdentifier) + assertFalse(storage.hasOpenObservation("obs1")) + } + + @Test + fun testFinishWithNullIdentifier() { + var finishedIdentifier: String? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedIdentifier = identifier + } + ) + + storage.startObservation("data1", "obs1", 1000L) + storage.finishObservation("data1", "obs1", 2000L) + + assertEquals("obs1", finishedIdentifier) + } + + @Test + fun testLogEventFamilyMatching() { + var finishedIdentifier: String? = null + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, identifier, _, _ -> + finishedIdentifier = identifier + } + ) + + // APP_IN_FOREGROUND and APP_IN_BACKGROUND share the same family APP_VISIBILITY + val startEvent = LogEvent.APP_IN_FOREGROUND + val endEvent = LogEvent.APP_IN_BACKGROUND + val identifier = "test_identifier" + val startTimestamp = 1000L + val endTimestamp = 2000L + + storage.startObservation(startEvent, identifier, startTimestamp) + storage.finishObservation(endEvent, identifier, endTimestamp) + + assertEquals(identifier, finishedIdentifier) + assertFalse(storage.hasOpenObservation(identifier)) + } + + @Test + fun testClear() { + var finishedCalled = false + val storage = InMemoryLongRunningObservationStorage( + onStoreInstant = { _, _ -> }, + onFinish = { _, _, _, _ -> + finishedCalled = true + } + ) + + storage.startObservation("data1", "obs1", 1000L) + assertTrue(storage.hasOpenObservation("obs1")) + + storage.clear() + + assertFalse(storage.hasOpenObservation("obs1")) + assertFalse(finishedCalled) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationDataManagerTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationDataManagerTest.kt new file mode 100644 index 000000000..ed319ca93 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationDataManagerTest.kt @@ -0,0 +1,199 @@ +package io.redlink.more.observations + +import io.redlink.more.database.entities.ObservationDataEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockMoreScope +import io.redlink.more.mocks.MockStudyMoreScope +import io.redlink.more.models.StudyState +import io.redlink.more.scopes.MoreDispatchers +import io.redlink.more.scopes.MoreScope +import io.redlink.more.scopes.StudyMoreScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ObservationDataManagerTest { + private lateinit var repository: MockMainRepository + private lateinit var dataManager: TestObservationDataManager + private lateinit var testDispatcher: TestDispatcher + private lateinit var testScope: TestScope + + private val testDispatchers = object : MoreDispatchers { + override val default: CoroutineDispatcher get() = testDispatcher + override val main: CoroutineDispatcher get() = testDispatcher + override val io: CoroutineDispatcher get() = testDispatcher + } + + @BeforeTest + fun setUp() { + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + testScope = TestScope(testDispatcher) + repository = MockMainRepository() + dataManager = TestObservationDataManager( + repository, + MockMoreScope(testScope), + MockStudyMoreScope(testScope), + testDispatchers + ) + } + + @AfterTest + fun tearDown() { + testScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun testAddData() = runTest { + val data = listOf(ObservationDataEntity(dataValue = "test")) + val scheduleIds = setOf("s1") + repository.mockStudy.updateStudyState(StudyState.ACTIVE) + + dataManager.add(data, scheduleIds) + + assertEquals(1, repository.mockObservationData.addedData.size) + assertEquals("test", repository.mockObservationData.addedData[0].dataValue) + assertEquals(1, repository.mockDataPointCount.increments.size) + assertEquals(scheduleIds, repository.mockDataPointCount.increments[0].first) + assertEquals(1L, repository.mockDataPointCount.increments[0].second) + } + + @Test + fun testAddEmptyData() = runTest { + dataManager.add(emptyList(), setOf("s1")) + assertEquals(0, repository.mockObservationData.addedData.size) + assertEquals(0, repository.mockDataPointCount.increments.size) + } + + @Test + fun testAddDataStudyInactive() = runTest { + repository.mockStudy.updateStudyState(StudyState.PAUSED) + val data = listOf(ObservationDataEntity(dataValue = "test")) + dataManager.add(data, setOf("s1")) + + assertEquals(1, repository.mockObservationData.addedData.size) + // Should NOT start listening if inactive + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.runCurrent() + assertTrue(!dataManager.sendDataCalled) + } + + @Test + fun testRemoveDataPointCount() { + dataManager.removeDataPointCount("s1") + // No easy way to verify as scheduleCount is private and removeDataPointCount just removes from it. + // But we can verify it doesn't crash. + } + + @Test + fun testStopListeningToCountChanges() = runTest(testDispatcher) { + repository.mockStudy.updateStudyState(StudyState.ACTIVE) + repository.mockObservationData.count = 5 + dataManager.listenToDatapointCountChanges() + + dataManager.stopListeningToCountChanges() + + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.runCurrent() + + assertTrue(!dataManager.sendDataCalled) + } + + @Test + fun testListenToDatapointCountChangesNoConnection() = runTest(testDispatcher) { + repository.mockObservationData.count = 5 + dataManager.connected = false + repository.mockStudy.updateStudyState(StudyState.ACTIVE) + + dataManager.listenToDatapointCountChanges() + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.runCurrent() + + assertTrue(!dataManager.sendDataCalled) + } + + @Test + fun testListenToDatapointCountChangesZeroCount() = runTest(testDispatcher) { + repository.mockObservationData.count = 0 + dataManager.connected = true + repository.mockStudy.updateStudyState(StudyState.ACTIVE) + + dataManager.listenToDatapointCountChanges() + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.runCurrent() + + assertTrue(!dataManager.sendDataCalled) + } + + @Test + fun testSaveAndSend() = runTest(testDispatcher) { + dataManager.saveAndSend() + advanceUntilIdle() + assertTrue(repository.mockObservationData.storeCalled) + } + + @Test + fun testStore() = runTest(testDispatcher) { + dataManager.store() + advanceUntilIdle() + assertTrue(repository.mockObservationData.storeCalled) + } + + @Test + fun testSendDataSuspend() = runTest(testDispatcher) { + val result = dataManager.sendData(true) + assertTrue(result) + assertTrue(dataManager.sendDataCalled) + testDispatcher.scheduler.advanceTimeBy(1.seconds) + assertEquals(true, dataManager.lastImmediately) + } + + @Test + fun testListenToDatapointCountChanges() = runTest(testDispatcher) { + repository.mockObservationData.count = 5 + dataManager.connected = true + repository.mockStudy.updateStudyState(StudyState.ACTIVE) + + dataManager.listenToDatapointCountChanges() + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.runCurrent() + + assertTrue(dataManager.sendDataCalled) + } + + class TestObservationDataManager( + repository: MainRepository, + scope: MoreScope, + studyScope: StudyMoreScope, + dispatchers: MoreDispatchers + ) : ObservationDataManager(repository, scope, studyScope, dispatchers) { + var sendDataCalled = false + var lastImmediately: Boolean? = null + + override fun sendData(immediately: Boolean, onCompletion: (Boolean) -> Unit) { + sendDataCalled = true + lastImmediately = immediately + onCompletion(true) + } + + var connected = true + override fun isConnected(): Boolean = connected + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt new file mode 100644 index 000000000..094c5eb87 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt @@ -0,0 +1,375 @@ +package io.redlink.more.observations + +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockObservationFactory +import io.redlink.more.mocks.MockStudyMoreScope +import io.redlink.more.models.ScheduleState +import io.redlink.more.observations.observationTypes.ObservationType +import io.redlink.more.scopes.MoreDispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ObservationManagerTest { + private lateinit var repository: MockMainRepository + private lateinit var observationFactory: MockObservationFactory + private lateinit var dataRecorder: MockDataRecorder + private lateinit var observationManager: ObservationManager + private lateinit var testDispatcher: TestDispatcher + private lateinit var testScope: TestScope + + private val testDispatchers = object : MoreDispatchers { + override val default: CoroutineDispatcher get() = testDispatcher + override val main: CoroutineDispatcher get() = testDispatcher + override val io: CoroutineDispatcher get() = testDispatcher + } + + @BeforeTest + fun setUp() { + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + testScope = TestScope(testDispatcher) + repository = MockMainRepository() + observationFactory = MockObservationFactory(repository) + dataRecorder = MockDataRecorder() + observationManager = ObservationManager( + repository, + observationFactory, + dataRecorder, + MockStudyMoreScope(testScope), + testDispatchers + ) + } + + @AfterTest + fun tearDown() { + testScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun testStartSchedule() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + + val result = observationManager.start(scheduleId) + + assertTrue(result) + assertTrue(mockObservation.startCalled) + assertEquals(scheduleId, mockObservation.lastScheduleId) + advanceUntilIdle() + assertEquals(ScheduleState.RUNNING, repository.mockSchedule.lastSetRunningState?.second) + } + + @Test + fun testStartAlreadyRunning() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + + observationManager.start(scheduleId) + val result = observationManager.start(scheduleId) + + assertFalse(result) + } + + @Test + fun testPauseSchedule() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + observationManager.start(scheduleId) + + observationManager.pause(scheduleId) + + assertTrue(mockObservation.stopCalled) + advanceUntilIdle() + assertEquals(ScheduleState.PAUSED, repository.mockSchedule.lastSetRunningState?.second) + } + + @Test + fun testStopSchedule() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + observationManager.start(scheduleId) + + observationManager.stop(scheduleId) + + assertTrue(mockObservation.stopCalled) + advanceUntilIdle() + assertEquals(scheduleId, repository.mockSchedule.lastSetCompletionState?.first) + assertEquals(repository.mockSchedule.lastSetCompletionState?.second, true) + } + + @Test + fun testPauseObservationType() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + observationManager.start(scheduleId) + + observationManager.pauseObservationType(type) + + assertTrue(mockObservation.stopCalled) + advanceUntilIdle() + assertEquals(ScheduleState.PAUSED, repository.mockSchedule.lastSetRunningState?.second) + } + + @Test + fun testStartObservationType() = runTest(testDispatcher) { + val type = "test_type" + val scheduleId = "s1" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationType = type, + state = ScheduleState.ACTIVE.name + ) + ) + observationFactory.matchingObservationTypes = setOf(type) + + observationManager.startObservationType(type) + + assertEquals(setOf(scheduleId), dataRecorder.lastStartedMultipleScheduleIds) + } + + @Test + fun testRestartStillRunning() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type, + state = ScheduleState.RUNNING.name + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + + val started = observationManager.restartStillRunning() + + assertTrue(scheduleId in started) + assertTrue(mockObservation.startCalled) + } + + @Test + fun testStopAll() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + observationManager.start(scheduleId) + + observationManager.stopAll() + + assertTrue(mockObservation.stopAndFinishCalled) + advanceUntilIdle() + assertTrue(repository.mockSchedule.lastSetCompletionState?.second == true) + } + + @Test + fun testCollectAllData() = runTest(testDispatcher) { + val scheduleId = "s1" + val observationId = "o1" + val type = "test_type" + repository.mockSchedule.storeSchedule( + ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + observationType = type + ) + ) + repository.mockObservation.storeObservation( + ObservationEntity(observationId = observationId, observationType = type) + ) + val mockObservation = MockObservation(repository, ObservationType(type, emptySet())) + observationFactory.observations.add(mockObservation) + observationManager.start(scheduleId) + + observationManager.upToDateTimestamps = mapOf(type to 1000L) + + var completed = false + observationManager.collectAllData { + completed = it + } + + advanceUntilIdle() + assertTrue(completed) + assertTrue(mockObservation.storeCalled) + assertEquals(1000L, mockObservation.lastStoreStart) + } + + class MockDataRecorder : DataRecorder { + var lastStartedScheduleId: String? = null + var lastStartedMultipleScheduleIds: Set? = null + var lastPausedScheduleId: String? = null + var lastStoppedScheduleId: String? = null + var stopAllCalled = false + var restartAllCalled = false + + override fun start(scheduleId: String) { + lastStartedScheduleId = scheduleId + } + + override fun startMultiple(scheduleIds: Set) { + lastStartedMultipleScheduleIds = scheduleIds + } + + override fun pause(scheduleId: String) { + lastPausedScheduleId = scheduleId + } + + override fun stop(scheduleId: String) { + lastStoppedScheduleId = scheduleId + } + + override fun stopAll() { + stopAllCalled = true + } + + override fun restartAll() { + restartAllCalled = true + } + } + + class MockObservation(repos: MainRepository, observationType: ObservationType) : + Observation(repos, observationType) { + var startCalled = false + var lastScheduleId: String? = null + var stopCalled = false + var stopAndFinishCalled = false + var storeCalled = false + var lastStoreStart: Long = -1 + + override fun start(): Boolean = true + + override fun stop(onCompletion: () -> Unit) { + onCompletion() + } + + override fun applyObservationConfig(settings: Map) { + // + } + + override fun start( + observationId: String, + scheduleId: String, + notificationId: String? + ): Boolean { + startCalled = true + lastScheduleId = scheduleId + return true + } + + override fun stop(scheduleId: String, removeNotification: Boolean) { + stopCalled = true + } + + override fun stopAndFinish(scheduleId: String) { + stopAndFinishCalled = true + } + + override fun store(start: Long, end: Long, onCompletion: () -> Unit) { + storeCalled = true + lastStoreStart = start + onCompletion() + } + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt new file mode 100644 index 000000000..0e93e07a7 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt @@ -0,0 +1,253 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.observations.appUsage + +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockSharedStorageRepository +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.observations.appUsage.model.LogEvent +import io.redlink.more.services.store.PermissionRepositoryImpl +import io.redlink.more.services.store.PermissionType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AppUsageObservationTest { + + @Test + fun testInstantEvent() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + val event = LogEvent.URL_OPEN + observation.onEvent(event, "https://redlink.at") + + assertEquals(1, mockRepo.mockObservationData.addedData.size) + val storedData = mockRepo.mockObservationData.addedData.first() + assertTrue(storedData.dataValue.contains("url_open")) + // Check for presence of string without colons or dots that might be escaped + assertTrue(storedData.dataValue.contains("redlink")) + } + + @Test + fun testRangeEvent() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + observation.onEvent(LogEvent.VIEW_OPEN, "test_view") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + + observation.onEvent(LogEvent.VIEW_CLOSED, "test_view") + + assertEquals(1, mockRepo.mockObservationData.addedData.size) + val storedData = mockRepo.mockObservationData.addedData.first() + assertTrue(storedData.dataValue.contains("view_visibility")) + assertTrue(storedData.dataValue.contains("test_view")) + } + + @Test + fun testTrackingDeclined() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + observation.onEvent(LogEvent.URL_OPEN, "https://example.com") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + } + + @Test + fun testStoreWithoutApproval() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + // BUTTON_PRESS has storeWithoutApproval = true now + observation.onEvent(LogEvent.BUTTON_PRESS, "test_button") + observation.onEvent(LogEvent.APP_TRACKING_DECLINED, "") + + assertEquals(1, mockRepo.mockObservationData.addedData.size) + val storedData = mockRepo.mockObservationData.addedData.first() + assertTrue(!storedData.dataValue.contains("button_press")) + assertTrue(storedData.dataValue.contains("app_tracking_declined")) + } + + @Test + fun testSendAfterApproval() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + observation.start("1", "1") + + // VIEW_OPEN has storeWithoutApproval = false + observation.onEvent(LogEvent.VIEW_OPEN, "test_view") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + + // Accept tracking + observation.onEvent(LogEvent.APP_TRACKING_ACCEPTED, null) + + // Close view - now it should be stored because tracking is approved + observation.onEvent(LogEvent.VIEW_CLOSED, "test_view") + // It should be 2 now because APP_TRACKING_ACCEPTED is also stored! + // and it flushes the buffered VIEW_OPEN + assertEquals(2, mockRepo.mockObservationData.addedData.size) + assertTrue(mockRepo.mockObservationData.addedData.any { it.dataValue.contains("view_visibility") }) + assertTrue(mockRepo.mockObservationData.addedData.any { it.dataValue.contains("app_tracking_accepted") }) + } + + @Test + fun testPersistenceAcrossRestarts() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) + + // First session: Tracking is declined, we log an event + val observation1 = AppUsageObservation(mockRepo, permissionRepo) + observation1.setDataManager(mockObservationDataManager(mockRepo)) + observation1.start("1", "1") + + observation1.onEvent(LogEvent.URL_OPEN, "https://example.com/buffered") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + // Check if it's in shared storage + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("url_open")) + + // Restart session: Tracking is still declined, we log another event + val observation2 = AppUsageObservation(mockRepo, permissionRepo) + observation2.setDataManager(mockObservationDataManager(mockRepo)) + observation2.start("1", "1") + + observation2.onEvent(LogEvent.URL_OPEN, "https://example.com/buffered2") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("buffered")) + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("buffered2")) + + // Finally accept tracking + observation2.onEvent(LogEvent.APP_TRACKING_ACCEPTED, null) + + // Both buffered events + APP_TRACKING_ACCEPTED should be stored now + assertEquals(3, mockRepo.mockObservationData.addedData.size) + val dataValues = mockRepo.mockObservationData.addedData.map { it.dataValue } + assertTrue(dataValues.any { it.contains("buffered") }) + assertTrue(dataValues.any { it.contains("buffered2") }) + assertTrue(dataValues.any { it.contains("app_tracking_accepted") }) + + // Shared storage should be cleared + assertEquals("NOT_SET", mockSharedStorage.load("app_usage_data_buffer", "NOT_SET")) + } + + @Test + fun testBufferUntilStart() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + + // No start("1", "1") yet! + + observation.onEvent(LogEvent.URL_OPEN, "https://redlink.at") + + // Should be buffered even if tracking is approved + assertEquals(0, mockRepo.mockObservationData.addedData.size) + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("url_open")) + + // Now start + observation.start("1", "1") + + // Should be flushed + assertEquals(1, mockRepo.mockObservationData.addedData.size) + assertTrue(mockRepo.mockObservationData.addedData.first().dataValue.contains("url_open")) + } + + @Test + fun testStoreWithoutApprovalBufferedUntilStart() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, false) // Declined + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + + observation.onEvent(LogEvent.APP_TRACKING_ACCEPTED, "") + + assertEquals(0, mockRepo.mockObservationData.addedData.size) + assertTrue( + mockSharedStorage.load("app_usage_data_buffer", "").contains("app_tracking_accepted") + ) + + observation.start("1", "1") + + assertEquals(1, mockRepo.mockObservationData.addedData.size) + assertTrue(mockRepo.mockObservationData.addedData.first().dataValue.contains("app_tracking_accepted")) + } + + @Test + fun testOnStudyExitClearsBuffer() { + val mockRepo = MockMainRepository() + val mockSharedStorage = MockSharedStorageRepository() + val permissionRepo = PermissionRepositoryImpl(mockSharedStorage) + permissionRepo.updatePermission(PermissionType.APP_TRACKING, true) + + val observation = AppUsageObservation(mockRepo, permissionRepo) + observation.setDataManager(mockObservationDataManager(mockRepo)) + + // Buffer an event (by not starting observation) + observation.onEvent(LogEvent.URL_OPEN, "https://redlink.at") + assertTrue(mockSharedStorage.load("app_usage_data_buffer", "").contains("url_open")) + + // Start an observation range + observation.onEvent(LogEvent.VIEW_OPEN, "test_view") + + // Call onStudyExit + observation.onStudyExit() + + // Buffer should be cleared from shared storage + assertEquals("NOT_SET", mockSharedStorage.load("app_usage_data_buffer", "NOT_SET")) + + // Now start the observation - nothing should be flushed because it was cleared + observation.start("1", "1") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + + // Close the view - nothing should be stored because storage was cleared and not flushed + observation.onEvent(LogEvent.VIEW_CLOSED, "test_view") + assertEquals(0, mockRepo.mockObservationData.addedData.size) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/services/notification/NotificationManagerTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/services/notification/NotificationManagerTest.kt new file mode 100644 index 000000000..8ca16c970 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/services/notification/NotificationManagerTest.kt @@ -0,0 +1,266 @@ +package io.redlink.more.services.notification + +import io.redlink.more.database.entities.NotificationEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.mocks.InMemoryStorageRepository +import io.redlink.more.mocks.MockDeeplinkManager +import io.redlink.more.mocks.MockLocalNotificationListener +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockNetworkService +import io.redlink.more.mocks.MockNotificationActionObserver +import io.redlink.more.models.NotificationStatusType +import io.redlink.more.models.StudyState +import io.redlink.more.scopes.AppDispatchers +import io.redlink.more.scopes.MoreDispatchers +import io.redlink.more.services.network.openapi.model.PushNotification +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NotificationManagerTest { + + private lateinit var notificationManager: NotificationManager + private lateinit var repository: MockMainRepository + private lateinit var localNotificationListener: MockLocalNotificationListener + private lateinit var networkService: MockNetworkService + private lateinit var deeplinkManager: MockDeeplinkManager + private lateinit var sharedStorageRepository: InMemoryStorageRepository + private lateinit var actionObserver: MockNotificationActionObserver + + private lateinit var testDispatcher: TestDispatcher + + private val testDispatchers = object : MoreDispatchers { + override val default: CoroutineDispatcher get() = testDispatcher + override val main: CoroutineDispatcher get() = testDispatcher + override val io: CoroutineDispatcher get() = testDispatcher + } + + @BeforeTest + fun setup() { + testDispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(testDispatcher) + AppDispatchers.set(testDispatcher, testDispatcher, testDispatcher) + repository = MockMainRepository() + localNotificationListener = MockLocalNotificationListener() + sharedStorageRepository = InMemoryStorageRepository() + networkService = MockNetworkService() + deeplinkManager = MockDeeplinkManager(repository) + notificationManager = NotificationManager( + repository, + localNotificationListener, + networkService, + deeplinkManager, + sharedStorageRepository, + testDispatchers + ) + actionObserver = MockNotificationActionObserver() + notificationManager.setActionObserver(actionObserver) + } + + @AfterTest + fun tearDown() { + AppDispatchers.reset() + Dispatchers.resetMain() + } + + @Test + fun testStoreAndHandleNotification() = runTest(testDispatcher) { + val key = "test_key" + val title = "Test Title" + val body = "Test Body" + + notificationManager.storeAndHandleNotification( + key = key, + title = title, + body = body, + displayNotification = true + ) + + testDispatcher.scheduler.advanceUntilIdle() + val storedNotification = repository.notification.getNotification(key) + assertNotNull(storedNotification) + assertEquals(title, storedNotification.title) + assertEquals(body, storedNotification.notificationBody) + assertEquals(1, localNotificationListener.displayedNotifications.size) + assertEquals(key, localNotificationListener.displayedNotifications.first().notificationId) + } + + @Test + fun testMarkNotificationAsRead() = runTest(testDispatcher) { + val key = "test_key" + notificationManager.storeAndHandleNotification( + key, + "Title", + "Body", + displayNotification = false + ) + + notificationManager.markNotificationAsRead(key) + testDispatcher.scheduler.advanceUntilIdle() + + val updatedNotification = repository.notification.getNotification(key) + assertNotNull(updatedNotification) + assertTrue(updatedNotification.read) + } + + @Test + fun testMarkNotificationAsCompleted() = runTest(testDispatcher) { + val key = "test_key" + notificationManager.storeAndHandleNotification( + key, + "Title", + "Body", + displayNotification = false + ) + + notificationManager.markNotificationAsCompleted(key) + testDispatcher.scheduler.advanceUntilIdle() + + val updatedNotification = repository.notification.getNotification(key) + assertNotNull(updatedNotification) + assertTrue(updatedNotification.completed) + } + + @Test + fun testDeleteNotificationFromRepository() = runTest(testDispatcher) { + val key = "test_key" + notificationManager.storeAndHandleNotification( + key, + "Title", + "Body", + displayNotification = false + ) + + notificationManager.deleteNotificationFromRepository(key) + testDispatcher.scheduler.advanceUntilIdle() + + val deletedNotification = repository.notification.getNotification(key) + assertEquals(deletedNotification, null) + } + + @Test + fun testNewFCMToken() = runTest(testDispatcher) { + val token = "new_fcm_token" + notificationManager.newFCMToken(token) + testDispatcher.scheduler.advanceUntilIdle() + + val result = sharedStorageRepository.load(NotificationManager.FCM_TOKEN_UPLOADED, false) + + assertTrue { result } + } + + @Test + fun testScheduleObservationReminders() = runTest(testDispatcher) { + val schedules = listOf( + ScheduleEntity( + scheduleId = "s1", + observationTitle = "Obs 1", + start = 1000L, + reminder = true, + observationId = "o1" + ), + ScheduleEntity( + scheduleId = "s2", + observationTitle = "Obs 2", + start = 2000L, + reminder = true, + observationId = "o2" + ) + ) + + notificationManager.scheduleObservationReminders(schedules) +// delay(50) + testDispatcher.scheduler.advanceUntilIdle() + + val storedS1 = repository.notification.getNotification("reminder_s1") + val storedS2 = repository.notification.getNotification("reminder_s2") + + assertNotNull(storedS1) + assertNotNull(storedS2) + assertEquals(2, localNotificationListener.displayedNotifications.size) + } + + @Test + fun testClearScheduledNotifications() = runTest(testDispatcher) { + val n1 = NotificationEntity(notificationId = "s1", userFacing = false) + val n2 = NotificationEntity(notificationId = "s2", userFacing = false) + repository.notification.storeNotifications(listOf(n1, n2)) + + notificationManager.clearScheduledNotifications() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, localNotificationListener.clearScheduledCount) + } + + @Test + fun testDownloadMissedNotifications() = runTest(testDispatcher) { + val pushNotif = PushNotification( + msgId = "missed_1", + title = "Missed", + body = "Body", + type = PushNotification.Type.TEXT + ) + networkService.missedNotifications = listOf(pushNotif) + + notificationManager.downloadMissedNotifications() + testDispatcher.scheduler.advanceUntilIdle() + + val stored = repository.notification.getNotification("missed_1") + assertNotNull(stored) + assertEquals("Missed", stored.title) + } + + @Test + fun testCheckIfCompletedOrRead() = runTest(testDispatcher) { + val observationId = "obs1" + val deepLink = "app://more/test?observationId=$observationId" + val notification = + NotificationEntity(notificationId = "n1", deepLink = deepLink, read = true) + + repository.mockNotification.storeNotification(notification) + repository.mockObservation.storeObservation( + ObservationEntity( + observationId = observationId + ) + ) + repository.mockSchedule.storeSchedule( + ScheduleEntity( + observationId = observationId, + state = io.redlink.more.models.ScheduleState.ACTIVE.name + ) + ) + + val status = notificationManager.checkIfCompletedOrRead(deepLink).first() + assertEquals(NotificationStatusType.READ, status) + } + + @Test + fun testHandleNotificationDataAsync() = runTest(testDispatcher) { + val data = mapOf( + "key" to "STUDY_STATE_CHANGED", + "MSG_ID" to "msg123", + "oldState" to "active", + "newState" to "paused" + ) + notificationManager.handleNotificationDataAsync(data) + testDispatcher.scheduler.advanceUntilIdle() + assertTrue(actionObserver.updateStudyCalled) + assertEquals(StudyState.ACTIVE, actionObserver.lastOldStudyState) + assertEquals(StudyState.PAUSED, actionObserver.lastNewStudyState) + } + +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModelTest.kt new file mode 100644 index 000000000..f7731a1be --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModelTest.kt @@ -0,0 +1,87 @@ +package io.redlink.more.viewModels.login + +import io.redlink.more.Shared +import io.redlink.more.mocks.InMemoryStorageRepository +import io.redlink.more.mocks.MockBluetoothConnector +import io.redlink.more.mocks.MockDataRecorder +import io.redlink.more.mocks.MockLocalNotificationListener +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockObservationFactory +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.models.LoginModel +import io.redlink.more.registration.RegistrationService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreLoginViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var registrationService: MockRegistrationService + private lateinit var viewModel: CoreLoginViewModel + + class MockRegistrationService(shared: Shared) : RegistrationService(shared) { + var sendRegistrationTokenCalled = false + var clearErrorCalled = false + + override fun sendRegistrationToken(loginModel: LoginModel) { + sendRegistrationTokenCalled = true + } + + override fun clearError() { + clearErrorCalled = true + } + } + + private fun createMockShared(): Shared { + return object : Shared( + localNotificationListener = MockLocalNotificationListener(), + repositories = MockMainRepository(), + sharedStorageRepository = InMemoryStorageRepository(), + observationDataManager = mockObservationDataManager(), + mainBluetoothConnector = MockBluetoothConnector(), + observationFactory = MockObservationFactory(MockMainRepository()), + dataRecorder = MockDataRecorder(), + connectionStatusFlow = flowOf(true) + ) {} + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + registrationService = MockRegistrationService(createMockShared()) + viewModel = CoreLoginViewModel(registrationService) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testSendRegistrationTokenValid() { + val loginModel = LoginModel("token", "https://example.com") + viewModel.sendRegistrationToken(loginModel) + assertTrue(registrationService.sendRegistrationTokenCalled) + } + + @Test + fun testSendRegistrationTokenInvalid() { + val loginModel = LoginModel("", "invalid-url") + viewModel.sendRegistrationToken(loginModel) + assertTrue(!registrationService.sendRegistrationTokenCalled) + } + + @Test + fun testClearError() { + viewModel.clearError() + assertTrue(registrationService.clearErrorCalled) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModelTest.kt new file mode 100644 index 000000000..3a521eb7d --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModelTest.kt @@ -0,0 +1,108 @@ +package io.redlink.more.viewModels.notifications + +import io.redlink.more.Shared +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.mocks.InMemoryStorageRepository +import io.redlink.more.mocks.MockBluetoothConnector +import io.redlink.more.mocks.MockDataRecorder +import io.redlink.more.mocks.MockLocalNotificationListener +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockNetworkService +import io.redlink.more.mocks.MockNotificationManager +import io.redlink.more.mocks.MockObservationFactory +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.models.NotificationModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreNotificationViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockFilterViewModel: MockFilterViewModel + private lateinit var mockNotificationManager: MockNotificationManager + private lateinit var viewModel: CoreNotificationViewModel + + class MockFilterViewModel : CoreNotificationFilterViewModel() { + var applyFilterCalled = false + override fun applyFilter(notificationList: List): List { + applyFilterCalled = true + return notificationList + } + } + + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + val mockRepo = MockMainRepository() + + mockFilterViewModel = MockFilterViewModel() + + val mockShared = createMockShared(mockRepo) + mockNotificationManager = MockNotificationManager( + mockRepo, + MockLocalNotificationListener(), + MockNetworkService(), + mockShared.deeplinkManager, + mockShared.sharedStorageRepository + ) + + viewModel = CoreNotificationViewModel(mockFilterViewModel, mockNotificationManager) + } + + private fun createMockShared(repository: MainRepository): Shared { + return object : Shared( + localNotificationListener = MockLocalNotificationListener(), + repositories = repository, + sharedStorageRepository = InMemoryStorageRepository(), + observationDataManager = mockObservationDataManager(), + mainBluetoothConnector = MockBluetoothConnector(), + observationFactory = MockObservationFactory(repository), + dataRecorder = MockDataRecorder(), + connectionStatusFlow = flowOf(true) + ) { + // Overriding localNotificationListener if it was open, but it's not. + } + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialization() = runTest { + // Just verify it doesn't crash on init + assertTrue(viewModel.notificationList.value.isEmpty()) + } + + @Test + fun testHandleNotificationAction() = runTest { + val notification = NotificationModel( + notificationId = "1", + channelId = null, + title = "Test 1", + notificationBody = "Body", + timestamp = 0L, + priority = 1L, + read = false, + completed = false, + userFacing = true, + deepLink = "link", + notificationData = emptyMap() + ) + + viewModel.handleNotificationAction(notification) { _, _ -> } + + assertTrue(mockNotificationManager.handleNotificationInteractionCalled) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModelTest.kt new file mode 100644 index 000000000..05f68fa6d --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModelTest.kt @@ -0,0 +1,87 @@ +package io.redlink.more.viewModels.observationDetails + +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.scopes.AppDispatchers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreObservationDetailsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepo: MockMainRepository + private lateinit var viewModel: CoreObservationDetailsViewModel + private val observationId = "obs1" + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + AppDispatchers.set(default = testDispatcher, main = testDispatcher, io = testDispatcher) + mockRepo = MockMainRepository() + viewModel = CoreObservationDetailsViewModel(mockRepo, observationId) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + AppDispatchers.reset() + } + + @Test + fun testViewDidAppearLoadsData() = runTest { + val observation = ObservationEntity( + observationId = observationId, + observationTitle = "Test Observation", + observationType = "type1", + participantInfo = "Info" + ) + val startSchedule = ScheduleEntity(scheduleId = "s1", start = 1000L, end = 2000L) + val endSchedule = ScheduleEntity(scheduleId = "s2", start = 3000L, end = 4000L) + + mockRepo.mockObservation.storeObservation(observation) + mockRepo.mockSchedule.firstAndLastDateResult = flowOf(startSchedule to endSchedule) + + viewModel.viewDidAppear() + runCurrent() + + val model = viewModel.observationDetailsModel.value + assertNotNull(model, "ObservationDetailsModel should not be null after viewDidAppear") + assertEquals("Test Observation", model.observationTitle) + assertEquals(1000L, model.start) + assertEquals(4000L, model.end) + } + + @Test + fun testViewDidDisappearClearsData() = runTest { + val observation = ObservationEntity( + observationId = observationId, + observationTitle = "Test Observation", + observationType = "type1", + participantInfo = "Info" + ) + mockRepo.mockObservation.storeObservation(observation) + mockRepo.mockSchedule.firstAndLastDateResult = flowOf(null to null) + + viewModel.viewDidAppear() + runCurrent() + assertNotNull(viewModel.observationDetailsModel.value) + + viewModel.viewDidDisappear() + advanceUntilIdle() + assertNull(viewModel.observationDetailsModel.value) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModelTest.kt new file mode 100644 index 000000000..71809bb91 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModelTest.kt @@ -0,0 +1,89 @@ +package io.redlink.more.viewModels.permission + +import io.redlink.more.Shared +import io.redlink.more.mocks.InMemoryStorageRepository +import io.redlink.more.mocks.MockBluetoothConnector +import io.redlink.more.mocks.MockDataRecorder +import io.redlink.more.mocks.MockLocalNotificationListener +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockObservationFactory +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.registration.RegistrationService +import io.redlink.more.services.network.openapi.model.Study +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.datetime.LocalDate +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreConsentViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var registrationService: RegistrationService + private lateinit var viewModel: CoreConsentViewModel + private val studyConsentTitle = "Study Consent" + + private fun createMockShared(): Shared { + val mockRepo = MockMainRepository() + return object : Shared( + localNotificationListener = MockLocalNotificationListener(), + repositories = mockRepo, + sharedStorageRepository = InMemoryStorageRepository(), + observationDataManager = mockObservationDataManager(mockRepo), + mainBluetoothConnector = MockBluetoothConnector(), + observationFactory = MockObservationFactory(mockRepo), + dataRecorder = MockDataRecorder(), + connectionStatusFlow = flowOf(true) + ) {} + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + registrationService = RegistrationService(createMockShared()) + viewModel = CoreConsentViewModel(registrationService, studyConsentTitle) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testPermissionsUpdateWhenStudyChanges() = runTest { + val study = Study( + studyTitle = "Test Study", + participantInfo = "Participant Info", + consentInfo = "Consent Info", + start = LocalDate(2023, 1, 1), + end = LocalDate(2023, 12, 31), + observations = emptyList(), + version = 1L + ) + + val registrationServiceStudy = registrationService.study as MutableStateFlow + + assertNull(viewModel.permissions.value) + + registrationServiceStudy.value = study + runCurrent() + + val permissions = viewModel.permissions.value + assertNotNull(permissions) + assertEquals("Test Study", permissions.studyTitle) + assertEquals("Consent Info", permissions.studyConsentInfo) + assertEquals(1, permissions.consentInfo.size) + assertEquals(studyConsentTitle, permissions.consentInfo[0].title) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModelTest.kt new file mode 100644 index 000000000..2ed95d817 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModelTest.kt @@ -0,0 +1,78 @@ +package io.redlink.more.viewModels.schedules + +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.mocks.MockDataRecorder +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.models.ScheduleListType +import io.redlink.more.models.ScheduleModel +import io.redlink.more.viewModels.dashboard.CoreDashboardFilterViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreScheduleViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepo: MockMainRepository + private lateinit var mockDataRecorder: MockDataRecorder + private lateinit var mockFilterViewModel: MockFilterViewModel + private lateinit var viewModel: CoreScheduleViewModel + + class MockFilterViewModel(repository: MainRepository) : + CoreDashboardFilterViewModel(repository) { + var applyFilterCalled = false + override fun applyFilter(scheduleModelList: Collection): Collection { + applyFilterCalled = true + return scheduleModelList + } + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockRepo = MockMainRepository() + + mockDataRecorder = MockDataRecorder() + mockFilterViewModel = MockFilterViewModel(mockRepo) + + viewModel = CoreScheduleViewModel( + mockRepo, + mockDataRecorder, + ScheduleListType.ALL, + mockFilterViewModel + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testStartSchedule() = runTest { + val scheduleId = "s1" + viewModel.start(scheduleId) + assertTrue(mockDataRecorder.startCalled) + } + + @Test + fun testPauseSchedule() = runTest { + val scheduleId = "s1" + viewModel.pause(scheduleId) + assertTrue(mockDataRecorder.pauseCalled) + } + + @Test + fun testStopSchedule() = runTest { + val scheduleId = "s1" + viewModel.stop(scheduleId) + assertTrue(mockDataRecorder.stopCalled) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModelTest.kt new file mode 100644 index 000000000..7536cff45 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModelTest.kt @@ -0,0 +1,94 @@ +package io.redlink.more.viewModels.studydetails + +import io.redlink.more.Shared +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.mocks.InMemoryStorageRepository +import io.redlink.more.mocks.MockBluetoothConnector +import io.redlink.more.mocks.MockDataRecorder +import io.redlink.more.mocks.MockLocalNotificationListener +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockObservationFactory +import io.redlink.more.mocks.mockObservationDataManager +import io.redlink.more.scopes.AppDispatchers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreStudyDetailsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepo: MockMainRepository + private lateinit var viewModel: CoreStudyDetailsViewModel + private lateinit var shared: Shared + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + AppDispatchers.set(default = testDispatcher, main = testDispatcher, io = testDispatcher) + mockRepo = MockMainRepository() + shared = object : Shared( + localNotificationListener = MockLocalNotificationListener(), + repositories = mockRepo, + sharedStorageRepository = InMemoryStorageRepository(), + observationDataManager = mockObservationDataManager(mockRepo), + mainBluetoothConnector = MockBluetoothConnector(), + observationFactory = MockObservationFactory(mockRepo), + dataRecorder = MockDataRecorder(), + connectionStatusFlow = flowOf(true) + ) {} + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + AppDispatchers.reset() + } + + @Test + fun testStudyDetailsLoading() = runTest { + val study = StudyEntity( + studyTitle = "Study Title", + consentInfo = "Consent Info", + participantInfo = "Participant Info" + ) + val observation = ObservationEntity( + observationId = "obs1", + observationTitle = "Observation Title", + observationType = "type1" + ) + val schedule = ScheduleEntity( + scheduleId = "s1", + observationId = "obs1", + done = true, + start = 1000L, + end = 2000L + ) + + (mockRepo.mockStudy as io.redlink.more.mocks.MockStudyRepository).upsert(study) + mockRepo.mockObservation.storeObservation(observation) + mockRepo.mockSchedule.storeSchedule(schedule) + + viewModel = CoreStudyDetailsViewModel(shared) + runCurrent() + + val model = viewModel.studyModel.value + assertNotNull(model, "StudyModel should not be null") + assertEquals("Study Title", model.study.studyTitle) + assertEquals(1, model.observations.size) + assertEquals("Observation Title", model.observations[0].observationTitle) + assertEquals(1, model.totalTasks) + assertEquals(1, model.finishedTasks) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModelTest.kt new file mode 100644 index 000000000..d235319f5 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModelTest.kt @@ -0,0 +1,83 @@ +package io.redlink.more.viewModels.taskCompletionBar + +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.mocks.MockScheduleRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreTaskCompletionBarViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepo: MockMainRepository + private lateinit var mockScheduleRepo: MockScheduleRepository + private lateinit var viewModel: CoreTaskCompletionBarViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockRepo = MockMainRepository() + mockScheduleRepo = mockRepo.schedule as MockScheduleRepository + } + + private fun createViewModel() { + viewModel = CoreTaskCompletionBarViewModel(mockRepo, testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialState() = runTest { + createViewModel() + advanceUntilIdle() + val taskCompletion = viewModel.taskCompletion.value + assertEquals(0, taskCompletion.finishedTasks) + assertEquals(0, taskCompletion.totalTasks) + } + + @Test + fun testTaskCompletionUpdates() = runTest { + val s1 = ScheduleEntity(scheduleId = "1", done = false) + val s2 = ScheduleEntity(scheduleId = "2", done = true) + val s3 = ScheduleEntity(scheduleId = "3", done = true) + + mockScheduleRepo.storeSchedule(s1) + mockScheduleRepo.storeSchedule(s2) + mockScheduleRepo.storeSchedule(s3) + + createViewModel() + advanceUntilIdle() + + val taskCompletion = viewModel.taskCompletion.value + assertEquals(2, taskCompletion.finishedTasks) + assertEquals(3, taskCompletion.totalTasks) + } + + @Test + fun testTaskCompletionChanges() = runTest { + val s1 = ScheduleEntity(scheduleId = "1", done = false) + mockScheduleRepo.storeSchedule(s1) + + createViewModel() + advanceUntilIdle() + assertEquals(1, viewModel.taskCompletion.value.totalTasks) + assertEquals(0, viewModel.taskCompletion.value.finishedTasks) + + mockScheduleRepo.setCompletionStateFor("1", true) + advanceUntilIdle() + assertEquals(1, viewModel.taskCompletion.value.totalTasks) + assertEquals(1, viewModel.taskCompletion.value.finishedTasks) + } +} diff --git a/shared/src/commonTest/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModelTest.kt b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModelTest.kt new file mode 100644 index 000000000..f26cbc9a7 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModelTest.kt @@ -0,0 +1,135 @@ +package io.redlink.more.viewModels.tasks + +import io.redlink.more.database.entities.DataPointEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity +import io.redlink.more.mocks.MockDataPointCountRepository +import io.redlink.more.mocks.MockDataRecorder +import io.redlink.more.mocks.MockMainRepository +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationStates +import io.redlink.more.scopes.AppDispatchers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoreTaskDetailsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepo: MockMainRepository + private lateinit var mockDataRecorder: MockDataRecorder + private lateinit var viewModel: CoreTaskDetailsViewModel + private val scheduleId = "s1" + private val observationId = "obs1" + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + AppDispatchers.set(default = testDispatcher, main = testDispatcher, io = testDispatcher) + mockRepo = MockMainRepository() + mockDataRecorder = MockDataRecorder() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + AppDispatchers.reset() + ObservationStates.resetAll() + } + + @Test + fun testInitializationLoadsTaskDetails() = runTest { + val observation = ObservationEntity( + observationId = observationId, + observationTitle = "Task Title", + observationType = "TaskType", + participantInfo = "Participant Information" + ) + val schedule = ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + start = 1000L, + end = 2000L + ) + + mockRepo.mockObservation.storeObservation(observation) + mockRepo.mockSchedule.storeSchedule(schedule) + + viewModel = CoreTaskDetailsViewModel(mockRepo, mockDataRecorder, scheduleId) + runCurrent() + + val details = viewModel.taskDetailsModel.value + assertNotNull(details, "TaskDetailsModel should not be null") + assertEquals("Task Title", details.observationTitle) + assertEquals("TaskType", details.observationType) + } + + @Test + fun testDataCountUpdates() = runTest { + val dataPoint = DataPointEntity(scheduleId = scheduleId, count = 42L) + (mockRepo.mockDataPointCount as MockDataPointCountRepository).dataPointResults[scheduleId] = + flowOf(dataPoint) + + viewModel = CoreTaskDetailsViewModel(mockRepo, mockDataRecorder, scheduleId) + runCurrent() + + assertEquals(42L, viewModel.dataCount.value) + } + + @Test + fun testObservationErrorsHandling() = runTest { + val observation = ObservationEntity( + observationId = observationId, + observationType = "TaskType" + ) + val schedule = ScheduleEntity( + scheduleId = scheduleId, + observationId = observationId, + start = 1000L, + end = 2000L + ) + mockRepo.mockObservation.storeObservation(observation) + mockRepo.mockSchedule.storeSchedule(schedule) + + ObservationStates.updateObservationErrors( + "TaskType", + setOf(Observation.ERROR_DEVICE_NOT_CONNECTED, "Other Error") + ) + + viewModel = CoreTaskDetailsViewModel(mockRepo, mockDataRecorder, scheduleId) + runCurrent() + + assertEquals(1, viewModel.taskObservationErrorActions.value.size) + assertEquals( + Observation.ERROR_DEVICE_NOT_CONNECTED, + viewModel.taskObservationErrorActions.value[0] + ) + assertEquals(1, viewModel.taskObservationErrors.value.size) + assertEquals("Other Error", viewModel.taskObservationErrors.value[0]) + } + + @Test + fun testObservationActions() = runTest { + viewModel = CoreTaskDetailsViewModel(mockRepo, mockDataRecorder, scheduleId) + runCurrent() + viewModel.startObservation() + assertTrue(mockDataRecorder.startCalled) + + viewModel.pauseObservation() + assertTrue(mockDataRecorder.pauseCalled) + + viewModel.stopObservation() + assertTrue(mockDataRecorder.stopCalled) + } +} diff --git a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt b/shared/src/iosMain/kotlin/io/redlink/more/Platform.kt similarity index 93% rename from shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt rename to shared/src/iosMain/kotlin/io/redlink/more/Platform.kt index 9460afb19..2baf7b223 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/Platform.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform +package io.redlink.more import platform.UIKit.UIDevice diff --git a/shared/src/iosMain/kotlin/io/redlink/more/database/DatabaseManager.ios.kt b/shared/src/iosMain/kotlin/io/redlink/more/database/DatabaseManager.ios.kt new file mode 100644 index 000000000..c72e72f4a --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/database/DatabaseManager.ios.kt @@ -0,0 +1,25 @@ +package io.redlink.more.database + +import androidx.room.Room +import androidx.room.RoomDatabase +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask + +fun getDatabaseBuilder(): RoomDatabase.Builder { + val dbFilePath = documentDirectory() + "/more_app.db" + return Room.databaseBuilder(name = dbFilePath) +} + +@OptIn(ExperimentalForeignApi::class) +private fun documentDirectory(): String { + val documentDirectory = NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) + return requireNotNull(documentDirectory?.path) +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/toString.kt b/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt similarity index 70% rename from shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/toString.kt rename to shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt index 2f659bd74..f96815906 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/toString.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt @@ -8,17 +8,20 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.extensions +package io.redlink.more.extensions -import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.* +import platform.Foundation.NSJSONSerialization +import platform.Foundation.NSJSONWritingPrettyPrinted +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create @OptIn(ExperimentalForeignApi::class) actual fun Any.asString(): String? { return try { NSJSONSerialization.dataWithJSONObject(this, NSJSONWritingPrettyPrinted, null)?.let { - return NSString.create(it, NSUTF8StringEncoding) as String? + return NSString.create(it, NSUTF8StringEncoding) as? String } } catch (e: Exception) { println(e) diff --git a/shared/src/iosMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt b/shared/src/iosMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt new file mode 100644 index 000000000..2597bd59e --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt @@ -0,0 +1,13 @@ +package io.redlink.more.models + +import dev.icerock.moko.resources.desc.StringDesc + +actual object NotificationTextLocalization { + actual fun localize(raw: String, fallback: String?): String { + return localizeToStringDesc(raw)?.localized() ?: fallback ?: raw + } + + actual fun localizeToStringDesc(raw: String): StringDesc? { + return NotificationTextKey.fromRaw(raw)?.asStringDesc() + } +} diff --git a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt b/shared/src/iosMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt similarity index 58% rename from shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt rename to shared/src/iosMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt index 48e00bf49..f13ab59a3 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt @@ -8,47 +8,41 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.network +package io.redlink.more.services.network import io.ktor.client.HttpClient import io.ktor.client.engine.darwin.Darwin import io.ktor.client.plugins.auth.Auth -import io.ktor.client.plugins.auth.providers.basic -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.retry -import io.ktor.client.request.request import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json actual fun getHttpClient(customLogger: Logger): HttpClient = HttpClient(Darwin) { - install(ContentNegotiation) { - json() - defaultRequest { - contentType(ContentType.Application.Json) - } - request { - contentType(ContentType.Application.Json) - retry { - maxRetries = 3 - } - } - Logging { - logger = customLogger - level = LogLevel.ALL - } - Auth { - basic { - } - } + + defaultRequest { + contentType(ContentType.Application.Json) + headers.append("Accept", "application/json") } + + install(Logging) { + logger = customLogger + level = LogLevel.INFO + sanitizeHeader { header -> header == HttpHeaders.Authorization } + } + + install(Auth) + engine { configureRequest { setAllowsCellularAccess(true) } + configureSession { + timeoutIntervalForRequest = 30.0 + timeoutIntervalForResource = 60.0 + } } } \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/UserDefaultsRepository.kt b/shared/src/iosMain/kotlin/io/redlink/more/services/store/UserDefaultsRepository.kt similarity index 97% rename from shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/UserDefaultsRepository.kt rename to shared/src/iosMain/kotlin/io/redlink/more/services/store/UserDefaultsRepository.kt index a750b52f3..76d9fff60 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/UserDefaultsRepository.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/services/store/UserDefaultsRepository.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.services.store import platform.Foundation.NSUserDefaults diff --git a/shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt b/shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt new file mode 100644 index 000000000..69f7faea0 --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt @@ -0,0 +1,28 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Apache 2.0 license with Commons Clause + * (see https://www.apache.org/licenses/LICENSE-2.0 and + * https://commonsclause.com/). + */ + +package io.redlink.more.util + +import io.github.aakira.napier.Napier +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString + +actual fun openSystemSettings() { + Napier.d { "Opening System Settings..." } + NSURL.URLWithString(UIApplicationOpenSettingsURLString)?.let { + UIApplication.sharedApplication.openURL( + it, + options = emptyMap(), + completionHandler = null + ) + } +} diff --git a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt b/shared/src/iosMain/kotlin/io/redlink/more/util/UUID.kt similarity index 91% rename from shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt rename to shared/src/iosMain/kotlin/io/redlink/more/util/UUID.kt index 251e46f54..30ebe1bef 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/util/UUID.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/util/UUID.kt @@ -8,7 +8,7 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.util +package io.redlink.more.util import platform.Foundation.NSUUID