From a5a8ed4ce1f536793049a1c83ad329d0911dec65 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Mon, 2 Dec 2024 10:47:43 +0100 Subject: [PATCH 01/59] Set the Android Backbutton to return to the dashboard on successful question response and cleared the back stack --- .../questionnaire/QuestionnaireResponseView.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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..ae878e08e 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 @@ -34,11 +35,15 @@ 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 - @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) From ef5bb16cd6282a8350d067b000f6e7992f097c17 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Mon, 2 Dec 2024 11:21:07 +0100 Subject: [PATCH 02/59] #269: Fixed the clickable text in the notification to apply the read state if no url is present --- .../activities/notification/NotificationView.kt | 14 ++++++++------ .../notification/composables/NotificationItem.kt | 14 +++++++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) 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..d1113655f 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 @@ -12,10 +12,12 @@ package io.redlink.more.app.android.activities.notification import androidx.compose.foundation.clickable 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 @@ -36,7 +38,6 @@ import io.redlink.more.app.android.activities.notification.composables.Notificat import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.MoreDivider - @Composable fun NotificationView(navController: NavController, viewModel: NotificationViewModel) { val backStackEntry = remember { navController.currentBackStackEntry } @@ -76,8 +77,9 @@ fun NotificationView(navController: NavController, viewModel: NotificationViewMo } items(viewModel.notificationList.sortedByDescending { it.timestamp }) { notification -> - Column( + Box( modifier = Modifier + .fillMaxWidth() .clickable { if (!notification.read) { viewModel.handleNotificationAction(notification, navController) @@ -85,10 +87,10 @@ fun NotificationView(navController: NavController, viewModel: NotificationViewMo } .padding(bottom = 10.dp) ) { - NotificationItem( - notification - ) - MoreDivider() + Column { + NotificationItem(viewModel, notification, navController) + MoreDivider() + } } } } 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..103b8001e 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,7 +36,9 @@ 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 @@ -48,7 +50,9 @@ import io.redlink.more.more_app_mutliplatform.models.NotificationModel @Composable fun NotificationItem( - notificationModel: NotificationModel + viewModel: NotificationViewModel, + notificationModel: NotificationModel, + navController: NavController ) { val context = LocalContext.current Column { @@ -121,7 +125,11 @@ 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) } + } } ) @@ -136,7 +144,7 @@ fun NotificationItem( } if (notificationModel.deepLink != null) { Icon( - if (notificationModel.read) Icons.Default.Done else Icons.Default.ArrowForwardIos, + if (notificationModel.read) 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 ) From d11589eae2a5ddabe799804b8957645623b2b8de Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Mon, 2 Dec 2024 11:44:01 +0100 Subject: [PATCH 03/59] #277: Fixed a bug where the body of a notification would be remembered and placed in the wrong notification --- .../activities/notification/composables/NotificationItem.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..1a1d9f9de 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 @@ -102,7 +102,7 @@ fun NotificationItem( .fillMaxWidth() .defaultMinSize(minHeight = 50.dp) ) { - val annotatedNotificationModelBody = remember { + val annotatedNotificationModelBody = remember(notificationModel.notificationBody) { notificationModel.notificationBody.trim().toAnnotatedString() } Column( @@ -136,7 +136,7 @@ fun NotificationItem( } if (notificationModel.deepLink != null) { Icon( - if (notificationModel.read) Icons.Default.Done else Icons.Default.ArrowForwardIos, + if (notificationModel.read) 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 ) From 7f0bb1182c15d626c165e131d2441a681aa65ee3 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 3 Dec 2024 07:45:36 +0100 Subject: [PATCH 04/59] Updated to version 4.0.19 --- androidApp/build.gradle.kts | 4 ++-- iosApp/iosApp.xcodeproj/project.pbxproj | 4 ++-- iosApp/iosApp/Info.plist | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index b4f1ad76a..8e182e6d4 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 34 - versionCode = 18 - versionName = "4.0.18" + versionCode = 19 + versionName = "4.0.19" } buildFeatures { compose = true diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index e2744f023..8a5ede174 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -1798,7 +1798,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.14; + MARKETING_VERSION = 4.0.19; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1838,7 +1838,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.14; + MARKETING_VERSION = 4.0.19; OTHER_LDFLAGS = ( "$(inherited)", "-framework", diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 4a0878f00..c722ac8b2 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.14 + 4.0.19 CFBundleURLTypes @@ -36,7 +36,7 @@ CFBundleVersion - 4.0.14 + 4.0.19 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption From 0bd84286f40c221d51e93a0824e576c989e3fa3b Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Wed, 8 Jan 2025 12:54:10 +0100 Subject: [PATCH 05/59] Added a new state which stops the UI until the study has fully updated --- .../app/android/activities/ContentActivity.kt | 10 +- .../android/activities/ContentViewModel.kt | 93 ++++++++++--------- .../more/more_app_mutliplatform/Shared.kt | 2 + .../services/network/NetworkService.kt | 6 +- .../viewModels/ViewManager.kt | 8 +- 5 files changed, 64 insertions(+), 55 deletions(-) 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..91089cb5b 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 @@ -19,6 +19,7 @@ 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.StudyUpdateView 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 @@ -54,10 +55,11 @@ class ContentActivity : ComponentActivity() { @Composable fun ContentView(viewModel: ContentViewModel) { - if (viewModel.hasCredentials.value) { - viewModel.openMainActivity(LocalContext.current) - } else { - MoreBackground(showBackButton = false, alertDialogModel = viewModel.alertDialogOpen.value) { + MoreBackground(showBackButton = false, alertDialogModel = viewModel.alertDialogOpen.value) { + if (viewModel.hasCredentials.value) { + viewModel.openMainActivity(LocalContext.current) + StudyUpdateView() + } else { if (viewModel.loginViewScreenNr.intValue == 0) { LoginView(model = viewModel.loginViewModel) AppVersion() 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..2d120217e 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 @@ -20,6 +20,7 @@ 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 @@ -36,8 +37,9 @@ 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.more_app_mutliplatform.viewModels.ViewManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -71,56 +73,57 @@ 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 - ) + (context as? Activity)?.let { activity -> + schedulePeriodicWorker(activity) + handleDeepLinkAndOpenMain(activity) + } + } + + 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 + ) + } - (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) - } - - } ?: 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) - } + private fun handleDeepLinkAndOpenMain(activity: Activity) { + val rawDeepLink = + activity.intent.getStringExtra("deepLink") ?: activity.intent.data?.toString() + val notificationId = activity.intent.getStringExtra(NotificationManager.MSG_ID) + + viewModelScope.launch(Dispatchers.IO) { + ViewManager.checkingForNewStudyData.first { !it } + val modifiedDeepLink = rawDeepLink?.let { link -> + MoreApplication.shared!!.deeplinkManager + .modifyDeepLink(link, stringResource(R.string.app_scheme), applicationId) + .firstOrNull() + } + + val finalUri = when { + modifiedDeepLink != null -> { + notificationId?.let { + MoreApplication.shared!!.notificationManager.handleNotificationInteraction( + it, modifiedDeepLink + ) } + Uri.parse(modifiedDeepLink) } - } ?: 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 + + notificationId != null -> { + Uri.parse(ContentActivity.DEEPLINK + NavigationScreen.NOTIFICATIONS.routeWithParameters()) } - openMain(it) + + else -> null } + Napier.d { finalUri.toString() } + withContext(Dispatchers.Main) { + activity.intent.data = finalUri + openMain(activity) + } } } 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 index 3c740bf49..da2eca192 100644 --- 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 @@ -158,7 +158,9 @@ class Shared( newStudyState: StudyState? = null ) { Scope.launch(Dispatchers.IO) { + ViewManager.checkingForUpdate(true) updateStudy(oldStudyState, newStudyState) + ViewManager.checkingForUpdate(false) } } 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 index 408665bc2..d1e55628b 100644 --- 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 @@ -267,7 +267,7 @@ class NetworkService( dataApiResponse.body().let { Napier.i(tag = "NetworkService::sendData") { "Sent data!" } dataApiResponse.response.cancel() - return Pair(it.toSet() ?: emptySet(), null) + return Pair(it.toSet(), null) } } dataApiResponse.response.cancel() @@ -351,9 +351,7 @@ class NetworkService( } private fun getException(exception: Exception): NetworkServiceError { - val errorResponse = when (exception) { - else -> "System error!" - } + val errorResponse = "System error!" Napier.e("Exception: ${exception.stackTraceToString()}", tag = TAG) exception.printStackTrace() return NetworkServiceError(null, errorResponse) 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 index 6cbfc6efb..485a2ea3f 100644 --- 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 @@ -9,17 +9,21 @@ import kotlinx.coroutines.flow.update object ViewManager { private val _studyIsUpdating = MutableStateFlow(false) private val _showBluetoothView = MutableStateFlow(false) + private val _checkingForNewStudyData = MutableStateFlow(false) val studyIsUpdating: StateFlow = _studyIsUpdating - + val checkingForNewStudyData: StateFlow = _checkingForNewStudyData val showBluetoothView: StateFlow = _showBluetoothView private var bleViewOpen = false - fun studyIsUpdating(state: Boolean) { _studyIsUpdating.set(state) } + fun checkingForUpdate(state: Boolean) { + _checkingForNewStudyData.set(state) + } + fun showBLEView(state: Boolean): Boolean { if (!state || !bleViewOpen) { _showBluetoothView.update { state } From 29375c8519bd7a0e515de9dfecb4cc684fcc0ab7 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 9 Jan 2025 10:13:19 +0100 Subject: [PATCH 06/59] Added the new state for iOS and fixed the issue on iOS as well --- iosApp/iosApp/AppDelegate.swift | 3 -- iosApp/iosApp/Services/FCMService.swift | 16 +++--- .../iosApp/Views/NavigationModalState.swift | 54 +++++++++++-------- iosApp/iosApp/iOSApp.swift | 4 +- .../more/more_app_mutliplatform/Shared.kt | 8 +-- .../viewModels/ViewManager.kt | 3 ++ 6 files changed, 49 insertions(+), 39 deletions(-) diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index db72fd24a..7c1b0846f 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -58,9 +58,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { return true } - - func applicationWillTerminate(_ application: UIApplication) { - } func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { print("Notification Received: \(userInfo)") diff --git a/iosApp/iosApp/Services/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift index 1614aa352..9c251eff3 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -7,17 +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 FirebaseMessaging import Foundation -import shared import Realm +import shared import UserNotifications -import FirebaseMessaging class FCMService: NSObject { func register() { @@ -36,7 +36,6 @@ extension FCMService: MessagingDelegate { } extension FCMService: UNUserNotificationCenterDelegate { - @MainActor func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let content = notification.request.content @@ -44,11 +43,12 @@ extension FCMService: UNUserNotificationCenterDelegate { 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) } - return [.sound,.badge, .banner] + return [.sound, .badge, .banner] } - + @MainActor func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + AppDelegate.shared.appInForeground(boolean: true) let data = response.notification.request.content.userInfo.notNilStringDictionary() let msgId = data[NotificationManager.companion.MSG_ID] ?? response.notification.request.identifier if let deepLinkString = data[NotificationManager.companion.DEEP_LINK] { @@ -66,7 +66,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 diff --git a/iosApp/iosApp/Views/NavigationModalState.swift b/iosApp/iosApp/Views/NavigationModalState.swift index d1e3028fb..df203b951 100644 --- a/iosApp/iosApp/Views/NavigationModalState.swift +++ b/iosApp/iosApp/Views/NavigationModalState.swift @@ -50,6 +50,8 @@ class NavigationModalState: ObservableObject { } } } + + private var studyUpdatingClosable: Ktor_ioCloseable? func screenBinding(for screen: NavigationScreen) -> Binding { Binding( @@ -184,33 +186,39 @@ class NavigationModalState: ObservableObject { } 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 + self.studyUpdatingClosable = ViewManager.shared.checkingForNewStudyDataAsClosure { isUpdating in + guard !isUpdating.boolValue else { return } + 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 + } } + + let observationId = parameters[.observationId] + let notificationId = parameters[.notificaitonId] ?? notificationId + let scheduleId = parameters[.scheduleId] + + if let notificationId { + AppDelegate.shared.notificationManager.handleNotificationInteraction(notificationId: notificationId, deeplink: modifiedDeepLink) + } + + self.openView(screen: matchingScreen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) } - - let observationId = parameters[.observationId] - let notificationId = parameters[.notificaitonId] ?? notificationId - let scheduleId = parameters[.scheduleId] - - if let notificationId { - AppDelegate.shared.notificationManager.handleNotificationInteraction(notificationId: notificationId, deeplink: modifiedDeepLink) - } - - self.openView(screen: matchingScreen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) + } else if modifiedDeepLink == nil, let notificationId { + AppDelegate.shared.notificationManager.markNotificationAsRead(notificationId: notificationId) } - } else if modifiedDeepLink == nil, let notificationId { - AppDelegate.shared.notificationManager.markNotificationAsRead(notificationId: notificationId) + self.studyUpdatingClosable?.close() + self.studyUpdatingClosable = nil } } + } } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 4812da88c..2cfb75bb9 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -27,15 +27,15 @@ struct iOSApp: App { .onChange(of: scenePhase) { newPhase in switch newPhase { case .background: + AppDelegate.shared.appInForeground(boolean: false) if AppDelegate.shared.observationManager.hasRunningTasks() { appDelegate.scheduleTasks() } - AppDelegate.shared.appInForeground(boolean: false) case .inactive: break case .active: - appDelegate.cancelBackgroundTasks() AppDelegate.shared.appInForeground(boolean: true) + appDelegate.cancelBackgroundTasks() break default: break 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 index da2eca192..9ef308946 100644 --- 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 @@ -39,7 +39,6 @@ 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 @@ -93,10 +92,12 @@ class Shared( } fun appInForeground(boolean: Boolean) { + if (appIsInForeGround == boolean) { + return + } Napier.i { "App is in foreground: $boolean" } appIsInForeGround = boolean if (appIsInForeGround) { - notificationManager.clearAllNotifications() if (credentialRepository.hasCredentials()) { updateStudyBlocking() notificationManager.createNewFCMIfNecessary() @@ -109,6 +110,7 @@ class Shared( observationFactory.updateObservationErrors() updateTaskStates() } + notificationManager.clearAllNotifications() } else { ViewManager.showBLEView(false) } @@ -157,7 +159,7 @@ class Shared( oldStudyState: StudyState? = null, newStudyState: StudyState? = null ) { - Scope.launch(Dispatchers.IO) { + Scope.launch(Dispatchers.Default) { ViewManager.checkingForUpdate(true) updateStudy(oldStudyState, newStudyState) ViewManager.checkingForUpdate(false) 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 index 485a2ea3f..e95de2ea0 100644 --- 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 @@ -40,6 +40,9 @@ object ViewManager { fun studyIsUpdatingAsClosure(state: (Boolean) -> Unit) = studyIsUpdating.asClosure(state) + fun checkingForNewStudyDataAsClosure(state: (Boolean) -> Unit) = + checkingForNewStudyData.asClosure(state) + fun resetAll() { _studyIsUpdating.set(false) _showBluetoothView.set(false) From 4fea114b0fabf381a23c37400e59644b2abf8a41 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 21 Jan 2025 11:41:49 +0100 Subject: [PATCH 07/59] Reset app badge when logging out --- iosApp/iosApp.xcodeproj/project.pbxproj | 5 +++-- iosApp/iosApp/Info.plist | 4 ++-- .../Views/Components/WebView/WebView.swift | 19 ++++++++++++++++--- .../Views/LimeSurvey/LimeSurveyView.swift | 1 - .../notification/NotificationManager.kt | 1 + 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 8a5ede174..e379d6f6f 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -1713,6 +1713,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRIP_STYLE = debugging; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -1798,7 +1799,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.19; + MARKETING_VERSION = 4.0.20; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1838,7 +1839,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.19; + MARKETING_VERSION = 4.0.20; OTHER_LDFLAGS = ( "$(inherited)", "-framework", diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index c722ac8b2..b66dd377d 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.19 + 4.0.20 CFBundleURLTypes @@ -36,7 +36,7 @@ CFBundleVersion - 4.0.19 + 4.0.20 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption diff --git a/iosApp/iosApp/Views/Components/WebView/WebView.swift b/iosApp/iosApp/Views/Components/WebView/WebView.swift index b35a0cafc..80d30455c 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebView.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebView.swift @@ -23,9 +23,22 @@ struct WebView: View { var body: some View { VStack { if viewModel.progress < 1 { - ProgressView(value: viewModel.progress, total: 1) + ProgressView(value: viewModel.progress, total: 1) } - SwiftUIWebView(viewModel: viewModel, url: url) + if #available(iOS 15.0, *) { + SwiftUIWebView(viewModel: viewModel, url: url) + .refreshable { + viewModel.webView.reload() + } + } else { + SwiftUIWebView(viewModel: viewModel, url: url) + } + } + } + + private func refreshPage() { + if let currentURL = viewModel.webView.url { + viewModel.webView.load(URLRequest(url: currentURL)) } } } @@ -50,7 +63,7 @@ struct SwiftUIWebView: UIViewRepresentable { if let url { self.viewModel.webView.load(URLRequest(url: url)) } else { - self.viewModel.webView.load(URLRequest(url: URL(string:"about:blank")!)) + self.viewModel.webView.load(URLRequest(url: URL(string: "about:blank")!)) } } } diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift index 866950055..9c27d7a80 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift @@ -27,7 +27,6 @@ struct LimeSurveyView: View { Text("Data is loading...") } } else { - WebView(url: viewModel.limeSurveyLink, viewModel: viewModel.webViewModel) .ignoresSafeArea(.all, edges: .bottom) } 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 index 5cbe9821c..2c119323f 100644 --- 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 @@ -242,6 +242,7 @@ class NotificationManager( fun clearAllNotifications() { localNotificationListener.clearNotifications() + localNotificationListener.updateBadgeCount(0) } fun updateNotificationBadgeCount() { From 339ed0b72efb8a81e7b9bb09a188e3338bf183bd Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 21 Jan 2025 11:47:21 +0100 Subject: [PATCH 08/59] Version 4.0.22 --- androidApp/build.gradle.kts | 4 ++-- iosApp/iosApp.xcodeproj/project.pbxproj | 4 ++-- iosApp/iosApp/Info.plist | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 8e182e6d4..a6ad1ae4a 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 34 - versionCode = 19 - versionName = "4.0.19" + versionCode = 22 + versionName = "4.0.22" } buildFeatures { compose = true diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index e379d6f6f..c9582dee9 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -1799,7 +1799,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.20; + MARKETING_VERSION = 4.0.22; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1839,7 +1839,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.20; + MARKETING_VERSION = 4.0.22; OTHER_LDFLAGS = ( "$(inherited)", "-framework", diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index b66dd377d..95cca0911 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.20 + 4.0.22 CFBundleURLTypes @@ -36,7 +36,7 @@ CFBundleVersion - 4.0.20 + 4.0.22 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption From d957162a212fc2ae5ddd20a47a398961b0a5c5c4 Mon Sep 17 00:00:00 2001 From: iaigner Date: Mon, 2 Jun 2025 17:33:15 +0200 Subject: [PATCH 09/59] MORE-225: add completed option to notification-item to be able to differentiate between read and completed observations and mark the notifications accordingly (+ android view) --- .../composables/NotificationItem.kt | 16 +++++++--- .../more/app/android/firebase/FCMService.kt | 1 + .../repository/NotificationRepository.kt | 32 +++++++++++++++++++ .../database/schemas/NotificationSchema.kt | 4 +++ .../models/NotificationModel.kt | 2 ++ .../models/NotificationStatusType.kt | 6 ++++ .../navigation/DeeplinkManager.kt | 32 +++++++++++++++++++ .../notification/NotificationManager.kt | 28 +++++++++++++--- 8 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt 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 e5cf9492b..b1141f17e 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Icon @@ -142,12 +143,17 @@ fun NotificationItem( modifier = Modifier.padding(vertical = 8.dp) ) } + if (notificationModel.deepLink != null) { - Icon( - if (notificationModel.read) 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 - ) + 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, + modifier = Modifier + .requiredSize(20.dp) + ) + } } } } 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..6ec83d14d 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 @@ -42,6 +42,7 @@ fun RemoteMessage.daoFromRemoteMessage(): NotificationSchema { title = this.notification?.title, notificationBody = this.notification?.body, read = false, + completed = false, userFacing = this.notification != null, priority = 1, notificationData = this.data, 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 index 6b27c083c..bfc8b8628 100644 --- 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 @@ -22,6 +22,7 @@ import kotlinx.coroutines.sync.withLock class NotificationRepository : Repository() { private val readNotificationIds = mutableSetOf() + private val completedNotificationIds = mutableSetOf() private val deletedNotificationIds = mutableSetOf() private val mutex = Mutex() fun storeNotification( @@ -32,6 +33,7 @@ class NotificationRepository : Repository() { timestamp: Long, priority: Long = 1, read: Boolean = false, + completed: Boolean = false, userFacing: Boolean = true, additionalData: Map? = null ) { @@ -47,6 +49,7 @@ class NotificationRepository : Repository() { timestamp, priority, read, + completed, userFacing, additionalData ) @@ -67,6 +70,10 @@ class NotificationRepository : Repository() { notification.read = true readNotificationIds.remove(notification.notificationId) } + if (notification.notificationId in completedNotificationIds) { + notification.completed = true + completedNotificationIds.remove(notification.notificationId) + } realmDatabase().store(setOf(notification), UpdatePolicy.ERROR) } else { deletedNotificationIds.remove(notification.notificationId) @@ -83,6 +90,7 @@ class NotificationRepository : Repository() { realmDatabase().store(notificationsToStore.map { it.apply { read = it.notificationId in readNotificationIds + completed = it.notificationId in completedNotificationIds } }, UpdatePolicy.ERROR) notificationsToDelete.forEach { deletedNotificationIds.remove(it.notificationId) } @@ -154,6 +162,30 @@ class NotificationRepository : Repository() { } } + fun setNotificationCompletedStatus(key: String, completed: Boolean = true) { + if (completed) { + readNotificationIds.add(key) + completedNotificationIds.add(key) + } else { + readNotificationIds.remove(key) + completedNotificationIds.remove(key) + } + + Scope.launch { + mutex.withLock { + realm()?.write { + val notification = + this.query("notificationId == $0", key).first().find() + notification?.let { + it.completed = completed + it.read = true + completedNotificationIds.remove(key) + } + } + } + } + } + fun deleteNotification(notificationId: String) { Scope.launch { deletedNotificationIds.add(notificationId) 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 index 3030e49c2..03fa036f5 100644 --- 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 @@ -32,6 +32,7 @@ class NotificationSchema : RealmObject { var timestamp: RealmInstant? = RealmInstant.now() var priority: Long = 0 var read: Boolean = false + var completed: Boolean = false var userFacing: Boolean = true var deepLink: String? = null var notificationData: RealmDictionary = realmDictionaryOf() @@ -69,6 +70,7 @@ class NotificationSchema : RealmObject { timestamp: Long? = null, priority: Long, read: Boolean, + completed: Boolean, userFacing: Boolean, notificationData: Map?, deepLink: String? = null @@ -79,6 +81,7 @@ class NotificationSchema : RealmObject { this.title = title this.notificationBody = notificationBody this.read = read + this.completed = completed this.userFacing = userFacing this.notificationData = notificationData?.mapKeys { it.key.replace(".", "_") }?.toRealmDictionary() @@ -100,6 +103,7 @@ class NotificationSchema : RealmObject { timestamp = notification.timestamp?.toEpochMilliseconds(), priority = 1, read = false, + completed = false, userFacing = notification.type == "text", notificationData = notification.data?.mapValues { it.value.toString() }, deepLink = notification.deepLink 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 index 86aefe0b8..2e0b41d46 100644 --- 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 @@ -21,6 +21,7 @@ data class NotificationModel( var timestamp: Long, var priority: Long, var read: Boolean, + var completed: Boolean, var userFacing: Boolean, var deepLink: String?, var notificationData: Map @@ -43,6 +44,7 @@ data class NotificationModel( timestamp = timestamp.toInstant().toEpochMilliseconds(), priority = it.priority, read = it.read, + completed = it.completed, userFacing = it.userFacing, deepLink = it.deepLink(), notificationData = notificationData diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt new file mode 100644 index 000000000..fd9fe862e --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt @@ -0,0 +1,6 @@ +package io.redlink.more.more_app_mutliplatform.models + +enum class NotificationStatusType { + READ, + COMPLETED; +} \ 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 index 4416d768b..8ea4bd424 100644 --- 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 @@ -6,6 +6,7 @@ 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.models.NotificationStatusType import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.cancellable @@ -22,6 +23,37 @@ class DeeplinkManager(private val observationFactory: ObservationFactory) { this.deepLinks.addAll(deepLinks) } + fun checkIfCompletedOrRead( + deepLink: String? + ): 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() + val state = schedule?.state + val isRead = state == NotificationStatusType.COMPLETED.toString() + val isCompleted = state == null; + // only not done modules have a schedule. if it has a schedule, the state will show if the element is actively collecting data + // which means: if the state is null, the module is already completed, otherwise check if element is Deactivated to check if the message can be deselected + when { + isRead -> emit(NotificationStatusType.READ) + isCompleted -> emit(NotificationStatusType.COMPLETED) + else -> emit(NotificationStatusType.READ) + } + } ?: run { + emit(NotificationStatusType.READ) + } + } + fun modifyDeepLink( deepLink: String?, protocolReplacement: String? = null, 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 index 2c119323f..cfda44e3f 100644 --- 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 @@ -17,6 +17,7 @@ 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.NotificationStatusType 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 @@ -69,6 +70,7 @@ class NotificationManager( body: String?, priority: Long = 1, read: Boolean = false, + completed: Boolean = false, data: Map? = null, displayNotification: Boolean ) { @@ -81,6 +83,7 @@ class NotificationManager( notificationBody = body, priority = priority, read = read, + completed = completed, userFacing = title != null, notificationData = data ), @@ -147,6 +150,11 @@ class NotificationManager( deleteNotificationFromSystemTray(notificationId) } + fun markNotificationAsCompleted(notificationId: String) { + notificationRepository.setNotificationCompletedStatus(notificationId, true) + deleteNotificationFromSystemTray(notificationId) + } + fun handleNotificationDataAsync(shared: Shared, data: Map) { Scope.launch { handleNotificationData( @@ -196,8 +204,20 @@ class NotificationManager( DeeplinkManager.OBSERVATION_DETAILS ) ) { - withContext(Dispatchers.Main) { - markNotificationAsRead(notification.notificationId) + // element should only be deselected, if it is not actively collecting data + deeplinkManager.checkIfCompletedOrRead(modifiedDeepLink).firstOrNull()?.let {isCompletedOrRead -> + println("isCompletedOrRead---------------") + println(isCompletedOrRead) + println("isCompletedOrRead---------------") + if (isCompletedOrRead === NotificationStatusType.READ) { + withContext(Dispatchers.Main) { + markNotificationAsRead(notification.notificationId) + } + } else if (isCompletedOrRead === NotificationStatusType.COMPLETED) { + withContext(Dispatchers.Main) { + markNotificationAsCompleted(notification.notificationId) + } + } } } withContext(Dispatchers.Main) { @@ -205,12 +225,12 @@ class NotificationManager( } } ?: run { withContext(Dispatchers.Main) { - markNotificationAsRead(notification.notificationId) + markNotificationAsCompleted(notification.notificationId) } } } } ?: run { - markNotificationAsRead(notification.notificationId) + markNotificationAsCompleted(notification.notificationId) } } From 64cd082223a6772879b5a0c50ef53080c997e705 Mon Sep 17 00:00:00 2001 From: iaigner Date: Mon, 2 Jun 2025 17:35:38 +0200 Subject: [PATCH 10/59] MORE-225: delete forgotten println --- .../services/notification/NotificationManager.kt | 3 --- 1 file changed, 3 deletions(-) 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 index cfda44e3f..d0770a772 100644 --- 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 @@ -206,9 +206,6 @@ class NotificationManager( ) { // element should only be deselected, if it is not actively collecting data deeplinkManager.checkIfCompletedOrRead(modifiedDeepLink).firstOrNull()?.let {isCompletedOrRead -> - println("isCompletedOrRead---------------") - println(isCompletedOrRead) - println("isCompletedOrRead---------------") if (isCompletedOrRead === NotificationStatusType.READ) { withContext(Dispatchers.Main) { markNotificationAsRead(notification.notificationId) From 917408b21766766e4348fb22adc12a15e944fd04 Mon Sep 17 00:00:00 2001 From: iaigner Date: Tue, 3 Jun 2025 12:17:33 +0200 Subject: [PATCH 11/59] MORE-225: add logic for completed to ios screen --- iosApp/iosApp/Services/FCMService.swift | 2 +- iosApp/iosApp/Views/Components/NotificationItem.swift | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp/Services/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift index 9c251eff3..7cf3c06b9 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -41,7 +41,7 @@ extension FCMService: UNUserNotificationCenterDelegate { 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(shared: AppDelegate.shared, key: msgId, title: content.title, body: content.body, priority: 1, read: false, completed: false, data: data, displayNotification: false) } return [.sound, .badge, .banner] } diff --git a/iosApp/iosApp/Views/Components/NotificationItem.swift b/iosApp/iosApp/Views/Components/NotificationItem.swift index 04b10d721..2c7cfde78 100644 --- a/iosApp/iosApp/Views/Components/NotificationItem.swift +++ b/iosApp/iosApp/Views/Components/NotificationItem.swift @@ -47,8 +47,10 @@ struct NotificationItem: View { } 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: [:])) } } From c3a2ffe2cec9dabba6124bb80a5a645e11d99232 Mon Sep 17 00:00:00 2001 From: iaigner Date: Tue, 3 Jun 2025 16:49:08 +0200 Subject: [PATCH 12/59] MORE-248: extend Login screen for Android with QR Code Option (Google ML Kit: Barcode Scanning) --- androidApp/build.gradle.kts | 5 + androidApp/src/main/AndroidManifest.xml | 19 ++ .../activities/login/LoginViewModel.kt | 4 + .../login/composables/ParticipantKeyInput.kt | 7 + .../login/composables/QRCodeButton.kt | 29 ++- .../activities/main/QRScannerActivity.kt | 227 ++++++++++++++++++ .../src/main/res/values/login-strings.xml | 4 +- 7 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/main/QRScannerActivity.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index a6ad1ae4a..56d63e05a 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -81,4 +81,9 @@ dependencies { implementation("com.google.code.gson:gson:2.10.1") implementation("com.github.acsbendi:Android-Request-Inspector-WebView:1.0.3") implementation("androidx.lifecycle:lifecycle-process:2.8.4") + //Google ML Kit for QR Scanning + implementation("com.google.mlkit:barcode-scanning:17.2.0") + implementation("androidx.camera:camera-camera2:1.3.0") + implementation("androidx.camera:camera-lifecycle:1.3.0") + implementation("androidx.camera:camera-view:1.3.0") } diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 60ce3803a..48eafd40f 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -19,10 +19,16 @@ + + + + @@ -60,6 +66,19 @@ + + + + + + + + + + val data = result.data + val scanned = data?.getStringExtra("qrResult") ?: return@rememberLauncherForActivityResult + model.extractValuesFromQRCode(scanned) + } + OutlinedButton( - onClick = {}, + onClick = { + val intent = Intent(context, QRScannerActivity::class.java) + qrScannerLauncher.launch(intent) + }, modifier = Modifier .fillMaxWidth(1f) .padding(vertical = 8.dp) @@ -50,10 +69,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/main/QRScannerActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/QRScannerActivity.kt new file mode 100644 index 000000000..d6160dc72 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/QRScannerActivity.kt @@ -0,0 +1,227 @@ +/* + * 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.content.pm.PackageManager +import androidx.activity.ComponentActivity +import androidx.camera.view.PreviewView +import com.google.mlkit.vision.barcode.BarcodeScanning +import android.os.Bundle +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import androidx.activity.compose.setContent +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.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.ButtonDefaults +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.mlkit.vision.common.InputImage +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.ui.theme.morePrimary + +// 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() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestCameraPermissionIfNeeded() + + setContent { + QrScannerScreen( + previewViewProvider = { + previewView = PreviewView(it) + previewView + }, + onClose = { finish() } + ) + } + } + + private fun requestCameraPermissionIfNeeded() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 123) + } else { + // Start camera only when permission granted and view is ready + // (delay to let Compose inflate first) + Handler(Looper.getMainLooper()).postDelayed({ startCamera() }, 200) + } + } + + @Deprecated("This method has been deprecated in favor of using the Activity Result API\n which brings increased type safety via an {@link ActivityResultContract} and the prebuilt\n contracts for common intents available in\n {@link androidx.activity.result.contract.ActivityResultContracts}, provides hooks for\n testing, and allow receiving results in separate, testable classes independent from your\n activity. Use\n {@link #registerForActivityResult(ActivityResultContract, ActivityResultCallback)} passing\n in a {@link RequestMultiplePermissions} object for the {@link ActivityResultContract} and\n handling the result in the {@link ActivityResultCallback#onActivityResult(Object) callback}.") + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 123 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + startCamera() + } else { + finish() + } + } + + // 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, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MoreColors.PrimaryLight200) + ) { + // Camera Preview (full screen) + AndroidView( + factory = previewViewProvider, + modifier = Modifier.fillMaxSize() + ) + + // 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(300.dp.toPx(), 300.dp.toPx()) + val canvasWidth = size.width + val canvasHeight = size.height + val left = (canvasWidth - cutoutSize.width) / 2 + val top = (canvasHeight - cutoutSize.height) / 2 + + // Dim everything + drawRect(color = Color(0xAA000000)) + + drawRect( + color = Color.Transparent, + topLeft = Offset(left, top), + size = cutoutSize, + blendMode = BlendMode.Clear + ) + } + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp) + .align(Alignment.TopCenter), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = getStringResource(id = R.string.more_qr_code_button), + color = MoreColors.PrimaryLight200, + fontSize = 20.sp + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 48.dp) + .align(Alignment.BottomCenter), + contentAlignment = Alignment.BottomCenter + ) { + OutlinedButton( + onClick = onClose, + modifier = Modifier + .padding(vertical = 8.dp) + .height(60.dp), + colors = ButtonDefaults.morePrimary(), + border = MoreColors.borderPrimary(true) + ) { + Text(getStringResource(id = R.string.more_close)) + } + } + } +} diff --git a/androidApp/src/main/res/values/login-strings.xml b/androidApp/src/main/res/values/login-strings.xml index 7880dc87d..68b068c77 100644 --- a/androidApp/src/main/res/values/login-strings.xml +++ b/androidApp/src/main/res/values/login-strings.xml @@ -4,13 +4,11 @@ Study Endpoint Edit Study Endpoint Enter Registration Token - Enter Token + Enter Token manually Login Enter Study URL Token Error Scan QR Code Open camera to scan a QR code or - - \ No newline at end of file From ddc77ef9683d3d6ca1a2fa8ad51cf9f2f42e38c5 Mon Sep 17 00:00:00 2001 From: iaigner Date: Wed, 4 Jun 2025 13:21:03 +0200 Subject: [PATCH 13/59] MORE-248: add native views logic for qr code scan in ios (qr code button & scan view) --- iosApp/iosApp.xcodeproj/project.pbxproj | 12 ++ .../xcshareddata/xcschemes/iosApp.xcscheme | 3 +- .../Strings/de.lproj/LoginView.strings | 3 +- .../Strings/en.lproj/LoginView.strings | 1 + .../Services/CameraPermissionManager.swift | 13 ++ iosApp/iosApp/Views/Login/LoginView.swift | 21 +++- .../iosApp/Views/Login/LoginViewModel.swift | 10 ++ .../iosApp/Views/Login/QRCodeCameraView.swift | 42 +++++++ .../iosApp/Views/Login/ScanQRCodeView.swift | 113 ++++++++++++++++++ 9 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 iosApp/iosApp/Services/CameraPermissionManager.swift create mode 100644 iosApp/iosApp/Views/Login/QRCodeCameraView.swift create mode 100644 iosApp/iosApp/Views/Login/ScanQRCodeView.swift diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c9582dee9..3e032fa26 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -148,6 +148,9 @@ 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.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 */; }; + B74670712DF0515100676D79 /* CameraPermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74670702DF0514500676D79 /* CameraPermissionManager.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 */; }; @@ -400,6 +403,9 @@ 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; 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 = ""; }; + B74670702DF0514500676D79 /* CameraPermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPermissionManager.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 = ""; }; @@ -532,6 +538,7 @@ 1F27536229CC39CA00324417 /* Services */ = { isa = PBXGroup; children = ( + B74670702DF0514500676D79 /* CameraPermissionManager.swift */, 1F6A4E3A29F6C94B00F0247F /* Bluetooth */, 07EF6C4029B5E0C700CEF37D /* PermissionManager.swift */, 1FE4446F29C849DC006AA11C /* Semaphore.swift */, @@ -923,6 +930,8 @@ B79DBA4C29D3161600A1F547 /* LoginQRCode */ = { isa = PBXGroup; children = ( + B746706E2DF04BDD00676D79 /* QRCodeCameraView.swift */, + B746706C2DF03FC100676D79 /* ScanQRCodeView.swift */, B79DBA4D29D3162700A1F547 /* LoginQRCodeView.swift */, ); name = LoginQRCode; @@ -1253,6 +1262,7 @@ 1F43998529C0FE2200687906 /* Navigation.swift in Sources */, 1F43998129B8EA5100687906 /* DictionaryExtension.swift in Sources */, 1FE4447029C849DC006AA11C /* Semaphore.swift in Sources */, + B74670712DF0515100676D79 /* CameraPermissionManager.swift in Sources */, 1F8847CE299155F50023EF10 /* SectionHeading.swift in Sources */, 1F80EC4529C2524F004667B1 /* NavigationScreen.swift in Sources */, 1F750CAD2A6FA771006E455E /* StudyPausedView.swift in Sources */, @@ -1279,6 +1289,7 @@ 1F8937882BFF0DAB0083D20E /* ObservationErrorListView.swift in Sources */, 1F9C3E8E298AAC1A00B9AC82 /* LoginView.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 */, @@ -1360,6 +1371,7 @@ 1F8847EC2992C3240023EF10 /* MoreFrame.swift in Sources */, 1F8847E82992BEE30023EF10 /* BasicText.swift in Sources */, 1F750CAF2A6FA8AE006E455E /* StudyClosedView.swift in Sources */, + B746706D2DF03FE800676D79 /* ScanQRCodeView.swift in Sources */, B78B4A7E29F0420700A1BA58 /* ObservationDetailsViewModel.swift in Sources */, 07EF6C4129B5E0C700CEF37D /* PermissionManager.swift in Sources */, 1F8EA2CD2A0BDF9A00F32602 /* LimeSurveyViewModel.swift in Sources */, diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme index 9fa15ce85..736b9681f 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" diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings index 96a6e9f35..79c6e9532 100644 --- a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings +++ b/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings @@ -21,5 +21,6 @@ "enter_study_url" = "URL der Studie eigeben"; -"scan_qr_code" = "Scannen Sie bitte Ihren QR Code"; +"scan_qr_code" = "QR Code Scannen"; +"scan_start_automatic" = "QR Code wird automatisch gescannt."; "camera_to_scan_qr_code" = "Öffnen Sie die Kamera um den QR code zu scannen"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings index 26af8bb2b..c056ba8d2 100644 --- a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings +++ b/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings @@ -22,6 +22,7 @@ "enter_study_url" = "Enter study URL"; "scan_qr_code" = "Scan QR Code"; +"scan_start_automatic" = "Scanning will start automatically."; "camera_to_scan_qr_code" = "Open camera to scan a QR Code"; 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/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 13ccf14ef..47bec4099 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -22,6 +22,7 @@ struct LoginView: View { @State private var showTokenInput = true @State private var showEndpoint = false + @State private var disabledQRCodeButton = false private let stringTable = "LoginView" @@ -47,11 +48,29 @@ struct LoginView: View { ) .padding(.bottom, 12) + Text(verbatim:.localize(forKey: "or", withComment: "Choose options between manually inputing Participant Key or Scanning QR Code", inTable: stringTable)) + .padding(.bottom, 12) + + MoreActionButton(backgroundColor: Color.more.primary, disabled: $disabledQRCodeButton) { + model.showQRCodeView = true + } label: { + HStack { + Text(verbatim:.localize(forKey: "scan_qr_code", withComment: "Login with QR Code.", inTable: stringTable)) + Spacer() + Image(systemName: "qrcode") + .foregroundColor(.more.primaryLight200) + } + } + .sheet(isPresented: $model.showQRCodeView) { + ScanQRCodeView(model: model) + } + .padding(.bottom, 12) + if showTokenInput { ErrorLogin(stringTable: .constant(stringTable), disabled: .constant(model.checkTokenCount())) .environmentObject(model) } - + Spacer() .frame(maxHeight: .infinity) diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index e271af476..9b11a1cc0 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -33,6 +33,7 @@ class LoginViewModel: ObservableObject { @Published var token: String = "" @Published var error: String = "" + @Published var showQRCodeView: Bool = false init(registrationService: RegistrationService) { print("LoginViewModel allocated!") @@ -46,6 +47,15 @@ class LoginViewModel: ObservableObject { } } + func extractValuesFromQRCode(qrCodeUrl: String) { + defaultEndpoint = 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 diff --git a/iosApp/iosApp/Views/Login/QRCodeCameraView.swift b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift new file mode 100644 index 000000000..25d5d0eaa --- /dev/null +++ b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift @@ -0,0 +1,42 @@ +// +// Untitled.swift +// iosApp +// +// Created by Isabella Aigner on 04.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import SwiftUI +import AVKit + +// Camera View using built in AVCaptureVideoPreviewLayer +struct QRCodeCameraView: UIViewRepresentable { + var frameSize: CGSize + + @Binding var session: AVCaptureSession + + func makeUIView(context: Context) -> UIView { + let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) + view.backgroundColor = .clear + + let cameraLayer = AVCaptureVideoPreviewLayer(session: session) + cameraLayer.frame = .init(origin: .zero, size: frameSize) + cameraLayer.videoGravity = .resizeAspectFill + cameraLayer.masksToBounds = true + view.layer.addSublayer(cameraLayer) + + return view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + + } +} + +/* +struct QRCodeCameraView_Previews: PreviewProvider { + static var previews: some View { + QRCodeCameraView(frameSize: CGSize(width: 300, height: 300), session: AVCaptureSession()) + } +} +*/ diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift new file mode 100644 index 000000000..634fc87df --- /dev/null +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -0,0 +1,113 @@ +// +// Untitled.swift +// iosApp +// +// Created by Isabella Aigner on 04.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import shared + +import SwiftUI +import AVFoundation + +struct ScanQRCodeView: View { + @StateObject var model: LoginViewModel + private let stringTable = "LoginView" + + // QR Code Scanner Properties + @State private var isScanning: Bool = false + @State private var session: AVCaptureSession = .init() + @State private var cameraPermission: CameraPermissionStatus = .idle + + // QR Code Scanner Output + @State private var qrOutput: AVCaptureMetadataOutput = .init() + + // Error Properties + @State private var errorMessage: String = "" + @State private var showError: Bool = false + + var body: some View { + VStack(spacing: 8) { + Button { + model.showQRCodeView = false + } label: { + Image(systemName: "xmark") + .foregroundColor(.more.textDefault) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(verbatim:.localize(forKey: "scan_qr_code", withComment: "Login with QR Code.", inTable: stringTable)) + .foregroundColor(.more.primaryLight200) + .padding(.top, 20) + + Text(verbatim:.localize(forKey: "scan_start_automatic", withComment: "Scanning will start automatically", inTable: stringTable)) + .foregroundColor(.more.primaryLight200) + .padding(.top, 20) + + Spacer(minLength: 0) + + /// Scanner + GeometryReader { + let size = $0.size + + QRCodeCameraView(frameSize: size, session: $session) + + 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("Primary"), style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) + .rotationEffect(.init(degrees: rotation)) + } + } + .frame(width: size.width, height: size.width) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + Spacer(minLength: 15) + + Button { + + } label: { + Image(systemName: "qrcode.viewfinder") + .font(.largeTitle) + .foregroundColor(.more.textDefault) + } + + Spacer(minLength: 45) + } + .padding(15) + // check camera permission + .onAppear(perform: checkCameraPermission) + } + + /// Checking Camera Permissions + func checkCameraPermission() { + Task { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + cameraPermission = .approved + case .notDetermined: + if await AVCaptureDevice.requestAccess(for: .video) { + cameraPermission = .approved + } else { + cameraPermission = .denied + } + case .denied, .restricted: + cameraPermission = .denied + default: break + } + } + } +} + +struct ScanQRCodeView_Previews: PreviewProvider { + static var previews: some View { + ScanQRCodeView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(dataManager: iOSObservationDataManager()), dataRecorder: IOSDataRecorder())))) + } +} + + From 747a28608edbcb032a6be47133d9de8d0e7550cf Mon Sep 17 00:00:00 2001 From: iaigner Date: Wed, 4 Jun 2025 19:56:25 +0200 Subject: [PATCH 14/59] MORE-225: Move logic to NotificationManager to handled completed/read properties --- .../database/repository/ScheduleRepository.kt | 23 +++++- .../navigation/DeeplinkManager.kt | 32 --------- .../notification/NotificationManager.kt | 72 +++++++++++++++---- 3 files changed, 80 insertions(+), 47 deletions(-) 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 index 60fd7bd25..18c4c9823 100644 --- 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 @@ -137,6 +137,27 @@ class ScheduleRepository : Repository() { } } + suspend fun getPreviousSchedule(observationId: String, currScheduleId: ObjectId): ScheduleSchema? { + val scheduleList = queryAllSchedulesForObservationId(observationId) + var previousSchedule: ScheduleSchema? = null + + scheduleList.collect { list -> + val index = list.indexOfFirst { it.scheduleId == currScheduleId } + + if (index > 0) { + previousSchedule = list[index - 1] + } + } + + return previousSchedule + } + + fun queryAllSchedulesForObservationId(observationId: String): Flow> { + return realm()?.query("observationId = $0", observationId)?.asMappedFlow() + ?.transform { emit(it.sortedBy { it.start }) } + ?: emptyFlow() + } + suspend fun getNextSchedule() = nextSchedule().firstOrNull() fun nextScheduleStart(): Flow { @@ -228,6 +249,4 @@ class ScheduleRepository : Repository() { } } } - - } \ 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 index 8ea4bd424..4416d768b 100644 --- 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 @@ -6,7 +6,6 @@ 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.models.NotificationStatusType import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.cancellable @@ -23,37 +22,6 @@ class DeeplinkManager(private val observationFactory: ObservationFactory) { this.deepLinks.addAll(deepLinks) } - fun checkIfCompletedOrRead( - deepLink: String? - ): 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() - val state = schedule?.state - val isRead = state == NotificationStatusType.COMPLETED.toString() - val isCompleted = state == null; - // only not done modules have a schedule. if it has a schedule, the state will show if the element is actively collecting data - // which means: if the state is null, the module is already completed, otherwise check if element is Deactivated to check if the message can be deselected - when { - isRead -> emit(NotificationStatusType.READ) - isCompleted -> emit(NotificationStatusType.COMPLETED) - else -> emit(NotificationStatusType.READ) - } - } ?: run { - emit(NotificationStatusType.READ) - } - } - fun modifyDeepLink( deepLink: String?, protocolReplacement: String? = null, 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 index d0770a772..1ba757f2c 100644 --- 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 @@ -15,9 +15,13 @@ 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.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.extensions.mapQueryParams import io.redlink.more.more_app_mutliplatform.models.NotificationModel import io.redlink.more.more_app_mutliplatform.models.NotificationStatusType +import io.redlink.more.more_app_mutliplatform.models.ScheduleState 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 @@ -25,9 +29,12 @@ import io.redlink.more.more_app_mutliplatform.services.store.SharedStorageReposi import io.redlink.more.more_app_mutliplatform.util.Scope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +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.flow.update import kotlinx.coroutines.withContext @@ -51,6 +58,8 @@ class NotificationManager( val notificationRepository = NotificationRepository() val _unreadUserCount = MutableStateFlow(0) val unreadUserCount: StateFlow = _unreadUserCount + private val scheduleRepository = ScheduleRepository() + private val observationRepository = ObservationRepository() init { Scope.launch(Dispatchers.IO) { @@ -190,6 +199,7 @@ class NotificationManager( } } + // To-Do: here check completed or only ended fun handleNotificationInteraction( notification: NotificationModel, protocolReplacement: String? = null, @@ -198,23 +208,24 @@ class NotificationManager( ) { notification.deepLink?.let { deepLink -> Scope.launch { + + // first check status of notification + val state = checkIfCompletedOrRead(deepLink).cancellable().firstOrNull() + + if (state != null) { + if (NotificationStatusType.READ == state) notificationRepository.setNotificationReadStatus(notification.notificationId, true) + if (NotificationStatusType.COMPLETED == state) notificationRepository.setNotificationCompletedStatus(notification.notificationId, true) + } + + // then modify deeplink deeplinkManager.modifyDeepLink(deepLink, protocolReplacement, hostReplacement) .firstOrNull()?.let { modifiedDeepLink -> if (modifiedDeepLink.contains(DeeplinkManager.TASK_DETAILS) || modifiedDeepLink.contains( DeeplinkManager.OBSERVATION_DETAILS ) ) { - // element should only be deselected, if it is not actively collecting data - deeplinkManager.checkIfCompletedOrRead(modifiedDeepLink).firstOrNull()?.let {isCompletedOrRead -> - if (isCompletedOrRead === NotificationStatusType.READ) { - withContext(Dispatchers.Main) { - markNotificationAsRead(notification.notificationId) - } - } else if (isCompletedOrRead === NotificationStatusType.COMPLETED) { - withContext(Dispatchers.Main) { - markNotificationAsCompleted(notification.notificationId) - } - } + withContext(Dispatchers.Main) { + markNotificationAsRead(notification.notificationId) } } withContext(Dispatchers.Main) { @@ -222,13 +233,48 @@ class NotificationManager( } } ?: run { withContext(Dispatchers.Main) { - markNotificationAsCompleted(notification.notificationId) + markNotificationAsRead(notification.notificationId) } } } } ?: run { - markNotificationAsCompleted(notification.notificationId) + markNotificationAsRead(notification.notificationId) + } + } + + fun checkIfCompletedOrRead( + notificationDeeplink: String, + ): Flow = flow { + + var state: NotificationStatusType? = null + + val queryParams = notificationDeeplink.mapQueryParams() + val observationId = queryParams["observationId"] + if (observationId.isNullOrEmpty() + || observationRepository.observationById(observationId.first()) + .firstOrNull() == null + ) { + emit(null) + return@flow + } + var schedule = + scheduleRepository.firstScheduleAvailableForObservationId(observationId.first()) + .cancellable().firstOrNull() + + val allSchedules = scheduleRepository.queryAllSchedulesForObservationId(observationId.first()) + + // 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 + + if (schedule?.state == ScheduleState.DONE.toString() || schedule?.state == null) { + state = NotificationStatusType.COMPLETED + } else { + state = NotificationStatusType.READ } + emit(state) } fun newFCMToken(token: String? = null) { From 42174f826ec29cb2cee8d5264c365c0c8c689b94 Mon Sep 17 00:00:00 2001 From: iaigner Date: Thu, 5 Jun 2025 11:14:58 +0200 Subject: [PATCH 15/59] MORE-225: Set notification to completed when question or lime survey are answered + delete modifier --- .../activities/notification/composables/NotificationItem.kt | 5 +---- .../more/more_app_mutliplatform/observations/Observation.kt | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) 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 b1141f17e..6f35114f0 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 @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Icon @@ -149,9 +148,7 @@ fun NotificationItem( 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, - modifier = Modifier - .requiredSize(20.dp) + tint = if (notificationModel.read) MoreColors.Approved else MoreColors.Primary ) } } 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 index bed413629..fd225715c 100644 --- 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 @@ -247,7 +247,7 @@ abstract class Observation(val observationType: ObservationType) { private fun handleNotification(scheduleId: String) { notificationIds.remove(scheduleId)?.let { - notificationManager?.markNotificationAsRead(it) + notificationManager?.markNotificationAsCompleted(it) } } From 2936f0f324f5e920f24fda2fa50332e1841d7272 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 5 Jun 2025 11:36:56 +0200 Subject: [PATCH 16/59] Updated to version 4.0.24 --- androidApp/build.gradle.kts | 4 ++-- iosApp/iosApp.xcodeproj/project.pbxproj | 4 ++-- iosApp/iosApp/Info.plist | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index a6ad1ae4a..68dd7d9b8 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 34 - versionCode = 22 - versionName = "4.0.22" + versionCode = 24 + versionName = "4.0.24" } buildFeatures { compose = true diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c9582dee9..3f2bc57e8 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -1799,7 +1799,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.22; + MARKETING_VERSION = 4.0.24; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1839,7 +1839,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.22; + MARKETING_VERSION = 4.0.24; OTHER_LDFLAGS = ( "$(inherited)", "-framework", diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 95cca0911..05ddbb275 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.22 + 4.0.24 CFBundleURLTypes @@ -36,7 +36,7 @@ CFBundleVersion - 4.0.22 + 4.0.24 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption From 7c93f4ed4389b1d627b74e5250cee7879545c0e4 Mon Sep 17 00:00:00 2001 From: iaigner Date: Fri, 6 Jun 2025 12:17:01 +0200 Subject: [PATCH 17/59] MORE-248: implement QRCode Scan for ios (new view, handling of camera permissions, qr code scan) --- iosApp/iosApp.xcodeproj/project.pbxproj | 16 ++- .../Views/Login/CameraPreviewView.swift | 31 +++++ .../Login/LoginQRCode/LoginQRCodeView.swift | 2 - .../iosApp/Views/Login/QRCodeCameraView.swift | 15 +- .../Views/Login/QRCodeScanDeligate.swift | 23 +++ .../iosApp/Views/Login/ScanQRCodeView.swift | 131 +++++++++++++++--- .../Views/Login/ScanQrCodeViewModel.swift | 15 ++ 7 files changed, 193 insertions(+), 40 deletions(-) create mode 100644 iosApp/iosApp/Views/Login/CameraPreviewView.swift create mode 100644 iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift create mode 100644 iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3e032fa26..0e4e2e6e9 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -146,11 +146,13 @@ 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 /* QRCodeScanDeligate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70888482DF2D4220048A4AC /* QRCodeScanDeligate.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 */; }; - B74670712DF0515100676D79 /* CameraPermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74670702DF0514500676D79 /* CameraPermissionManager.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 */; }; @@ -401,11 +403,13 @@ 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 /* QRCodeScanDeligate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScanDeligate.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 = ""; }; - B74670702DF0514500676D79 /* CameraPermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPermissionManager.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 = ""; }; @@ -538,7 +542,6 @@ 1F27536229CC39CA00324417 /* Services */ = { isa = PBXGroup; children = ( - B74670702DF0514500676D79 /* CameraPermissionManager.swift */, 1F6A4E3A29F6C94B00F0247F /* Bluetooth */, 07EF6C4029B5E0C700CEF37D /* PermissionManager.swift */, 1FE4446F29C849DC006AA11C /* Semaphore.swift */, @@ -930,6 +933,9 @@ B79DBA4C29D3161600A1F547 /* LoginQRCode */ = { isa = PBXGroup; children = ( + B708884C2DF2EE510048A4AC /* CameraPreviewView.swift */, + B708884A2DF2E66E0048A4AC /* ScanQrCodeViewModel.swift */, + B70888482DF2D4220048A4AC /* QRCodeScanDeligate.swift */, B746706E2DF04BDD00676D79 /* QRCodeCameraView.swift */, B746706C2DF03FC100676D79 /* ScanQRCodeView.swift */, B79DBA4D29D3162700A1F547 /* LoginQRCodeView.swift */, @@ -1262,7 +1268,6 @@ 1F43998529C0FE2200687906 /* Navigation.swift in Sources */, 1F43998129B8EA5100687906 /* DictionaryExtension.swift in Sources */, 1FE4447029C849DC006AA11C /* Semaphore.swift in Sources */, - B74670712DF0515100676D79 /* CameraPermissionManager.swift in Sources */, 1F8847CE299155F50023EF10 /* SectionHeading.swift in Sources */, 1F80EC4529C2524F004667B1 /* NavigationScreen.swift in Sources */, 1F750CAD2A6FA771006E455E /* StudyPausedView.swift in Sources */, @@ -1325,6 +1330,7 @@ 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 */, @@ -1335,6 +1341,7 @@ 07D9046829C85166003D2912 /* GPSObservation.swift in Sources */, EDB16E3029F9357300701C27 /* TaskCompletionBarView.swift in Sources */, ED474B4C29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift in Sources */, + B708884B2DF2E6730048A4AC /* ScanQrCodeViewModel.swift in Sources */, 1F13BA472993972E00938C1E /* ConsentListItem.swift in Sources */, B74DDB6629E69F44006FEA74 /* NotificationViewModel.swift in Sources */, 1FDC264A29C1CFE80011D8A4 /* NavigationText.swift in Sources */, @@ -1384,6 +1391,7 @@ EDB16E2B29F933EF00701C27 /* TaskCompletionBarViewModel.swift in Sources */, 07FD293C29D57F0300853108 /* ModuleListItem.swift in Sources */, 1F9DB1A0298CF44000DBB7DB /* MoreColor.swift in Sources */, + B70888492DF2D4290048A4AC /* QRCodeScanDeligate.swift in Sources */, B79DBA4729D3141400A1F547 /* NavigationLinkButton.swift in Sources */, 07BC54B229CB47C400459267 /* DetailsTitle.swift in Sources */, 1F8847D629915ADE0023EF10 /* MoreAnimation.swift in Sources */, diff --git a/iosApp/iosApp/Views/Login/CameraPreviewView.swift b/iosApp/iosApp/Views/Login/CameraPreviewView.swift new file mode 100644 index 000000000..5d5e4e458 --- /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 UIKit +import AVFoundation + +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) + self.previewLayer = layer + } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } +} 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/QRCodeCameraView.swift b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift index 25d5d0eaa..5bc812bc5 100644 --- a/iosApp/iosApp/Views/Login/QRCodeCameraView.swift +++ b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift @@ -12,24 +12,17 @@ import AVKit // Camera View using built in AVCaptureVideoPreviewLayer struct QRCodeCameraView: UIViewRepresentable { var frameSize: CGSize - - @Binding var session: AVCaptureSession + @Binding var cameraSession: AVCaptureSession func makeUIView(context: Context) -> UIView { - let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) - view.backgroundColor = .clear - - let cameraLayer = AVCaptureVideoPreviewLayer(session: session) - cameraLayer.frame = .init(origin: .zero, size: frameSize) - cameraLayer.videoGravity = .resizeAspectFill - cameraLayer.masksToBounds = true - view.layer.addSublayer(cameraLayer) + let view = CameraPreviewView() + view.configure(session: cameraSession) return view } func updateUIView(_ uiView: UIViewType, context: Context) { - + uiView.setNeedsLayout() } } diff --git a/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift b/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift new file mode 100644 index 000000000..68a879b18 --- /dev/null +++ b/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift @@ -0,0 +1,23 @@ +// +// QRCodeScanDeligate.swift +// iosApp +// +// Created by Isabella Aigner on 06.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import SwiftUI +import AVFoundation +import AVKit + +class QRScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + @Published var scannedCode: String? + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + if let metaObject = metadataObjects.first { + guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else { return } + guard let code = readableObject.stringValue else { return } + print(code) + scannedCode = code + } + } +} diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 634fc87df..6ba83bf6f 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -13,19 +13,32 @@ import AVFoundation struct ScanQRCodeView: View { @StateObject var model: LoginViewModel + + init(model: LoginViewModel) { + _model = StateObject(wrappedValue: model) + } + private let stringTable = "LoginView" // QR Code Scanner Properties @State private var isScanning: Bool = false - @State private var session: AVCaptureSession = .init() + @State private var cameraSession: AVCaptureSession = .init() @State private var cameraPermission: CameraPermissionStatus = .idle - + // QR Code Scanner Output @State private var qrOutput: AVCaptureMetadataOutput = .init() + @State private var videoPreviewLayer: AVCaptureVideoPreviewLayer = .init() + @State private var showError: Bool = false // Error Properties @State private var errorMessage: String = "" - @State private var showError: Bool = false + @Environment(\.openURL) private var openURL + + // Camera QR Code Output Delegate + @StateObject private var qrDelegate = QRScannerDelegate() + + // Scanned Code + @State private var scannedCode: String = "" var body: some View { VStack(spacing: 8) { @@ -37,29 +50,36 @@ struct ScanQRCodeView: View { } .frame(maxWidth: .infinity, alignment: .leading) - Text(verbatim:.localize(forKey: "scan_qr_code", withComment: "Login with QR Code.", inTable: stringTable)) - .foregroundColor(.more.primaryLight200) + Image("more_welcome") .padding(.top, 20) + .padding(.bottom, 10) - Text(verbatim:.localize(forKey: "scan_start_automatic", withComment: "Scanning will start automatically", inTable: stringTable)) - .foregroundColor(.more.primaryLight200) - .padding(.top, 20) + HStack(spacing: 8) { + Image(systemName: "qrcode.viewfinder") + .font(.largeTitle) + .foregroundColor(.more.textDefault) + + Text(verbatim:.localize(forKey: "scan_qr_code", withComment: "Login with QR Code.", inTable: stringTable)) + .foregroundColor(.more.primary) + } Spacer(minLength: 0) - /// Scanner + /// Scanner Frame GeometryReader { let size = $0.size - QRCodeCameraView(frameSize: size, session: $session) + QRCodeCameraView(frameSize: CGSize(width: size.width, height: size.height), cameraSession: $cameraSession) + .onAppear() { + 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("Primary"), style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) + .stroke(Color("Secondary"), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) .rotationEffect(.init(degrees: rotation)) } } @@ -67,21 +87,39 @@ struct ScanQRCodeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - Spacer(minLength: 15) - - Button { - - } label: { - Image(systemName: "qrcode.viewfinder") - .font(.largeTitle) - .foregroundColor(.more.textDefault) - } - Spacer(minLength: 45) } .padding(15) // check camera permission .onAppear(perform: checkCameraPermission) + .alert(isPresented: $showError) { + Alert( + title: Text("Permission needed"), + message: Text(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: qrDelegate.scannedCode) { newValue in + if let code = newValue { + scannedCode = code + cameraSession.stopRunning() + + // send code to model to put it into Token and url & close view + + model.extractValuesFromQRCode(qrCodeUrl: scannedCode) + print(model.token) + print(model.defaultEndpoint) + + model.showQRCodeView = false + } + } } /// Checking Camera Permissions @@ -95,19 +133,66 @@ struct ScanQRCodeView: View { cameraPermission = .approved } else { cameraPermission = .denied + presentError("Please Provide Access to your Camera for scanning codes") } case .denied, .restricted: cameraPermission = .denied + presentError("Please Provide Access to your Camera for scanning codes") default: break } } } + + func presentError(_ message: String) { + errorMessage = message + showError.toggle() + } + + + func setupCamera() { + do { + guard let device = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back).devices.first else { + presentError("UNKNOWN DEVICE ERROR") + return + } + + do { + // Camera input + let input = try AVCaptureDeviceInput(device: device) + + guard cameraSession.canAddInput(input), cameraSession.canAddOutput(qrOutput) else { + presentError("UNKNOWN INPUT/OUTPUT ERROR") + return + } + + // add input & output to camera session + cameraSession.beginConfiguration() + cameraSession.addInput(input) + cameraSession.addOutput(qrOutput) + + // setting outputconfig to reaed qr code + qrOutput.metadataObjectTypes = [.qr] + // adding delegate to retrieve the fetched qr code from camera + qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) + + cameraSession.commitConfiguration() + + DispatchQueue.global(qos: .background).async { + cameraSession.startRunning() + } + + } catch { + presentError(error.localizedDescription) + } + } + } } +/* struct ScanQRCodeView_Previews: PreviewProvider { static var previews: some View { ScanQRCodeView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(dataManager: iOSObservationDataManager()), dataRecorder: IOSDataRecorder())))) } } - +*/ diff --git a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift new file mode 100644 index 000000000..90c7b7e31 --- /dev/null +++ b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift @@ -0,0 +1,15 @@ +// +// QRCodeCameraModel.swift +// iosApp +// +// Created by Isabella Aigner on 06.06.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import SwiftUI + +enum CameraPermissionStatus: String { + case idle = "Not Determined" + case approved = "Access Granted" + case denied = "Access Denied" +} From 9ba94d20a51357eeaa03b137872d361570f04141 Mon Sep 17 00:00:00 2001 From: iaigner Date: Fri, 6 Jun 2025 12:41:02 +0200 Subject: [PATCH 18/59] MORE-248: move all func in ScanQRCodeView into it's model and adapt logic where needed --- .../Views/Login/QRCodeScanDeligate.swift | 14 +-- .../iosApp/Views/Login/ScanQRCodeView.swift | 118 ++---------------- .../Views/Login/ScanQrCodeViewModel.swift | 87 +++++++++++++ 3 files changed, 107 insertions(+), 112 deletions(-) diff --git a/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift b/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift index 68a879b18..89718d912 100644 --- a/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift +++ b/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift @@ -10,14 +10,14 @@ import SwiftUI import AVFoundation import AVKit -class QRScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { - @Published var scannedCode: String? +class QRScannerDelegate: NSObject, AVCaptureMetadataOutputObjectsDelegate { + var onCodeScanned: ((String) -> Void)? + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { - if let metaObject = metadataObjects.first { - guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else { return } - guard let code = readableObject.stringValue else { return } - print(code) - scannedCode = code + 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 index 6ba83bf6f..ed99536a2 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -12,34 +12,15 @@ import SwiftUI import AVFoundation struct ScanQRCodeView: View { - @StateObject var model: LoginViewModel - - init(model: LoginViewModel) { - _model = StateObject(wrappedValue: model) - } - + @StateObject private var viewModel = ScanQRCodeViewModel() + @ObservedObject var model: LoginViewModel + private let stringTable = "LoginView" - // QR Code Scanner Properties - @State private var isScanning: Bool = false - @State private var cameraSession: AVCaptureSession = .init() - @State private var cameraPermission: CameraPermissionStatus = .idle - - // QR Code Scanner Output - @State private var qrOutput: AVCaptureMetadataOutput = .init() - @State private var videoPreviewLayer: AVCaptureVideoPreviewLayer = .init() - @State private var showError: Bool = false - // Error Properties - @State private var errorMessage: String = "" + @State private var errorMessage: String = ""*/ @Environment(\.openURL) private var openURL - // Camera QR Code Output Delegate - @StateObject private var qrDelegate = QRScannerDelegate() - - // Scanned Code - @State private var scannedCode: String = "" - var body: some View { VStack(spacing: 8) { Button { @@ -69,9 +50,9 @@ struct ScanQRCodeView: View { GeometryReader { let size = $0.size - QRCodeCameraView(frameSize: CGSize(width: size.width, height: size.height), cameraSession: $cameraSession) + QRCodeCameraView(frameSize: CGSize(width: size.width, height: size.height), cameraSession: $viewModel.cameraSession) .onAppear() { - setupCamera() + viewModel.setupCamera() } ZStack { @@ -91,11 +72,11 @@ struct ScanQRCodeView: View { } .padding(15) // check camera permission - .onAppear(perform: checkCameraPermission) - .alert(isPresented: $showError) { + .onAppear(perform: viewModel.checkCameraPermission) + .alert(isPresented: $viewModel.showError) { Alert( title: Text("Permission needed"), - message: Text(errorMessage), + message: Text(viewModel.errorMessage), primaryButton: .default(Text("Open Settings"), action: { let settingsString = UIApplication.openSettingsURLString if let settingsURL = URL(string: settingsString) { @@ -106,83 +87,10 @@ struct ScanQRCodeView: View { secondaryButton: .cancel(Text("Cancel")) ) } - .onChange(of: qrDelegate.scannedCode) { newValue in - if let code = newValue { - scannedCode = code - cameraSession.stopRunning() - - // send code to model to put it into Token and url & close view - - model.extractValuesFromQRCode(qrCodeUrl: scannedCode) - print(model.token) - print(model.defaultEndpoint) - - model.showQRCodeView = false - } - } - } - - /// Checking Camera Permissions - func checkCameraPermission() { - Task { - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - cameraPermission = .approved - case .notDetermined: - if await AVCaptureDevice.requestAccess(for: .video) { - cameraPermission = .approved - } else { - cameraPermission = .denied - presentError("Please Provide Access to your Camera for scanning codes") - } - case .denied, .restricted: - cameraPermission = .denied - presentError("Please Provide Access to your Camera for scanning codes") - default: break - } - } - } - - func presentError(_ message: String) { - errorMessage = message - showError.toggle() - } - - - func setupCamera() { - do { - guard let device = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back).devices.first else { - presentError("UNKNOWN DEVICE ERROR") - return - } - - do { - // Camera input - let input = try AVCaptureDeviceInput(device: device) - - guard cameraSession.canAddInput(input), cameraSession.canAddOutput(qrOutput) else { - presentError("UNKNOWN INPUT/OUTPUT ERROR") - return - } - - // add input & output to camera session - cameraSession.beginConfiguration() - cameraSession.addInput(input) - cameraSession.addOutput(qrOutput) - - // setting outputconfig to reaed qr code - qrOutput.metadataObjectTypes = [.qr] - // adding delegate to retrieve the fetched qr code from camera - qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) - - cameraSession.commitConfiguration() - - DispatchQueue.global(qos: .background).async { - cameraSession.startRunning() - } - - } catch { - presentError(error.localizedDescription) + .onChange(of: viewModel.scannedCode) { code in + if let code { + model.extractValuesFromQRCode(qrCodeUrl: code) + model.showQRCodeView = false // View schließen } } } diff --git a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift index 90c7b7e31..98c7bccd4 100644 --- a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift +++ b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift @@ -6,10 +6,97 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // +import shared import SwiftUI +import AVFoundation + 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 + self?.cameraSession.stopRunning() + } + } + } + + func checkCameraPermission() { + Task { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + await MainActor.run { + self.cameraPermission = .approved + self.setupCamera() + } + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + await MainActor.run { + if granted { + self.cameraPermission = .approved + self.setupCamera() + } else { + self.presentError("Please Provide Access to your Camera") + } + } + case .denied, .restricted: + presentError("Please Provide Access to your Camera") + default: + break + } + } + } + + func setupCamera() { + guard let device = AVCaptureDevice.default(for: .video) else { + presentError("No camera available.") + 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) + } + + cameraSession.startRunning() + } catch { + presentError(error.localizedDescription) + } + } + + func presentError(_ message: String) { + DispatchQueue.main.async { + self.errorMessage = message + self.showError = true + } + } +} From 6ae0cc3c6a88bb5699cab7dc7ce5109c6a6051a5 Mon Sep 17 00:00:00 2001 From: iaigner Date: Fri, 6 Jun 2025 12:42:54 +0200 Subject: [PATCH 19/59] MORE-248: delete forgotten comment --- iosApp/iosApp/Views/Login/ScanQRCodeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index ed99536a2..ca837e4d7 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -18,7 +18,7 @@ struct ScanQRCodeView: View { private let stringTable = "LoginView" // Error Properties - @State private var errorMessage: String = ""*/ + @State private var errorMessage: String = "" @Environment(\.openURL) private var openURL var body: some View { From f26e5500823b85dc0cfdb8dc641a6cf33e10e30a Mon Sep 17 00:00:00 2001 From: iaigner Date: Fri, 6 Jun 2025 13:00:44 +0200 Subject: [PATCH 20/59] MORE-248: refactor - add some localizations & correct typos --- iosApp/iosApp.xcodeproj/project.pbxproj | 12 ++++-------- .../Resources/Strings/de.lproj/LoginView.strings | 4 ++++ .../Resources/Strings/en.lproj/LoginView.strings | 4 +++- iosApp/iosApp/Views/Login/QRCodeCameraView.swift | 11 ++++++++--- ...deScanDeligate.swift => QRCodeScanDelegate.swift} | 0 iosApp/iosApp/Views/Login/ScanQRCodeView.swift | 8 +++----- 6 files changed, 22 insertions(+), 17 deletions(-) rename iosApp/iosApp/Views/Login/{QRCodeScanDeligate.swift => QRCodeScanDelegate.swift} (100%) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 0e4e2e6e9..248b53a9e 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -146,7 +146,7 @@ 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 /* QRCodeScanDeligate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70888482DF2D4220048A4AC /* QRCodeScanDeligate.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 */; }; @@ -177,7 +177,6 @@ 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 */; }; @@ -403,7 +402,7 @@ 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 /* QRCodeScanDeligate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScanDeligate.swift; 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 = ""; }; @@ -437,7 +436,6 @@ 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 = ""; }; @@ -935,10 +933,9 @@ children = ( B708884C2DF2EE510048A4AC /* CameraPreviewView.swift */, B708884A2DF2E66E0048A4AC /* ScanQrCodeViewModel.swift */, - B70888482DF2D4220048A4AC /* QRCodeScanDeligate.swift */, + B70888482DF2D4220048A4AC /* QRCodeScanDelegate.swift */, B746706E2DF04BDD00676D79 /* QRCodeCameraView.swift */, B746706C2DF03FC100676D79 /* ScanQRCodeView.swift */, - B79DBA4D29D3162700A1F547 /* LoginQRCodeView.swift */, ); name = LoginQRCode; sourceTree = ""; @@ -1364,7 +1361,6 @@ 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 */, @@ -1391,7 +1387,7 @@ EDB16E2B29F933EF00701C27 /* TaskCompletionBarViewModel.swift in Sources */, 07FD293C29D57F0300853108 /* ModuleListItem.swift in Sources */, 1F9DB1A0298CF44000DBB7DB /* MoreColor.swift in Sources */, - B70888492DF2D4290048A4AC /* QRCodeScanDeligate.swift in Sources */, + B70888492DF2D4290048A4AC /* QRCodeScanDelegate.swift in Sources */, B79DBA4729D3141400A1F547 /* NavigationLinkButton.swift in Sources */, 07BC54B229CB47C400459267 /* DetailsTitle.swift in Sources */, 1F8847D629915ADE0023EF10 /* MoreAnimation.swift in Sources */, diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings index 79c6e9532..c91eca550 100644 --- a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings +++ b/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings @@ -24,3 +24,7 @@ "scan_qr_code" = "QR Code Scannen"; "scan_start_automatic" = "QR Code wird automatisch gescannt."; "camera_to_scan_qr_code" = "Öffnen Sie die Kamera um den QR code zu scannen"; + +"permission_needed" = "Kamerazugriff erforderlich"; +"open_settings" = "Einstellungen öffnen"; +"cancel" = "Abbrechen"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings index c056ba8d2..bdef232c1 100644 --- a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings +++ b/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings @@ -25,4 +25,6 @@ "scan_start_automatic" = "Scanning will start automatically."; "camera_to_scan_qr_code" = "Open camera to scan a QR Code"; - +"permission_needed" = "Permission needed"; +"open_settings" = "Open settings"; +"cancel" = "Cancel"; diff --git a/iosApp/iosApp/Views/Login/QRCodeCameraView.swift b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift index 5bc812bc5..c8fd27b03 100644 --- a/iosApp/iosApp/Views/Login/QRCodeCameraView.swift +++ b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift @@ -26,10 +26,15 @@ struct QRCodeCameraView: UIViewRepresentable { } } -/* + struct QRCodeCameraView_Previews: PreviewProvider { + @State static var previewSession = AVCaptureSession() + static var previews: some View { - QRCodeCameraView(frameSize: CGSize(width: 300, height: 300), session: AVCaptureSession()) + QRCodeCameraView( + frameSize: CGSize(width: 300, height: 300), + cameraSession: $previewSession + ) } } -*/ + diff --git a/iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift b/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift similarity index 100% rename from iosApp/iosApp/Views/Login/QRCodeScanDeligate.swift rename to iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index ca837e4d7..6c028b213 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -75,16 +75,16 @@ struct ScanQRCodeView: View { .onAppear(perform: viewModel.checkCameraPermission) .alert(isPresented: $viewModel.showError) { Alert( - title: Text("Permission needed"), + title: Text(verbatim:.localize(forKey: "permission_needed", withComment: "Camera permission is needed, but not granted.", inTable: stringTable)), message: Text(viewModel.errorMessage), - primaryButton: .default(Text("Open Settings"), action: { + primaryButton: .default(Text(verbatim:.localize(forKey: "open_settings", withComment: "Open device settings to grant permissions.", inTable: stringTable)), action: { let settingsString = UIApplication.openSettingsURLString if let settingsURL = URL(string: settingsString) { // open app settings, using openURL SwiftUI API openURL(settingsURL) } }), - secondaryButton: .cancel(Text("Cancel")) + secondaryButton: .cancel(Text(verbatim:.localize(forKey: "cancel", withComment: "Cancel QR Code scan", inTable: stringTable))) ) } .onChange(of: viewModel.scannedCode) { code in @@ -96,11 +96,9 @@ struct ScanQRCodeView: View { } } -/* struct ScanQRCodeView_Previews: PreviewProvider { static var previews: some View { ScanQRCodeView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(dataManager: iOSObservationDataManager()), dataRecorder: IOSDataRecorder())))) } } -*/ From 1c10a60f094a01f4c2fea2055d6bd8f6bd96151c Mon Sep 17 00:00:00 2001 From: iaigner Date: Tue, 10 Jun 2025 11:58:24 +0200 Subject: [PATCH 21/59] MORE-248: match fix login & match designs between ios and android --- .../activities/login/LoginViewModel.kt | 2 +- .../login/composables/ParticipantKeyInput.kt | 10 +-- .../login/composables/QRCodeButton.kt | 6 +- .../activities/main/QRScannerActivity.kt | 87 ++++++++++++++++--- .../src/main/res/values-de/login-strings.xml | 3 +- .../src/main/res/values/login-strings.xml | 3 +- .../Views/Components/MoreTextFieldHL.swift | 3 + iosApp/iosApp/Views/Login/LoginView.swift | 13 +-- .../iosApp/Views/Login/LoginViewModel.swift | 2 +- 9 files changed, 96 insertions(+), 33 deletions(-) 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 411851197..475c819e2 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 @@ -71,7 +71,7 @@ class LoginViewModel(registrationService: RegistrationService, private val login } fun extractValuesFromQRCode(qrCodeUrl: String) { - defaultEndpoint.value = qrCodeUrl.substringBefore("signup?") + dataEndpoint.value = qrCodeUrl.substringBefore("signup?") 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/ParticipantKeyInput.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/composables/ParticipantKeyInput.kt index 33886d811..c0b1ba744 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 @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.sp 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.MoreDivider import io.redlink.more.app.android.ui.theme.MoreColors @Composable @@ -121,13 +122,10 @@ fun ParticipationKeyInput( ) } Spacer(modifier = Modifier.height(16.dp)) - Text( - text=getStringResource(id = R.string.more_or_text), - fontSize = 16.sp - ) - Spacer(modifier = Modifier.height(16.dp)) QRCodeButton(model = model) - Spacer(modifier = Modifier.height(16.dp)) + 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 80444968f..dc4f375bd 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 @@ -18,7 +18,6 @@ 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 @@ -35,7 +34,7 @@ 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.ui.theme.moreSecondary @Composable @@ -57,9 +56,8 @@ fun QRCodeButton(model: LoginViewModel) { }, modifier = Modifier .fillMaxWidth(1f) - .padding(vertical = 8.dp) .height(60.dp), - colors = ButtonDefaults.morePrimary(), + colors = ButtonDefaults.moreSecondary(), border = MoreColors.borderPrimary(true) ) { Row( 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 index d6160dc72..64a11063e 100644 --- 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 @@ -32,11 +32,14 @@ import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider 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 @@ -48,14 +51,17 @@ 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 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.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.morePrimary +import io.redlink.more.app.android.ui.theme.moreSecondary // Infos to Barcodes mit ML Kit: https://developers.google.com/ml-kit/vision/barcode-scanning/android?hl=de @@ -163,7 +169,7 @@ fun QrScannerScreen( // Camera Preview (full screen) AndroidView( factory = previewViewProvider, - modifier = Modifier.fillMaxSize() + Modifier.fillMaxSize(0.95f) ) // Overlay with center cutout (placed directly after camera view so it doesn’t cover text/buttons) @@ -179,9 +185,10 @@ fun QrScannerScreen( val left = (canvasWidth - cutoutSize.width) / 2 val top = (canvasHeight - cutoutSize.height) / 2 - // Dim everything - drawRect(color = Color(0xAA000000)) + // background color + drawRect(color = MoreColors.PrimaryLight) + // camera area drawRect( color = Color.Transparent, topLeft = Offset(left, top), @@ -194,21 +201,76 @@ fun QrScannerScreen( Box( modifier = Modifier .fillMaxWidth() - .padding(top = 48.dp) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.TopEnd + ) { + Button( + onClick = onClose, + modifier = Modifier + .height(60.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + contentColor = MoreColors.Secondary + ), + elevation = null + ) { + Text( + text = "✕", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth(0.95f) + .padding(top = 64.dp) + .padding(horizontal = 24.dp) .align(Alignment.TopCenter), - contentAlignment = Alignment.TopCenter + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = getStringResource(id = R.string.more_qr_code_button), - color = MoreColors.PrimaryLight200, - fontSize = 20.sp + + // Welcome Image + Image( + id = R.drawable.welcome_to_more, + contentDescription = getStringResource(id = R.string.more_welcome_title) ) + + Box( + modifier = Modifier.fillMaxWidth(0.95f), + 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(0.95f), + 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 + ) + } } Box( modifier = Modifier .fillMaxWidth() .padding(bottom = 48.dp) + .padding(horizontal = 24.dp) .align(Alignment.BottomCenter), contentAlignment = Alignment.BottomCenter ) { @@ -216,8 +278,9 @@ fun QrScannerScreen( onClick = onClose, modifier = Modifier .padding(vertical = 8.dp) - .height(60.dp), - colors = ButtonDefaults.morePrimary(), + .height(60.dp) + .fillMaxWidth(), + colors = ButtonDefaults.moreSecondary(), border = MoreColors.borderPrimary(true) ) { Text(getStringResource(id = R.string.more_close)) diff --git a/androidApp/src/main/res/values-de/login-strings.xml b/androidApp/src/main/res/values-de/login-strings.xml index ac499b34f..e070505e8 100644 --- a/androidApp/src/main/res/values-de/login-strings.xml +++ b/androidApp/src/main/res/values-de/login-strings.xml @@ -11,6 +11,5 @@ Scannen Sie bitte Ihren QR Code Öffnen Sie die Kamera um den QR code zu scannen oder - - + QR Code wird automatisch gescannt. \ 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 68b068c77..21b7568de 100644 --- a/androidApp/src/main/res/values/login-strings.xml +++ b/androidApp/src/main/res/values/login-strings.xml @@ -4,11 +4,12 @@ Study Endpoint Edit Study Endpoint Enter Registration Token - Enter Token manually + Enter Token Login Enter Study URL Token Error Scan QR Code Open camera to scan a QR code or + QR Code will scan automatically \ No newline at end of file diff --git a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift index c130f9901..7c82b23d4 100644 --- a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift +++ b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift @@ -24,12 +24,15 @@ struct MoreTextFieldHL: View { var capitalization: Capitalization = .normal var autoCorrectDisabled = false var textType: UITextContentType? = nil + var hlAlignment = TextAlignment.leading + var body: some View { VStack(alignment: .leading) { HStack{ Spacer() SectionHeading(sectionTitle: headerText, showAllText: true) + .multilineTextAlignment(hlAlignment) Spacer() } .padding(3) diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 47bec4099..7b2ee6ba0 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -44,14 +44,12 @@ struct LoginView: View { input: $model.token, capitalization: .uppercase, autoCorrectDisabled: true, - textType: .oneTimeCode + textType: .oneTimeCode, + hlAlignment: TextAlignment.center ) .padding(.bottom, 12) - Text(verbatim:.localize(forKey: "or", withComment: "Choose options between manually inputing Participant Key or Scanning QR Code", inTable: stringTable)) - .padding(.bottom, 12) - - MoreActionButton(backgroundColor: Color.more.primary, disabled: $disabledQRCodeButton) { + MoreActionButton(backgroundColor: Color.more.secondary, disabled: $disabledQRCodeButton) { model.showQRCodeView = true } label: { HStack { @@ -66,11 +64,14 @@ struct LoginView: View { } .padding(.bottom, 12) + + Divider() + if showTokenInput { ErrorLogin(stringTable: .constant(stringTable), disabled: .constant(model.checkTokenCount())) .environmentObject(model) } - + Spacer() .frame(maxHeight: .infinity) diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index 9b11a1cc0..de82e1474 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -48,7 +48,7 @@ class LoginViewModel: ObservableObject { } func extractValuesFromQRCode(qrCodeUrl: String) { - defaultEndpoint = qrCodeUrl.components(separatedBy: "signup?").first ?? "" + endpoint = qrCodeUrl.components(separatedBy: "signup?").first ?? "" if let tokenPart = qrCodeUrl.components(separatedBy: "token=").last, tokenPart != qrCodeUrl { From 0cdc5a162ae5c215bab4f5f94443036c5390badd Mon Sep 17 00:00:00 2001 From: iaigner Date: Tue, 10 Jun 2025 18:07:46 +0200 Subject: [PATCH 22/59] MORE-248: fix depricated function in kotlin + PR adaptations --- .../login/composables/QRCodeButton.kt | 8 +- .../activities/main/QRScannerActivity.kt | 93 ++++++++++++------- .../src/main/res/values-de/login-strings.xml | 2 + .../src/main/res/values/login-strings.xml | 4 +- .../Strings/de.lproj/LoginView.strings | 1 + .../Strings/en.lproj/LoginView.strings | 3 +- .../Views/Components/MoreTextFieldHL.swift | 2 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 44 +++++---- .../Views/Login/ScanQrCodeViewModel.swift | 18 ++-- 9 files changed, 116 insertions(+), 59 deletions(-) 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 dc4f375bd..ed8cffc82 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 @@ -25,6 +25,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons 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 @@ -40,18 +41,19 @@ import io.redlink.more.app.android.ui.theme.moreSecondary @Composable fun QRCodeButton(model: LoginViewModel) { - val context = LocalContext.current + val context = rememberUpdatedState(LocalContext.current) val qrScannerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> val data = result.data - val scanned = data?.getStringExtra("qrResult") ?: return@rememberLauncherForActivityResult + val scanned = result.data?.getStringExtra("qrResult") ?: + return@rememberLauncherForActivityResult model.extractValuesFromQRCode(scanned) } OutlinedButton( onClick = { - val intent = Intent(context, QRScannerActivity::class.java) + val intent = Intent(context.value, QRScannerActivity::class.java) qrScannerLauncher.launch(intent) }, modifier = Modifier 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 index 64a11063e..8a25278b5 100644 --- 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 @@ -21,9 +21,8 @@ import android.Manifest import android.app.Activity import android.content.Context import android.content.Intent -import android.os.Handler -import android.os.Looper 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 @@ -43,7 +42,10 @@ 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.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent @@ -51,6 +53,7 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -60,16 +63,35 @@ 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.ui.theme.MoreColors import io.redlink.more.app.android.ui.theme.moreSecondary + // Infos to Barcodes mit ML Kit: https://developers.google.com/ml-kit/vision/barcode-scanning/android?hl=de class QRScannerActivity: ComponentActivity() { + var permissionGiven = false + + var permissions = arrayOf( + Manifest.permission.CAMERA, + ) + // preview view for the camera qr code scanner private lateinit var previewView: PreviewView private val scanner = BarcodeScanning.getClient() + + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + startCamera() + } else { + permissionGiven = false + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -87,28 +109,18 @@ class QRScannerActivity: ComponentActivity() { } private fun requestCameraPermissionIfNeeded() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) - != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 123) - } else { - // Start camera only when permission granted and view is ready - // (delay to let Compose inflate first) - Handler(Looper.getMainLooper()).postDelayed({ startCamera() }, 200) + when { + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> { + startCamera() + } + ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA) -> { + } + else -> { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } } } - @Deprecated("This method has been deprecated in favor of using the Activity Result API\n which brings increased type safety via an {@link ActivityResultContract} and the prebuilt\n contracts for common intents available in\n {@link androidx.activity.result.contract.ActivityResultContracts}, provides hooks for\n testing, and allow receiving results in separate, testable classes independent from your\n activity. Use\n {@link #registerForActivityResult(ActivityResultContract, ActivityResultCallback)} passing\n in a {@link RequestMultiplePermissions} object for the {@link ActivityResultContract} and\n handling the result in the {@link ActivityResultCallback#onActivityResult(Object) callback}.") - override fun onRequestPermissionsResult( - requestCode: Int, permissions: Array, grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == 123 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { - startCamera() - } else { - finish() - } - } // function converts camera image into an inputimage @OptIn(ExperimentalGetImage::class) @@ -161,6 +173,9 @@ fun QrScannerScreen( modifier: Modifier = Modifier, previewViewProvider: (Context) -> PreviewView, ) { + val currentContext = rememberUpdatedState(LocalContext.current) + var permissionGiven = rememberUpdatedState(currentContext.value.checkSelfPermission(Manifest.permission.CAMERA) ) + Box( modifier = modifier .fillMaxSize() @@ -179,11 +194,11 @@ fun QrScannerScreen( .drawWithContent { drawContent() - val cutoutSize = Size(300.dp.toPx(), 300.dp.toPx()) + 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) / 2 + val top = (canvasHeight - cutoutSize.height) / 3 *2 // background color drawRect(color = MoreColors.PrimaryLight) @@ -201,7 +216,7 @@ fun QrScannerScreen( Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), + .padding(horizontal = 8.dp), contentAlignment = Alignment.TopEnd ) { Button( @@ -214,11 +229,10 @@ fun QrScannerScreen( ), elevation = null ) { - Text( - text = "✕", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 16.dp) + IconInline( + icon = Icons.Rounded.Close, + color = MoreColors.Secondary, + contentDescription = getStringResource(id = R.string.more_close_icon) ) } } @@ -240,7 +254,7 @@ fun QrScannerScreen( ) Box( - modifier = Modifier.fillMaxWidth(0.95f), + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter ) { Text( @@ -253,7 +267,7 @@ fun QrScannerScreen( } Box( - modifier = Modifier.fillMaxWidth(0.95f), + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter ) { Text( @@ -264,6 +278,23 @@ fun QrScannerScreen( textAlign = TextAlign.Center ) } + + if (permissionGiven.value == -1) { + 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( diff --git a/androidApp/src/main/res/values-de/login-strings.xml b/androidApp/src/main/res/values-de/login-strings.xml index e070505e8..b70c0ca83 100644 --- a/androidApp/src/main/res/values-de/login-strings.xml +++ b/androidApp/src/main/res/values-de/login-strings.xml @@ -12,4 +12,6 @@ Ö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/login-strings.xml b/androidApp/src/main/res/values/login-strings.xml index 21b7568de..c58200f5f 100644 --- a/androidApp/src/main/res/values/login-strings.xml +++ b/androidApp/src/main/res/values/login-strings.xml @@ -11,5 +11,7 @@ Scan QR Code Open camera to scan a QR code or - QR Code will scan automatically + 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/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings index c91eca550..4c4e10d94 100644 --- a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings +++ b/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings @@ -28,3 +28,4 @@ "permission_needed" = "Kamerazugriff erforderlich"; "open_settings" = "Einstellungen öffnen"; "cancel" = "Abbrechen"; +"provide_camera_access" = "Um den QR Code zu scannen benötigen wir Zugriff zu deiner Kamera."; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings b/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings index bdef232c1..800342607 100644 --- a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings +++ b/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings @@ -22,9 +22,10 @@ "enter_study_url" = "Enter study URL"; "scan_qr_code" = "Scan QR Code"; -"scan_start_automatic" = "Scanning will start automatically."; +"scan_start_automatic" = "QR Code will be scanned automatically."; "camera_to_scan_qr_code" = "Open camera to scan a QR Code"; "permission_needed" = "Permission needed"; "open_settings" = "Open settings"; "cancel" = "Cancel"; +"provide_camera_access" = "Camera permissions needed to scan QR code."; diff --git a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift index 7c82b23d4..dd056d1bd 100644 --- a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift +++ b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift @@ -24,7 +24,7 @@ struct MoreTextFieldHL: View { var capitalization: Capitalization = .normal var autoCorrectDisabled = false var textType: UITextContentType? = nil - var hlAlignment = TextAlignment.leading + var hlAlignment: TextAlignment = .leading var body: some View { VStack(alignment: .leading) { diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 6c028b213..9ec064208 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -47,27 +47,39 @@ struct ScanQRCodeView: View { Spacer(minLength: 0) /// Scanner Frame - GeometryReader { - let size = $0.size - - QRCodeCameraView(frameSize: CGSize(width: size.width, height: size.height), cameraSession: $viewModel.cameraSession) - .onAppear() { - viewModel.setupCamera() + 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) + } - 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)) + if(!viewModel.showError) { + HStack(spacing: 8) { + Text(verbatim:.localize(forKey: "provide_camera_access", withComment: "Access to camera wasn't granted to scan QR Code.", inTable: stringTable)) + .foregroundColor(.more.important) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) } } - .frame(width: size.width, height: size.width) - .frame(maxWidth: .infinity, maxHeight: .infinity) } - + Spacer(minLength: 45) } .padding(15) diff --git a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift index 98c7bccd4..6a8cb52c9 100644 --- a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift +++ b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift @@ -56,12 +56,13 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { if granted { self.cameraPermission = .approved self.setupCamera() + self.showError = false } else { - self.presentError("Please Provide Access to your Camera") + self.showError = true } } case .denied, .restricted: - presentError("Please Provide Access to your Camera") + self.showError = true default: break } @@ -70,7 +71,7 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { func setupCamera() { guard let device = AVCaptureDevice.default(for: .video) else { - presentError("No camera available.") + self.showError = true return } @@ -89,14 +90,19 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { cameraSession.startRunning() } catch { - presentError(error.localizedDescription) + presentError(errorDescription: error.localizedDescription) } } - func presentError(_ message: String) { + func presentError(errorDescription: String?) { + if (errorDescription != nil) { + print("Error when setting up camera: ") + print(errorDescription! as String) + } + DispatchQueue.main.async { - self.errorMessage = message self.showError = true + } } } From 932af95159abe760694676cdb22f448bc87791be Mon Sep 17 00:00:00 2001 From: iaigner Date: Tue, 10 Jun 2025 18:10:10 +0200 Subject: [PATCH 23/59] MORE-248: show error when error occurs --- iosApp/iosApp/Views/Login/ScanQRCodeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 9ec064208..effab8610 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -69,7 +69,7 @@ struct ScanQRCodeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - if(!viewModel.showError) { + if(viewModel.showError) { HStack(spacing: 8) { Text(verbatim:.localize(forKey: "provide_camera_access", withComment: "Access to camera wasn't granted to scan QR Code.", inTable: stringTable)) .foregroundColor(.more.important) From be505537d9d5ab25c58e70f634a29ac9ced2ca45 Mon Sep 17 00:00:00 2001 From: iaigner Date: Wed, 11 Jun 2025 09:49:40 +0200 Subject: [PATCH 24/59] MORE-248: readme update for android local development --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 71b65a3e9..3bce6c1ec 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,29 @@ Because **More App Multiplatform** supports iOS and Android, you can choose whic 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 From a8105a7ceb8e4fcc85b066aeb3b1acb537320c38 Mon Sep 17 00:00:00 2001 From: iaigner Date: Wed, 11 Jun 2025 11:43:28 +0200 Subject: [PATCH 25/59] MORE-248: pr adaptions --- .../more/app/android/activities/main/QRScannerActivity.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 index 8a25278b5..8f4f34186 100644 --- 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 @@ -73,10 +73,6 @@ import io.redlink.more.app.android.ui.theme.moreSecondary class QRScannerActivity: ComponentActivity() { var permissionGiven = false - var permissions = arrayOf( - Manifest.permission.CAMERA, - ) - // preview view for the camera qr code scanner private lateinit var previewView: PreviewView private val scanner = BarcodeScanning.getClient() @@ -174,7 +170,7 @@ fun QrScannerScreen( previewViewProvider: (Context) -> PreviewView, ) { val currentContext = rememberUpdatedState(LocalContext.current) - var permissionGiven = rememberUpdatedState(currentContext.value.checkSelfPermission(Manifest.permission.CAMERA) ) + val permissionGiven = rememberUpdatedState(currentContext.value.checkSelfPermission(Manifest.permission.CAMERA) ) Box( modifier = modifier @@ -198,7 +194,7 @@ fun QrScannerScreen( val canvasWidth = size.width val canvasHeight = size.height val left = (canvasWidth - cutoutSize.width) / 2 - val top = (canvasHeight - cutoutSize.height) / 3 *2 + val top = ((canvasHeight - cutoutSize.height) / 3) * 2 // background color drawRect(color = MoreColors.PrimaryLight) From 0065a856b790b3efbbf478fe98ba2666078ba4a8 Mon Sep 17 00:00:00 2001 From: iaigner Date: Wed, 11 Jun 2025 14:02:14 +0200 Subject: [PATCH 26/59] MORE-248: fix reactivity of permissionGiven in QRScannerActivity --- .../activities/main/QRScannerActivity.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 index 8f4f34186..fb95170c7 100644 --- 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 @@ -45,7 +45,8 @@ 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.rememberUpdatedState +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 @@ -53,7 +54,6 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -71,20 +71,19 @@ import io.redlink.more.app.android.ui.theme.moreSecondary // Infos to Barcodes mit ML Kit: https://developers.google.com/ml-kit/vision/barcode-scanning/android?hl=de class QRScannerActivity: ComponentActivity() { - var permissionGiven = false - // 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() - } else { - permissionGiven = false } } @@ -99,6 +98,7 @@ class QRScannerActivity: ComponentActivity() { previewView = PreviewView(it) previewView }, + permissionGiven = permissionGiven, onClose = { finish() } ) } @@ -168,10 +168,8 @@ fun QrScannerScreen( onClose: () -> Unit, modifier: Modifier = Modifier, previewViewProvider: (Context) -> PreviewView, + permissionGiven: MutableState ) { - val currentContext = rememberUpdatedState(LocalContext.current) - val permissionGiven = rememberUpdatedState(currentContext.value.checkSelfPermission(Manifest.permission.CAMERA) ) - Box( modifier = modifier .fillMaxSize() @@ -275,7 +273,7 @@ fun QrScannerScreen( ) } - if (permissionGiven.value == -1) { + if (!permissionGiven.value) { Box( modifier = Modifier.fillMaxWidth() .padding(vertical = 30.dp) From 15050230d552d84bf0fccbca1fbbc2a2104ba12b Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Wed, 11 Jun 2025 14:27:21 +0200 Subject: [PATCH 27/59] Updated to version 4.0.25 --- androidApp/build.gradle.kts | 4 ++-- androidApp/src/main/AndroidManifest.xml | 3 ++- iosApp/iosApp.xcodeproj/project.pbxproj | 4 ++-- iosApp/iosApp/Info.plist | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 5efcc6144..1ca5378cb 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 34 - versionCode = 24 - versionName = "4.0.24" + versionCode = 25 + versionName = "4.0.25" } buildFeatures { compose = true diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 48eafd40f..32c5c382f 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ - + CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.24 + 4.0.25 CFBundleURLTypes @@ -36,7 +36,7 @@ CFBundleVersion - 4.0.24 + 4.0.25 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption From d96a4953855c0c9b2dc9079dccb1b4ea533a593e Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 12 Jun 2025 14:31:48 +0200 Subject: [PATCH 28/59] Hotfix for the app crash --- README.md | 191 +++++++++++++----- androidApp/build.gradle.kts | 4 +- iosApp/iosApp.xcodeproj/project.pbxproj | 4 +- iosApp/iosApp/Info.plist | 4 +- .../database/RealmDatabase.kt | 2 +- 5 files changed, 147 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 3bce6c1ec..b91c58811 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,13 +13,16 @@ 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) @@ -27,29 +32,40 @@ It's recommended that you install the latest stable versions for compatibility a Also, it's recommended to install the following plugins in the Android Studio directly: -[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). +[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. 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: ```sh ./gradlew build ``` @@ -58,99 +74,142 @@ The following is an instruction on how to install and configure the project on y ### 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: + +* 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._ +_You may ignore KDoctor's warnings regarding the CocoaPods installation. In this project, we use +Swift Package Manager and not CocoaPods._ + ## 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! 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). + +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: +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. + +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 { @@ -164,10 +223,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: @@ -175,33 +235,62 @@ 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/more/more_app_mutliplatform/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 +Ludwig Boltzmann Institute for Digital Health and +Prevention - [more-health.at](https://more-health.at/) - more@dhp.lbg.ac.at diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 1ca5378cb..75805af29 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 34 - versionCode = 25 - versionName = "4.0.25" + versionCode = 26 + versionName = "4.0.26" } buildFeatures { compose = true diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ad46c6061..6b91dc41f 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -1815,7 +1815,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.25; + MARKETING_VERSION = 4.0.26; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1855,7 +1855,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.0.25; + MARKETING_VERSION = 4.0.26; OTHER_LDFLAGS = ( "$(inherited)", "-framework", diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index a0cc23dd6..fc184701f 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.25 + 4.0.26 CFBundleURLTypes @@ -36,7 +36,7 @@ CFBundleVersion - 4.0.25 + 4.0.26 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption 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 index 4f172b502..b6c5baa0d 100644 --- 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 @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.transform import kotlinx.coroutines.sync.Mutex import kotlin.reflect.KClass -private const val DB_SCHEMA_VERSION: Long = 4 +private const val DB_SCHEMA_VERSION: Long = 5 object RealmDatabase { var realm: Realm? = null From 992e4be2f2753da20b31d789a87fe9d3e1bbe604 Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:43:28 +0200 Subject: [PATCH 29/59] 238 automatic deployment (#295) Implemented Fastlane for iOS and Android, which is built on push and deployed on tag on the branches main and develop --- .github/workflows/build-release.yml | 93 ------- .github/workflows/build.yml | 68 ++++++ .github/workflows/deploy-beta.yml | 73 ++++++ .github/workflows/deploy-release.yml | 46 ---- .gitignore | 7 + .../.shared-appleMain.cinteropLibraries | 3 - .../.shared-appleTest.cinteropLibraries | 3 - .../.shared-iosMain.cinteropLibraries | 3 - .../.shared-iosTest.cinteropLibraries | 3 - .../.shared-nativeMain.cinteropLibraries | 3 - .../.shared-nativeTest.cinteropLibraries | 3 - ...tor-utils-cinterop-threadUtils-TE4abA.klib | Bin 1747 -> 0 bytes ...interop-cinterop-realm_wrapper-nt9oMQ.klib | Bin 50852 -> 0 bytes ...linx_atomicfu-cinterop-interop-wFq7cg.klib | Bin 2433 -> 0 bytes ...ps-konnection-1.4.1-commonMain-1qj7aA.klib | Bin 4432 -> 0 bytes ...ev.tmapps-konnection-1.4.1-ios-BePiUw.klib | Bin 6016 -> 0 bytes ...aakira-napier-2.7.1-commonMain-UpJokw.klib | Bin 5020 -> 0 bytes ...aakira-napier-2.7.1-darwinMain-EnitAw.klib | Bin 5121 -> 0 bytes ...-client-auth-2.3.12-commonMain-tgNnVQ.klib | Bin 7703 -> 0 bytes ...-negotiation-2.3.12-commonMain-jI37cw.klib | Bin 4947 -> 0 bytes ...t-negotiation-2.3.12-posixMain-jI37cw.klib | Bin 4277 -> 0 bytes ...-client-core-2.3.12-commonMain-FU-9lg.klib | Bin 48874 -> 0 bytes ...r-client-core-2.3.12-posixMain-FU-9lg.klib | Bin 8510 -> 0 bytes ...lient-darwin-2.3.12-darwinMain-CnRCQQ.klib | Bin 16875 -> 0 bytes ...ient-logging-2.3.12-commonMain-grxlVw.klib | Bin 6044 -> 0 bytes ...lient-logging-2.3.12-posixMain-grxlVw.klib | Bin 4141 -> 0 bytes ...-ktor-events-2.3.12-commonMain-Q6-xkw.klib | Bin 3168 -> 0 bytes ...or-ktor-http-2.3.12-commonMain-W5sIeA.klib | Bin 29090 -> 0 bytes ...tor-ktor-http-2.3.12-posixMain-W5sIeA.klib | Bin 3169 -> 0 bytes ...ktor-ktor-io-2.3.12-commonMain-3YsjwQ.klib | Bin 35033 -> 0 bytes ...ktor-ktor-io-2.3.12-darwinMain-sbySvA.klib | Bin 6899 -> 0 bytes ....ktor-ktor-io-2.3.12-posixMain-3YsjwQ.klib | Bin 15536 -> 0 bytes ...erialization-2.3.12-commonMain-NxrIfg.klib | Bin 3940 -> 0 bytes ...tion-kotlinx-2.3.12-commonMain-s53Slg.klib | Bin 4573 -> 0 bytes ...ation-kotlinx-2.3.12-posixMain-s53Slg.klib | Bin 3862 -> 0 bytes ...kotlinx-json-2.3.12-commonMain-sJ8SDA.klib | Bin 4678 -> 0 bytes ...-kotlinx-json-2.3.12-posixMain-sJ8SDA.klib | Bin 4479 -> 0 bytes ...r-ktor-utils-2.3.12-commonMain-kEcFvw.klib | Bin 28672 -> 0 bytes ...r-ktor-utils-2.3.12-darwinMain-TE4abA.klib | Bin 4740 -> 0 bytes ...ktor-ktor-utils-2.3.12-nixMain-kEcFvw.klib | Bin 3785 -> 0 bytes ...or-ktor-utils-2.3.12-posixMain-kEcFvw.klib | Bin 14337 -> 0 bytes ...erialization-2.3.12-commonMain-8xBQEg.klib | Bin 3142 -> 0 bytes ...r-websockets-2.3.12-commonMain-8-9-_g.klib | Bin 7868 -> 0 bytes ...or-websockets-2.3.12-posixMain-8-9-_g.klib | Bin 4264 -> 0 bytes ...lin-cinterop-1.13.0-commonMain-zZQVnw.klib | Bin 27315 -> 0 bytes ...n-cinterop-1.13.0-nativeDarwin-nt9oMQ.klib | Bin 24770 -> 0 bytes ...library-base-1.13.0-commonMain-0LVaVg.klib | Bin 68251 -> 0 bytes ...brary-base-1.13.0-nativeDarwin-RwRULA.klib | Bin 6132 -> 0 bytes ...-library-base-1.13.0-nativeIos-RwRULA.klib | Bin 4283 -> 0 bytes ...kotlin-stdlib-2.0.0-commonMain-2bbUHA.klib | Bin 140310 -> 0 bytes ...st-2.0.0-annotationsCommonMain-24eTFQ.klib | Bin 1430 -> 0 bytes ...est-2.0.0-assertionsCommonMain-24eTFQ.klib | Bin 5850 -> 0 bytes ...inx-atomicfu-0.23.1-commonMain-wFq7cg.klib | Bin 6101 -> 0 bytes ...inx-atomicfu-0.23.1-nativeMain-wFq7cg.klib | Bin 5817 -> 0 bytes ...routines-core-1.8.1-commonMain-XanZ2w.klib | Bin 64456 -> 0 bytes ...ines-core-1.8.1-concurrentMain-XanZ2w.klib | Bin 5577 -> 0 bytes ...es-core-1.8.1-nativeDarwinMain-sy5nKg.klib | Bin 4097 -> 0 bytes ...routines-core-1.8.1-nativeMain-XanZ2w.klib | Bin 10565 -> 0 bytes ...linx-datetime-0.4.0-commonMain-k5yUlQ.klib | Bin 10627 -> 0 bytes ...linx-datetime-0.4.0-darwinMain-bPkWaQ.klib | Bin 4261 -> 0 bytes ...linx-datetime-0.4.0-nativeMain-k5yUlQ.klib | Bin 8926 -> 0 bytes ...lization-core-1.7.1-commonMain-8gwKMQ.klib | Bin 49097 -> 0 bytes ...lization-core-1.7.1-nativeMain-8gwKMQ.klib | Bin 3476 -> 0 bytes ...lization-json-1.7.1-commonMain-Ii3AMw.klib | Bin 36342 -> 0 bytes ...lization-json-1.7.1-nativeMain-Ii3AMw.klib | Bin 4949 -> 0 bytes ...b.kbson-kbson-0.3.0-commonMain-eKbbTg.klib | Bin 24269 -> 0 bytes ...godb.kbson-kbson-0.3.0-iosMain-l8A9Sw.klib | Bin 4077 -> 0 bytes README.md | 160 +++++++++++- androidApp/Gemfile | 3 + androidApp/Gemfile.lock | 227 +++++++++++++++++ androidApp/build.gradle.kts | 97 ++++++++ androidApp/fastlane/Appfile | 1 + androidApp/fastlane/Fastfile | 231 ++++++++++++++++++ androidApp/fastlane/README.md | 48 ++++ androidApp/fastlane/report.xml | 30 +++ androidApp/proguard-rules.pro | 49 ++++ iosApp/Gemfile | 3 + iosApp/Gemfile.lock | 227 +++++++++++++++++ iosApp/fastlane/Appfile | 9 + iosApp/fastlane/Fastfile | 123 ++++++++++ iosApp/fastlane/Matchfile | 8 + iosApp/fastlane/README.md | 48 ++++ iosApp/fastlane/report.xml | 33 +++ iosApp/fastlane/test_output/report.html | 126 ++++++++++ iosApp/fastlane/test_output/report.junit | 2 + iosApp/iosApp.xcodeproj/project.pbxproj | 44 ++-- iosApp/iosApp/Info.plist | 2 +- 87 files changed, 1585 insertions(+), 191 deletions(-) delete mode 100644 .github/workflows/build-release.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/deploy-beta.yml delete mode 100644 .github/workflows/deploy-release.yml delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleMain.cinteropLibraries delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-appleTest.cinteropLibraries delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosMain.cinteropLibraries delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-iosTest.cinteropLibraries delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeMain.cinteropLibraries delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/.shared-nativeTest.cinteropLibraries delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.ktor-ktor-utils-2.3.12-iosMain-cinterop/io.ktor_ktor-utils-cinterop-threadUtils-TE4abA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-cinterop/io.realm.kotlin_cinterop-cinterop-realm_wrapper-nt9oMQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-wFq7cg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-commonMain-1qj7aA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/dev.tmapps-konnection-1.4.1-ios-BePiUw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-commonMain-UpJokw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.aakira-napier-2.7.1-darwinMain-EnitAw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-auth-2.3.12-commonMain-tgNnVQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-commonMain-jI37cw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-content-negotiation-2.3.12-posixMain-jI37cw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-commonMain-FU-9lg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-core-2.3.12-posixMain-FU-9lg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-darwin-2.3.12-darwinMain-CnRCQQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-commonMain-grxlVw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-client-logging-2.3.12-posixMain-grxlVw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-events-2.3.12-commonMain-Q6-xkw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-commonMain-W5sIeA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-http-2.3.12-posixMain-W5sIeA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-commonMain-3YsjwQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-darwinMain-sbySvA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-io-2.3.12-posixMain-3YsjwQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-2.3.12-commonMain-NxrIfg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-commonMain-s53Slg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-2.3.12-posixMain-s53Slg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-commonMain-sJ8SDA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-serialization-kotlinx-json-2.3.12-posixMain-sJ8SDA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-commonMain-kEcFvw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-darwinMain-TE4abA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-nixMain-kEcFvw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-utils-2.3.12-posixMain-kEcFvw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websocket-serialization-2.3.12-commonMain-8xBQEg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-commonMain-8-9-_g.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.ktor-ktor-websockets-2.3.12-posixMain-8-9-_g.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-commonMain-zZQVnw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-cinterop-1.13.0-nativeDarwin-nt9oMQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-commonMain-0LVaVg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeDarwin-RwRULA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/io.realm.kotlin-library-base-1.13.0-nativeIos-RwRULA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.0.0-commonMain-2bbUHA.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-annotationsCommonMain-24eTFQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.0.0-assertionsCommonMain-24eTFQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-commonMain-XanZ2w.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-concurrentMain-XanZ2w.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeDarwinMain-sy5nKg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.8.1-nativeMain-XanZ2w.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-commonMain-k5yUlQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-darwinMain-bPkWaQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.4.0-nativeMain-k5yUlQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-commonMain-8gwKMQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.7.1-nativeMain-8gwKMQ.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-commonMain-Ii3AMw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.7.1-nativeMain-Ii3AMw.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-commonMain-eKbbTg.klib delete mode 100644 .kotlin/metadata/kotlinTransformedMetadataLibraries/org.mongodb.kbson-kbson-0.3.0-iosMain-l8A9Sw.klib create mode 100644 androidApp/Gemfile create mode 100644 androidApp/Gemfile.lock create mode 100644 androidApp/fastlane/Appfile create mode 100644 androidApp/fastlane/Fastfile create mode 100644 androidApp/fastlane/README.md create mode 100644 androidApp/fastlane/report.xml create mode 100644 androidApp/proguard-rules.pro create mode 100644 iosApp/Gemfile create mode 100644 iosApp/Gemfile.lock create mode 100644 iosApp/fastlane/Appfile create mode 100644 iosApp/fastlane/Fastfile create mode 100644 iosApp/fastlane/Matchfile create mode 100644 iosApp/fastlane/README.md create mode 100644 iosApp/fastlane/report.xml create mode 100644 iosApp/fastlane/test_output/report.html create mode 100644 iosApp/fastlane/test_output/report.junit 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..4ee175590 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,68 @@ +name: Build apps +on: + push: + branches: + - '**' + tags-ignore: + - '**' + pull_request: + branches: + - '**' + +jobs: + ios: + name: iOS + runs-on: macos-15 + 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 }} + + 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 }} + defaults: + run: + working-directory: iosApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set Xcode Version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.4 + + - name: Build iOS app + run: fastlane build + + android: + name: Android + runs-on: macos-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 }} + defaults: + run: + working-directory: androidApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - 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..1f761b0e7 --- /dev/null +++ b/.github/workflows/deploy-beta.yml @@ -0,0 +1,73 @@ +name: Deploy beta release +on: + push: + branches: + - develop + - main + tags: + - '[0-9]+.[0-9]+.[0-9]+' + +jobs: + ios: + name: iOS + runs-on: macos-15 + 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 }} + + 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 }} + defaults: + run: + working-directory: iosApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set Xcode Version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.4 + + - 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: macos-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 }} + defaults: + run: + working-directory: androidApp + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - 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 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..9f06134df 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,10 @@ local.properties xcuserdata shared/src/commonMain/kotlin/generated /androidApp/release/ +*.ipa +.env +*.mobileprovision +*.cer* +*.p12 +*.zip +*.p8 \ 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 ca651ddbb45d3465629ac6d40c0fd15f89900385..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1747 zcmWIWW@Zs#VBp|jNQu4@!2kqIAOZ+D7&sYHQqvMkb4v6>Ls%IY-+V1jV`d0I(Qtn8 zs#olc3=EHfSO7^wZem_$T553#T;tq{zTV7+0&VYqirQ*zS*YtRs>trhsz2|_L&N|6$C@-%x(wYZcE;4X!4c$%1+-_$Gukm}}e&IHS8E-gNR`y~+AhL1`>e&rlMH~mZGc!Do6g+)l+v73c!ThBa+_hA7rsdIH zCudfwu5|s$x=U5kb!DgKLIp=_RW@BV?K~Ahv{*)Vrz$qv3KEmE6M;cel2MeJm=ao& znNy70?UgrNFHQocmlUAa&4@M40NHH4?7UpK*J1+>`rURA=`o+Z_4A||({*!o-J8}INzas|G@k&<`&=1gny}=D{}Glxji?x8!v8U zXM42E!0PnE>PgZ9rQdAh*Eh_6K9jM2XWZ0}=2K&4d#P&fc>9XC!gJc}Em~e5mMw36 z`e16*%r@XJ@vvrNZke8dC$T z_J#Zmn!NdUWnG1ty;3 zvv#t~HyRh!FEz;6J$JUx^&sZMd>reW{VpU;pODDEDQ;8*)BH7?Tf5$IwC&GuNV< zgPv;<=B&hK4q6^YHx1cUpj?Rn;y}k@xC(C`MK>Egk0Q)YCDv@*ITqbAWRHMS2m*w# aAUuL4y##o(vVjz{1EC(!UHw4i3=9CidRUSG 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 729c60ea88f7b7cf4f70ffbedf33c53a4638edde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50852 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 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 52e4117684d23ffcf1d79933b51a31ca2d920826..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4432 zcmbtY2{@E%8~(<=W{oU`vV<{_CC+clo*|T_$YF-zXc&wo$9hV(tYzQQA#0L|lEf6E zm_b@ZQAkci%ds@n|IPpZ3?I|-pZB`n@0#no=DF|tdFT0_=l;eLNzcF!04yv30PF`h z0MPyC3f=-(0auJ0D!|i!kBv1W0P_i)=cNZM*99aDyLLE%0iodLUK8Mj!g;u1{QOq~ z&AYU={2|Vv`?AShi6J(U-FTMcBHBf`c(=}ZCxOJb4YUoeA(+h$>13D{1ho zQRV4@!@8pUQMB`s{3Tr86pTIrZo$nWy}VrmJTa?jjq+aaB$6H=%@Lhlq<9M=sk@u> z(Y&jxwiaEDK6pT(_7xp(uv9}FBV(IwAxr%Zz07n?TU1kF|Es0En*Ktx43kWX)j<;@ z*f%oRzL^dmlnWN+j&X9u1k%oiq2;re{y9_Z(ckgs z5(jKegqTl<_i3rMa;$?2|9~5dEkd(e(b#_dpDP5>Ng3Zotfn9yv;!T5l zlD6+i%e&*3f=VX+Qy z;r{eI^_Qcb9lx$p(VHBvG37K|oW)HkRbAsy*PAp@POuD)J z>y;U?IU7Zm=E%R@U#3>iYidq?x>G$k$=ozs{q^I+DzYG5WEwCib*fd6haNsCUSh}V z3s;xM5HOX6+^HIg4}_n5YAAi5RIzMXX46@+Kuk?G2tDap?iVu^R3j!`!0H`0^iZxq z{#r*^BA3czRqg(#v!*9Z9llD0pc~7^vZosn(zcupHFb{4{HKgc-KJpK-V$G*h0@y) z1nrFog6`_F3tBb(X?1$F<*K>%;KiXNBGDBgf#XX}w{M7Aeq!%YhgrO0MSS9ro+oA< zJ6>d$B$?zr)*6n~JuCYRR%9jM2Ai`i+L#CmCo{n2GFvH^mtuq_1LpmSS5 zp@hY{K`#A(#dK$V*Uk`;F+SDLrNL45obeT%@~YYT+H<`Ysl!)=2@iLvS6uA{s*l}O z7OAqnRU?yosCQs!k!^ZP!?h@dhe&?KCKG|3p!YRy>vZ(himH7shn<+dgMSq|9{T*& zXrW&Jk+ghAvT!F+{_yE>#_o1o@3_m)c^#UiUU#?d7)TxF5R@Pt7pZ?Aj~W)7(>>z& zR3DWnTT#)xi&;qnzD<#y{a};X z0{g$7P$gPVs9{pGDgA!Y)APe#h@z9K+$_vncQ-?nBl=luMhJ|DVz*i1|JZ*I*0Lp#w?laz#xgq-MYKOro;}NVXfdy z5-)$NG&tZp6&!!(;FG0stv3DnzZAxDmrs{CULQ_w;nuHqz$?MM#W`XS**sXaI_-UU zc0ZVnoL~0QEW^L89fbl*6Dk*@wRR*gG1xff?zE`QanInJs2hs?xO_L$5*z+Nq9j&y zkui*p+%AVzjj-)*?%%WiAO`=9A;6_T3M~VFmwIq`oBR_7 zMWJLnK?H5iKBeqJ3 z!y|cR@Yni2^9FLeehv4U{-6{j(=>~XxH3PA3}O%?rk`6pc*(|d>$G@=A-Sl}mym7Z za5lkVQa$6OW~MV|AeZxE&`{$c_^8!h{f1vi3Q8|-N0++TYV~+v`~@8QX8wqUsj0=5 zHy%iN7kgyHIsU1+zWh#hd!F*iTK(Bpzu0|NxeJe$yA2fjvdZi}W7~t7hwb}Sa&Uqu zel?Tjo~1zLp#F%F#5OGHkwErUPcNV0B{hj{jh-lB|Q}Nv6TfiFT0QaVTWgU#7 z)4IwTUE_tmW!8At=%dtN0Nw=ysdyM2KS0}ZQ=T&F+F-yfk?X^u2AmqI1b$oz-(bg~ zFqE*hj+qK`Zw<^w{|v=}`e!N*41BIvWQhC zr0-#FbjVO}3fPKZN{LaI0(g5v`fCJlFvw77s6nPe2mc`Srgj-hLxEk%fMSfPYx2iv zHnqu67N||8vg`-V^ADWUS{9n78OjPB{Z!Ubrk`e|X#}9G6qc0}rHoDL+77;S|6Hz{ z4N)i`bcj;YFDN6xzC}pwGVx3Tfm}XbRMd jUP|+(E;V+l-c4G$C6W=W8p_`u+(0Mzy5|di9RU6fKUly6 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 8b847fe826de636d3487b6b41081ea82b6c4d41b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6016 zcmb`L2{_dI_s7S+WE%}JLPRKgdy0DzGZuo5(I z4nQz!;s?PP0ZafW%-O*gfs(N>rvp&cjd?rM08F+xaP)VYqY3}~g79#VIk-D`z@1?} zs2`5D-Q~ zc4U9nE&~s2~hBnjVuMAs&Ns6$I z#gP`i{kPD-LCcnJ;uC?OjqbVX-V;{#p_yK{^wgZ+weU8hSK5$@t$Mw$HCSZfbkR0> z^wtp>7ix}F;+dzJ95o+A-|!EvOzZ>eve9KL!fjm)XB=oNAkH?gRhQ2O_&w|DOth%BxUlwewmuVXe`n@za; ze6)yu+S3(56~Y4+G^(8pbb6p$(`fA~tfn~KIBOC1MYmV8AT53$tkN)d`AvP_xv%h} zm)($SG3B)rPn|OlJr5h6J2&#rqLb!E0%p|q<4Kc!7R|mI4j~ zVsp9qxT&?m=$=J`lvHx=J%N0+;g^wZHLs3vrkZD{0I-K!3w2sPicY3O9TgEwcurlD z=bLulp|UHeg8yM;4_|d*HdwIo+fs3N4*E&LgRoAT13o8uT$t}1kuJVJ^k-EO=Ds_h z+f9Xt{>-7ex0x18qkbAiR40brDx#9-#i$9led~3{>igKYt{i#) zY=WdJychC^J^c5xm;N<^Gm$mPkCzVQ&5oz@=XLDQ1id*gU|6ZCs%eLpr1!CB+2)3?+P)J)q379D5}Vn0;;I55p?Wrwvu50a zuP-dSf|U80c{_L(bH?zOJ7a2$HP!eY8G;2P5ysX@>+RCdngdE7^}(MSMdBMyAHg#> zutK7P7|Y<8xhRH{IV_GwCA_L~wv##fF_7u~QUe5O^##^J8{oomjv*K$nT=2U8Oa4k`S@dFLW)@2J8vu>?}Pb z;c*=oGe1hn>z|`xz!t>2R$yvXwej6$83W~cIeNnmwYKkmCmBGeJJDXA9{%g1)yNQC z?TuB-IIDVz`TZI>8%)ZWcP`;d;8vw_Y)D3*+bJq5WowUae-$9qlE;T_L2EpBxvQvF z+wq$*mw7UEw*in2%WS%UNmlsaoZJ7M|_}FOs=61=a2|3E% zTJO4(GhMR}qlIw?%dbkN7>j;$k^kgVG5zvDld4`F=q-Fv(Rv?L=R#@z{Xb?tJpDr; zx;9|ZWcteCDQd{g#p|;lV^TBazr&RkQ;OAO=W3IWx;-c^a_4ZAguf^*R=m5A`IY`c zv+yhHXL9i~ZaU=&scMB)35itJ5Mag{+ih>`n{n|nQ<44AvJk_`8j~RomvL55Vk$2( zA@^l|8r|=uX%<0h%JvWO`RNjDNj5`EU9)-xO4o{R`q5~_R{S;I0P2p`*-*tW}NoRiIiD1?zZ)smSJFN z)@;aH>RYjZE1mhDgOi=Ew^dFA;j&*Z2jRx7^$SN#G1q}(Qi-VUl^}2M`Fa_#hLuwA ztGv2l9>JOT))yvN*CcI?vzINtwWXV{bE@B5(ARZ%S7yErPWYgb?rO7oL)|Vvo6q}# z6Avc3D=TFGN{f>fqs*5c_dZ08hA5tHeb}okKz-zWN5xuA2ZP&q^*sRzaH}Hz(%X|W zQe6}FLyn7Pm0!klFPh@d@`sh2DlsYlMzvLIs#cyKJ4I;b{)FeiPMy}>6Y7hA{W$AR zCRgd}OAQ=*X(%Z!Ue(dl+j+)N`ej!yy0=TF>ak;)BYlT8Qb+=*Q?|m2(#s{kq+;f z6WqfI;R}U9e~raS#`1>wc=~!f!6;UjiW&si{W5^KSR~taHDGdPt2^b@0LfL3@av}g zwu=Hv4AG5zJ||(8$S~Wd^}RU6X`N(Jgp>bfIzKquH}sCPJ7pBg*_{;S>c5Y&>m*MZ zm2!q5MO~u(*R}0B(NIPuM%jEb#PdJNr0DmKO8UZ5GAXA464U2r%N*WPN1h9R$|o3>~4%003NsAA3R%*vCQq>p!iya?Jn$ 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 5a2f649cfc87b84d9b20534c02a6c2da75552c9e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5020 zcmbtY2{=^iA08&gzKoHSWUG+f#bin2rebo19=x5g|O3ob=h>BcdJKyxDipLJ6Gg#tEiiA|2RfTti?_}O^_~oM!9aY zLv()GPQ)%@;^V2JzR{lD*ZU%bi=5Tgr%ras%5nu)QM)HY(a(f!dfAprV#r^}L2!{I zPl7km6-UOco(xu0MH~Y}CjwJsrARLySAS3Zmz?_H_iE7!H3t;Wnm&2j(D3xVNqv2O zzH^>)UH$#O`ubM%Q-xYtle_S6^=Y_JzIqfqhuUa3B{5`O+Z#_qE7U%1I1u3M%bGfR zLZy?l(i60_i}Mg$NsTYgg@|*lgKCE!V`KNNaYN}@0n0cz9zUHk7um(qBiHTt41qK6cXS;OEnUk?S|4OS~p+DL8wKC2@xM zu=8z*)4|9~ZwhzOn&V-TakK{_1@vO|TyEO|sR;?L!fw|11mkp$p9HbT2H$hsy1tox z*iYfjxCfD59M49Ri`f}beSOE7qq!CkL6of`RERuxJAXAM?L6V@X^pM^;$`L=Yr=%T|HtRg+Zh1%27Ff9PQY7sc@|6YbTAa zhgO_F&#>=&CXwk8_&!oh1Vfy*%chOPh>y1aR3_WaUc*;;O6@~ zj(v=+X@ds)q|Wv`5`Oj_Sp)Zlr1AG2w=I;Z{#9d+&GKr0V(+4AbP0VNiS!WaqunrW36w86sTQS1QH#49 zP72*mnrPv%^G_r>2$a5=N|Vnt;p+3w=&H`Vg{V!Y3nd4Nv_(GH`%&8O5LPv!J#(V^ zbDU&=*1n+@GTlQvR586I`B6rEj%(H5oo$&0!KRw${Hrz?p5`}B*>Trg=DlrJPp!@X z?D{joux)mx9KrYI0~<7(-oM;m>Mxgm$k^TITE3Qf8?RGf;IsQ%rf1p*C4&_xsJNn& ziCO*FGELJ*s8~wg$l!%WnmsFD+9bDI5XM+M+HBGx@!YWU>B_`SJM#QOkN+~yzO;gM zT%aF&2b?5GmLJ@Bsw-_gJySi?N)<%G=4$^~<(u5t?d$(IfPHr6LPT~RM8C`s>#?;h z>CN$3me2bhJ&FFzDsEIYJ~lS;QfFkmHi7qYV3X9H2UQB272Z=xkS!c&@Z4ikbSgEfox26@ zwbDDpDXyGx*Jv;kN4fpjo&h2zo5#aaBU9L_x}9bzrgK;8^wf!vo;K+@$2qo(p##)A zE}`bxuW^AxpN7)XDFx%r5Gk`u!v62e8U`qfLzHOWwHkEJ|Th<44st5gy6q+9`YCB&<&TDRYbT0W}GY5jMu zL7-H5eU%p9BR{Efj(yT9`r8eTH@NH?tg(?2oj~VhrcgwY*wh+~6F(i7DwPzjSJz~t zA6t$bPO+-fk8P}tyQW?U*|$FfXO(iKc&Zp#KX}KiiJ%SdXL|*`ZrH03eZsz2xL?(} z5*f3t=V99R!JbdZ1IZ_&NVJ$M=-TsQBluaeYX5HaT_(!NSMZBulsblQfgc0GvxIVOe&&!?t&I6>}~mF@YQ#ALN=p62Z^ z;~DGTVXjil?A5zPie6}9H+&!Qb;#om#lJ37sO`Fcn4CvdBio&B!nWDHiHDx;$_Z5X z{iVSi)ooK!Wt*Gu%xKRki_6JA7MCd-eWI zOV>SbH;EXVGTqI}RbXUm`;?`Rc-1t-n-C)o83_*Z2s&tSD|V+&ojYwechC!meh~rp zY4O*iM~6MKvWbeA9k-v~mn)V57oB4*E9a6>STcg@L-DpW#rLWAK|;miZMh0T$#} zm;^|metms`76?{!FJLkT_(C=ehr%5B-$S;{uYh^Leuc@yyY4%ZTHsmHy?{Bvts0Z_ z6zm5$S8UwCoD7bI^X#?CpO1EO41EywBEo90l zH_Racl#XT8ORC0~uNYuZaPeV+x_=cEREogR45$UYjPk=AWs9I!C`K4G8(^{_5IFFQ L0}8NxT+Ch;KPrKWp-|W?9tFdp#IhJX4292)VbK&iqC z?7r8Cdh@N?ts(2fJBL5-5myR{%-E9gga@1Qe%f8Elf8N54%IwD@|WpQL{Kc}rU!%6EF!oNiK%sqNUb{i8!TBuIy*vf-P zDw+=8eH9pSffK^M`X3j~7pL?aQ&j}y{1dlRE2nX95&v!aWo&=1S5Mc`$(X^nxhW%q zrixD@ehIs)_`QWnQob<;Zv}W_0OzG3<47M)i0lhX*m;H1jWZ~spD3s#T__~%o8mwG zw2;Dov3d(K(`<-X;TH1nETf?&iWp?NyW@w09j?#37=Ii;Ps7@G$7JbDK8kNoJF24L zy01@D)k2>c=%0?eWbhqA^dLMI*&6R+EfhY|IPaj5sKQKb^I=tnsnO3LHuxxC(6O#P z-M?s8x!y7h`A*+HCn_PLCk}?Bcmbp%+JBxDF z4Vhx@p!JiB;HyDY#4xM6-}6@o9j~3OagzJh&;PtQXnQK87V~5GB}YzVQTXn#3xw<6 zu~VY1G#@;zB@BrUsp@;uHd@PF)%vbS-8Y&Q@sO6ZDfuqKm47bx*$&-~(oJTRyeWZj zG-2EXnXkW^Ijn8CfFwz1<@(pHEtz*P@e_-f*MQ^H+hphv^V6 zZR+*4A<7q_7hNA)JdOy+od_g&n%77WX<3r7p&Xx7btj*C-QG7<$d>P$uhrW2_^?w> zmi=+6*Z1YhYYEwX$d^%w@SILJ_HusxeXsaz`w_hiWFpS%;I;v^Vp6Q#-I6Jfj2F7d zAF+I`%6IB>4|dm9asMf>ZB&A5NyiT_@AwV;DM803j*ot&s}skcI5IgNAD7ohZE3@b zMB)MJgxFTnpXBXliZ5nNNxG61*3QLQ_>Hrx< zE+7Lxht-7=j;n-zX(0rR^XYozZwrFa2ILWPPSlG1s;dm6`k2#=%?(ZT>&Y3puMmbe zY%!zaROcMz7bvZ9vnR?rv&~{IpyE3nczaE@3Amwaz_oc%kYM+H-}PlL^8gh^&8H?` zZ+iW;ubCqW>nyY=b@ImI@mqyfdP+v-`UO!dE~7s9h{zBuVI{b*3&X;ZJ`67f`g0v; z^lK^+zD7BjV#PPH)uAX*tq!;>{ssgm9kq&NT05F5(PttLn#bkxNgt-TVugotQNK&o zX)97V2L?w{s1O0uS8bzzpb!M@02fL|UkruAVlv|Sv$LGhiq~qup|T{-*E|nd>)6m^ zV`Hle37V39?Gx3%+-)hJPSaXvP~7`;AS0_Gv%p%Al*sNnYwcter!xRdeU45`t1GVb ziVuh@Log&smv~tg^cO}o8q?tuoZu5VE9|Z8RvEt(Z)Y`>n62G6v2oJd3Fk4cy}nh~ zzWMvt+Kp#%^(zk8ZsTR5O)IJuDPgz{iWFh7Jmx2kvMg zo|T}-T{Mqee+%b?+Zc%RpQ=mXlxu3>oKlXN{gfI9D2}Nuf>ZHE!fgaZ@O)7Oa!rM@ zYNVYH3cC72s!?19+(tmi)eA$GYbuoKm-+`N)`I^&C^>bCg~Qzh1WElH$a&sEnOdov zfTCU=^c+(#JIUdoaBl&Ds(=jhwqOn@%xS=(;qC&0j*x+#=QZFk(zKZ?BOUEfSgwE+ z&P+Llh04N62R#&IIWTg@zA{~N4}Ymr!1*RX8M4#_Krt+XEXNWk1CkDMC~UOmZyi)K h+#oC*m#-8TZWoBYs$f%+{@Z2<*n?;IDwqub{{vLPp#%T` 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 98da75590920dd7d1725f7910c80a27a338056a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7703 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 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 745c21951735b50b1b725aa7cba12df2ecf7ed20..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4947 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 Rm|qqKUS!}Zg4s?X{{qK@R*e7v 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 47bfdb02c3819facc53eb3258e16401ee67b8917..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4277 zcmWIWW@Zs#VBp|jU|?_rVg?2#FbNc5;ABWiO-n4zDbWuNVP#+}DEltN%n*Q6gCLTI zoXouJl*E!m+&Yi(7nP?0tvUz9;@EWN=BJeAq{3}}Y_#p%B65Msta^}iP z)m6_Z_S25nGVr;L!Phr2s=0oO&#^=v<9qiIHzTYLgY%>3;3(YUK2{}J~ zE&622vh-)7(V0o&*PCy(rOXs=7QVoyK7-dU!_4C021Y!-bH?Vzg2d$P#Prno%zVA< zlKdjQsza%rUBr`t`cX*^+@tt}N7#4Dj3=GlK zvCAMH(*b(fdAaZ~&FwSvKWre#T1GuIfLzyJ1!Dz1wCx zCEP^(%@)eMsmod*mse}tw`1Ri`jYj_`~~!!zG?CWtSvWt^7Wv+_4NF!xjdTY2g?k1 zrd0*pcMxd5(!(<4S%}UJ|4EjjCZ|0EX3my;xu)w`q_IgOq=f5=*zHu{<94$ze0PC_ld> z9$JLqc41Suz*CUxcyPH8#TbKlloAHnh3KVgzWgk$DL_Nt1F-;-`*IWWGSgCvOW?&+ zZ|_E~Lkag9o#v!Am@>k`1`WQch!FURJ*Zc%gjc}iuJOZ8in7S z8EQSc)>+2*U0*CY^PX~5TSwoKeIb`}_orNBb-K1l_}Ws*>OdydM0<~A=OsIQ)e9Fi zob;dZsb=S?cR8`r8JVWJxtX7WC;hYe|F}|4?F-jR_p|3*LUuGC>e{<;>cXx2=cLBt zE@Xs|yi$}}oL^d$oLY=Kc!8dWSGmZcP?A`bo?1erMkypaVH$x|aA`$+g_#L)b}QpG zA~P>Jr!*xsg*da2voEG|vhz!TZli`tdB8fgEEUxxMkWyk+zlE~Xd?hd^CrL>w{}qT z1_2C#Oqh1e#tynU=#3qOIoa6EL2LS;n+DPiYo&pjB?!O|Y&>ANih@Q8y3Oc~6ok#y z)UlahlLg&HAQ!;G0#v;tz-6E?iD7|AN$A#~SM~^NbeKuE27A?yZYRhNnAboRI|6JW z-cH`~BgR}T)jmF#gX(w$&}GFN zNVpx1UgIN-EFxeeM*WY^wV)aw0nTDG8l~cf*S6?}qSyZjLq*uI1ObYnXsrQsqtROc z2%~e6jK*jWpc@0y2Foa*+7|&V*by$oQWxVh2vip%Krxa*uo?|#h=H`h+ykna5nv4# zW3bfI_}m4mrxD;iUIQ^}ZG0x9=1gnghybkkg!vfZRP;KWcyn9vnu}he2Y9mrQvw5n P7!Y0n`t>|$oPq%WCnHMk 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 d5eb44a3adae81bdc68f6b9188fbf4a203d9b8d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48874 zcmb@tbC7P&k}lk~ZQHiHciXmY+wR_N+qP}HciXmi+r7V@Z{|$QZ{nPD=ghr-)QUIW zhPp#oC!(MHv(T(BAbK z2^c{BF9L82XY)$m0v6u`;lBvj7}#2vnm9TCr=ZLjBb+}03_xFYuRw6x=>W1faQSLJ zqUP}ayNhwBwpj-^S!m>~vIQHukZNYJ!GvPvZ?K``lOLd3p7p#WINe%05MZuYmJW&K zW%m99zZ&n4f&Z$>ziIt9)e--TCe{|VR>lU-2LE^sJH}=@li$)r-{RKO1#$acO6q&RyduJ=#7**mcd zqlrVf3G*gz`NQ7@6q92)#VzFF`gxF{86qY@4o(Qo_+-6q`9+Kju=+FW?rGqQMCgZ# ztIke)62)UzZg9Dro}nu2=xUDXVL$RbQ=&AM?r7wHZ8r_8V*W^>_8P+{qyOwt>DfC1 zTf{Z^BS)J_NNxwwiKiAI!Ruqix#JhE7>$?0+u?1#`edHu?o99OfReg4l52gC(O_T z^g{d-g(?0A)6f}NTbS7X6L*vABdhNL0RR~Nc2|~v0gyrO-;w{a025USwF6Ov7GsTs zNi8FoG!wKmnZYRp__eHTmx6#eY5%TKmGID0!o|p?j6O;pI44=USK{^)^ur{*K29-N z9Hnjy?nC+W+8)i0j*1TeUzh$U4E52h$3D1&a59{}6CNFvRZdf$m;@#NxJUJw!eCQ* zy{m)lxMCE^@BA1b`H9X`49by)~5c6E?r#MJXwn7 zwBE=hEogMrX59PEBGUTH%DmrGaDja%EJo}g#E^ZS|7c3Yno>$N2nMD&b`GmDXW+mT z1kcVeC?#|QUVwXpB|RCT;R(O051)<;)|L`coqfF)oIY z(K(y($L<5K2OZ0wQD;<&tjXq-RE;{2Ab?ra;SVG72bKS^8SguGYzaz~i|Jb~t%n{y zI)uqE$k}7iJ8*h_sGN(;ZV-9p^kQ{k~10e3g{^HY|PAjfWbLR?C1DmT0S+18})8Q zCs`xB$d<2CV9}Mv^l3Xy@7%D9YkU_S39NR6`IFaFjC0;ei4uFuCNOKwIayE$A{q6E zqQTmbdby$PH5HPK(*Fo8JU}lfO}~?jvITlfYfQZ;SUO_$DzJ(B#l<*SO1mjZLPJv; zs7ys1lr=t|Y!<3~QX0e|DC!iauq-+*<$)p|a$M6GsTnOjK~s2}OZNIS{JpGsysGC6 z&{F9ze;KckO*m)FTIsNy$83z*gKZ4jr>Ar_CPq`_3eZE(%df2R%Cc7O@wL6?SZXmR zp_YVOXxSx3t&EpkD`U$Ry#&gs7d6wg-yB^{lg>gDga^4-Wp==Bkh*%2m0UCUQZ$|C z2FdROX@t$v*mI5udfVJhq^$6ev;yDVXe-69$JquYX;w_!_cGc|N{BX5t^=9LbAwQ3 zhp#ru1{m`>yBk_+ujOWY*P^$}t>!ig-m6#Y65+QD==IwV6PuUf?w=h*H?OFm#~&3y zu^$;gIki5=pP^7yZz&zu6EC1sG0{L2DoI>zGB_rktL6Ek@);C`6h`{&GShN-(go6m zn}Y2>9pR7oBo$uT2WkVpWKibQKe46V;G*Fb@GQbc!*jLM&@#ws4Xm$Y3ock-3w1Kx z>}UD4SfHpwV8dRv-biT- z*>#sV;v_rlc$DB3kU)9_oj>bVTD{`p{Hy|i4$_f2wCxVePfMBbWrF%8wi?XmWH zBEL`6Ztd2-^Qn#GhFZ6*-HM5CY>bS5Z)4Pn@0A^7Kp3%EaY(RQz>D{%AVyLw zrE)f;ucjhoOa}L-K%tITQcx?v#-1IlWMEW{isAuG<^L7g|8?E-zLiZnNDiK+)tQ09 z#f%nACwScA4V26lTvSOGS!^b5_vUON7CzRC366viO%JUG<@eQ<#&&q6KZl|+=AV#+`Zx-hb!Y{(7X*&UuhYxx8 zY!QXa0Rj}nQevdi0UjnVXCQ=oK~Pbuv(^gBRc?QNflGfMLA}(%^9`}-+CTp!7}EaF zV;7x~fwlENj^t)7j&Iyx003&x008X&D(ZJw{09F^P}EVCazYkESguiBSJ7%2Wx`m~ zSGQ(>#xS@?srARxU)vQk&g6iUSklDW(9*r4=p?qzP1qOs0{EfgEB+-|)BYQp!|5Ty z$kSA;K|I;3q_p!n^SahcJFpkPIX0kvRs-TJ^k&XKXwKdTf25+S@oIs~M*fD@$9FZ) z=DHqW{ypkl7oZf~C-=JJi_6U;*Q;#Aqmw9r3TVW7j>6H6vS`KQ6tt7!&PKo>HwaT0 z4~Rv5jB69x97H&|re|B<2Fm^p7bf_j6)xI+>4+}v9AJ>BVEbe|l*O`oA-Lje?=X~6 z6PY@l8EtL-LO_4QV#n{}gYY|Sx!$0F9w7o`$Uv=vOKU$+AO(vNHY+^A1{9otJy33G z`G$i9up@!h>=ZC`9puiPO5pVz>>#9FXf9NgNw2ZcJv zFNv4yfh99rMxo>G&a|pRob5(pbtR{## z&a}ESb=TAWY%vw#mozxV6I2~>(StqnS-O(V2-&9NlWEM?w7kqm4^`vVZrHeLY7e_A zHUKe|p*%x7@~Xn*Z#Ytya}~l*$ZU&x*eb%p(hG3nCCRu}qJz>f+~_WC-)pvK$XqpI zKu=+>5IRF8%eG)#&8GN@t*3j0vNC+pni5A~rNXfyJgDmbSebCouSKlDV>}>#&~%If z6Dhd%Z52ZMhe=sh>H@xAizvkjm{cCCM-prz-&imqU)xN zaE{DME`>=STEO6}MX(O>LXbP%d z*im@*QP~4_@hx*i8iVtw>e`{aVRoS_clwc?TM1*?woL+Uo=N<3-Yv09`MqIXZHe8= z5nNHN*?RYkZ%tW2%kq!Jk3UsUdW+M&$H>SL*`IwR(iDk!Kk#kEOSecgw|DCK{ZB4OyL>b=OA>_3e|x zhVZ8}MNXc4jO?GX>;kPLpxeD2tYp8Xy}t6<^zd3|^szb$_qZ-k+m5pi$zkx~U-OMD zlC(Qa;xNtfAex_Ndn_Yn0nEE9qSlZP2-;O~s(eoI!p32v2xC-mDkukKSfshrYY2sp zBL|xql`n{mt4ytZ%6tViWfJ36l(xa3&}wERvWhJdz;C^Z3{yMRgN1 zq8$}jN@8ad2%Ji?CL;zwe#Vmgl*7?sV_UPi#PD_dQD7)nb-1Y#L8aA2Lr^0DK}2Xu zGNeQvfgsn3FxD?E6N^eT&ao`M(jcy;NDg-c-a^F3J1j%tl;y}wCYOUO{QC4xt zw{(DaQ?@^BVjZU6;#rlgq-Ok{)}F84=%uY(>KkiEm(+M`e(*LRq2J@dxJwBlvws*ZDJ`tg-UGpet1RI)SFwmL!_v5#WJckO6rc56tO zXLb{+bPnhIc!yS~Ki_Su%jCjQl6s2BWKJ)L5Q}@S7ySAxF3$P)lYF8$olxW&BdO#6 zboXTaq=1*li|m?>EW#g0Z{0N?%_x53`h&VXG&4HiKJUU8LG62wiKM5$Xass2)Q<2+ zmo7Yaaui@BiPOoZ#r}Ej;J=4E4xzQAhi^ur4g9|dOy9KRzXzsjC0;vZL4m z84oAMAy=UKvaFFvMFMmPOqhgtguKCUHuN2^T5r8OTcS7<2JZDWB7GQLRP$ZaBZA zG11TsN>Y*D!+7&1R@f!sN#i}mqzZCYrM8irSNsS*wFHqq4@Mcv{? zDa_o$d86Nq?5w}cjNrFa&A_j(ohA2scr5gyc;+04f-5|Mu7%QtJ~3Y7*FR7ca6%Z!sEDd=bd71(66ZiR7ERrtYP9* z<;{8Y`_MRpL$8V2y~KLD*`3?CiT@D}gY{)0Fm=8lbnZ>)`i{Qbu}>rC=i~mv^!5kPUpel7Eq^flD^$nM*7=`S6-Y72sdK)Gv-Njnf%ks_ z{7s|&eTu&@sbpndxea-g$I-@5?*kHgG7w?@d_C2$u>vX8@L-xq0KjUD4vsy_dP8gD z-aOMvjCbHSr8oQz*YmN(d}3sy+U!)Q{>8m)ryI_dm7bM`y)Zb?2(szd$x1ULn6@Xkg2l3>@3}Ix~*kwx<)*!~hSkUt9ikZ#%G3vPy^>-UdKv`Pr z&$7qu<2R4cn4yR?S4pVma~&Neixl>Q+UotUGxhS#-=N#O_e|IgfAxj1q4=7 z>7n@xWWQVxdR=)~yI-jxCJbbT{ujHim9m4%F!9DDox!3SqK%$#)%%00iYxDv_uxQr zwOz%u%mCYmYAvE`je*IEX|3lIkN1S`4z-Z?0lTN@5k6p}@YO@4cX@I>-a3=(s^X0i zMCp!kOQ?p7X`gx7SEW}}3~@GBWf%&scUSu>%Jkc2mqPFUPdExZXt^tXcGJxb^=3+* zVlz;<PJA)&vB>rC!jkd4!XET1JIHM6ia`N!D_j11sb9Vh_63M2pk_rH$Ip!e?+{Kcl0m3@wMRD3B+p@IDBbnk4v1OMlqOngSitQ&9!VJ0ME@JAm=b z7r-p^-K;`coCRjA04VOToSfH=?;PbKCt)`hVk`yvJpP7_z<3?a#T!E5q|cFJlj`=K zi9gwTrLg%3IdR!%(uKhJ@U7)Ypul?Lp|7s8XK%sE=@&}|UdY(8!Qki8Y9l3y%TDdH zC=C|gH14Z_Ef&FMgb$E@2W;yZ@o~REd1q~gZMG1?a|<|Chd!Pz-fWOwKUIkRoQsVm z2SP7qE-7q!O`{!d-vH*jy?kDrZua4 z@nrommZ$8(H8$3)Eo>OzPDlhIPx(K>YYi8v%TSDHV+93?|-4#3|ZhQjKO-1%|< zYoJ5-=viU&mWu={UO?MIU`r&y)-2%&J>S(hAEHj&c%IjQi>MCrztv+g?r+VM|}Ksd<*)TE6f&d6UdHJ8%ksX3jGxgQJ^+ zd35nU3K(jsS~%mYe&y8%YjDY8(5{%4UZ=AoQOarMV*Iz`(c|+J_(^ZdwpIR3&s@_d z4v5VgbaQ|+vf=0o2hz62W>pe(En!#Wjs18dzaLF446+PumKF`97T#u~&OHQCZ3Y~E z3wc}{Rv}%>(tsJ$fY8%}$6Co~RpZ_@ndK8qK+7~9s*xOL0j<2MLqyW%LC0R5+SX`0 z4I6D|sGI=r5Rsq-o3(t41Lmng27BKil0yl+PL^&mFIu=gx^7y>B}*)!x()|OGsHcfM!q|d;-FC`f{5pguNC@H*pa> zBEe@lKrDm56@KiR9M~L_KzJKAvKz8Cu*4m ziUdz|JY~!T3H7z?&rbMuwcx4!Gdd5FyE?9$_|X9~*-a0+89m5Pq0^f-JYxEgor1p9 zU|qBq>Kf+5=3kZCvk9yQe*~4a`xlTK2!Gii9?`Dc8;hOldcBq(l57IgHIC3S1iB&?c5N6?dq z_kxfnK$!dE>p=<17f`ijn8MLcuTA`h*~nm@#v>p<1GFvYs>A+1CZAt6*=d%>7gG## zF>$SD$X`%txFe<*(&`zGj%g<)S3gEEK>#I)DHI_{L1rb%*Y8Y~hA(OjLK?yp>G&?K z%6N84I>aot?jt(j&WNHkF0HB=5$8=<7OO1)vH%z1NR;_W5@wOS1>ZY%$^itC$JL9jOVKkvH!96c$8!4JS5b|IbJ0v%y zsH00#8e=wfSVc=uvH?qRDJMmJlW>%iRfwd9{=ll8QPZYkO2K21lOQNS*?j_}ioCK{ zOtf3V!rw8jHFa^ZKO4wei}{?hj;6rx+NCJW(3%0>+twb|p)I2Cs}9OSB5I^c=a|@> zLDQ2t(~nw)GG4-wS1gF&73TO%)uVV)H2dc?W+y-;`1zCRcBds1tcjGTsoHE!mZ*ib zu+Scv!Hz|gO6c}v$V#$HHM+(t@fa=(hw4J$*mi#Mz5_jVfoIOmkc#&66z-C1YzVIX z@L0(=(*1Rd;zLjHo1TP*ee|p1i0jXyWA@G-8%)`{yP^5pt+lV?-rD`^e@_c`=QWjF zzIm1f`2Q**F#g?$pd#gjC5CWZU-yfK!5;xaq9sai)e%Bfmt-agwW>)SLjg&dl6(n@ z%>3sPod%d#heA!#C-$A+9kjPZ3|EQw6y@jx%+D(EWMR4IXBjs;eo>&W1VRNht?Glf zuYTX!4QnfcSRB*zCr*JKhipn_8?FF%AA{CtW+=?;dWS8h@@W>YaBGjDU!_Zn>pId) z+ZNYCea-?{jH95Cz_jwpBii@I6Q_9H{`Ptzz^`fL*Q@;L!tz*_YTV1L&H1=ELhib- zLvJxAb9~IWUO@fA69nGjXP1N`o@qV<(CU`v+?KT=sAae=ookk^nd^jOMpluevIHai zORTdLY(RT4`Uan-6d!(c z5EW0Lhff{v;G;jOyu#M|>pOlD2dOWZE;hqTU9k|hgb8o_J|?dJ$rxfj>M}#$u^9!q zkg2kdwR=PcI}-I}v2rF;_CC)ESZ8QL?~5ZWQz= zR}P(C`EF%=ZCbc7BEkcB6xAVIp=2bm1xcd)cAq4>3M5sE0d@y8;q&$5lChoNL>Syn z<2n3S#LL@rq z)i0cebB$`g6bc*b_(j>UcjmSI37r3V z7#|)JDXAttZj|epoQzlU?$zyIx)(J@4s`rX|W$8f33dcRa4iS_K}v z%_@S9Z?7Idk9&o>Cax60O(3OoJe3Y#EOR>X;c=`r9Nq>tKuqQ z(}}OeArrvZI7K`Z;JJUk@%%#tD2hso3ko+v6@~2}r1sPpksk5;f}SoJA5h4t z6hRH42sJi_hVVN8AWs=@NKxg zK#Jzyd-vk>oaD@`wIcNN%|$%D6xjcyd%^K9F!{gD?qp|VW#a7gj~9xnBxsBHT`$V{ z9zXssCScI}kE#B$;Ar{!@0J!ylg_9_txX-1z#<@F2^_^ayF4QUV-vDgM3o_Fef(90 zH{j0D2?MjM;SemUcs9oNmV_G@FYY#FTFb6X9bAEJGZSFBf`OS%(BejiLjQrGL9~L4 z`kCySO`BKslGLwdf;F%$Pl%%|7resvFn;>0r00w1Kyz#J<^XlD(rGY>?rqPIz4NuT zt+#TOv>deekSi{$3bI}AHGYQ-3cP+2z}fI3#ByWAbQ}>JakzMg^rRv;5s^7FrF2p9 zL=EROk;S;eGI*Y|^vT8q>G4OICs!4DMY2|p{v&%)XvB?C=9W^JJ`Q|zBeF@Uz*>BNBFLS{NEU#@$VX6Mxj%7 zPynHc-q!?oA5@V5S}=1$NI6MBKoW`sM7*5#L`2s0I;tBn{{Z@hgG$m;vd7x5D0cA5%%ln)#!IJ`2-e`vM77E?PQUa(|g_i()omudG#&cg^|6a>w6R ziAM?Nys$tv_CU~}jnTNrqPKx?T;R)^_h~xL8u!1m%LfxLkhWuA42tJT83&6YveuNqy#H(&<6|M>M6pRCsOaK;|>IH9Q-1|SLBt)7JN5loO#XrLW*#Zf|_3`(My zBcNnVL52cJA)!H#q6LVM{5~IQlfY~$QSRU99N=VQ0NmfD2${1pF_V;+P%x0F~N?dJ!_HKQ~T(h!$J2L2q3|xv|wQ ztq3vPcC>E2GEMD0&@X#l6mna2!DuoAzjTsMN}WkQw=r+IuM3ei zi&kmBNs8_LI?|WqH|)w#9jIxwR^c8O47kt%pJI?=vZ+OgC@f}B8SfbXy!=5<3p~Ji zWUQttP*QDys|a|o=<@^sFGtV$6mrS4U*^JeVVp{fap9g)mUPMo;);n-qC%p_J3Abuv7d!_P|TMG1oovyKS)DI zlWH+K0zaV2*q4|7kOrnU<2jMK^-NKq06#z?{nBePm~|hHnbqR-S{pDh?@pqHyYnsO zq+BeDkyfTQrZ}7Ko=Cw~8uQ6h5e?_oj}6aS+uto0GLg7Akj_fL(M}SJ+lXAt`jN?# za7!Z;V&bU4!2wd@E!hM{9cT7C8j{4gVsl!!6wV|j7S}HhzUwx-BXPT5cwwC^-y*w~ zJCm7%Q->Om{O6jGL=z3yoxpr;9C2_qJ$dwQC zNjD#G=_7La{&6+$DAk|$6b`G6Ed2jMax($wID!x@TYaXYi>P_?rc|3-C6tr9jDXm^N zah@GrN7bP;;asYLXX|1XA|v#9W0MI5+G3LUS%K+&8oykj4pDXRu5hVo)Z2e@9yV`J z=ZR>zvoWa-w~uK#!Lm}twMJ@`J*P!qZmfA{$cr3CL_GIZ4JAoEW zEt_!bskfbWxUK}do#}=rrCwSx)^bG1ETLZq;LQdg7kiF{nY`tp+AVBFNI{xN6O|2s zy0>%P3~Z1f6>BXTP;zj=&9g}0XsaSMbIZ4MYrCf|j*4gu16>l=;%fqETs;9wghLyH zcY;)fSS#e;DLEYVBX&x%h9(j1^`TO&NvbL-5XKELwe*E7&=@oDvN17vX1R^ycmjS9 zdKAQJw9FPFTP~cGLQkTdqqKV+`0Q>j!Eo1D^-SXN~>@}L#u1Krq#xF;!eRgoE5l4$W;sYfe^3$`il z0pUXi*OD*20~(BtHa2)$DWk93r!g}&qFnWJM2>KocFwC48wz1GlQJBHu%a^c9nb2r zOQVsHiiTU4rdg>7UHCjsR&R-oMn_A;PD)0}MCGQWXJw>sec6faE?GK8e;Lx7&Se)Q zRs{LP=IDuPMWoSv7ifDjZsswm_4wB+iEN0Ee@m}9!qHCH@=}MH!xBl2?8&SP^7`JIokv}VcK^1+h3tp z6KI;-rgC$ga==zE7OUXFMPNH>S&Fgfy%n$oyD#f;4CWvo>o&jK2b8%Z%;(=4SF$C%JM#yXWP@brHu2J>$vsLPp?=~FAEt1oRs&q7Po4>lA z=EA8Z#T>NvS#o2GJT7>;HPY!#M8l|T%%S%MQ0BQ?9T(|lkjkH%f6RT1hh`aLnQ-h2 zBXKk_As^uv&&YWR0KUU?`wkBNkxYGzSa3mAX{iLDBDn_NDaZ)T=(rPU9z774Cqkre1yrZ-F1Xr zXW~s;qMzE>tVQSy;+@Top_kA;zxqJ#gV9|s4Wcv{<12kiI0K^)bT{Jy588dEsbL_tFd<$PrjPZh>-DHz9CV_=R^Vvg0rq;d6*yMy^j*z*zQEP4 zAOUxP8HSuB5iGDpFAwH`(f;1~p^9yqO&_+^&_->9*u_xqj1MsS)TlQjm^0-d%Sh*s zQNZ4CQVie0xDL{-d#c!|)}OKf9Y_HriT*I?HWoXVN?%g(V_X~bpsb-ESynx32rGeN zfb1Tfp{+2@lGnN?@d2NV%kw9(VmBrVakhZbaU_=hp@3KyQP*Iswuv=aMbua2*wfo- zdJp8QD%)nRBW>->Xe_VtYIk4|*r@|Oq^K%lzq#WW`s{dY#C`1)-9d9Oa;{fiWq@zk zdd=hDJlW<13C_slky&v;lHri>a7mWebo!QJOYNX>gRvfVYtIN7e|wbB)(I>1!Wuu3 z0=?h@Jpxb~*&!?C8^^g5{PqQaBa!~tPKE%ck?ZVo9KO(nuk~74!*E}VG21}va-D4V zGgphYGopw&p=JFFV*PrC^X_91Hcq{okF4I0D}sTGh*xE#NiA_pmwZ`t#7|Kkh9e(v z?9z*cCuzeGdsy$zg^V!H5Cj`@s%<&hmQHQM7vF&@5(|wLm>6A0c>siblq%B`7B=L- zjg!O(699I%9db%US;y$mfRYeO%$p)E0|Ds*h&-`<^?9vcO_Q z+5$K2>|4%I{F1#|+`vA?zzd;EqYXY;R>pWRf^0KYi$(AqV%ILM=#CHyWu9x|ouSLj2%f&vPr%9Epu&p7%w3F@Siey$cHG zV4rBH$36D0nvE*znCe6bdQCGBt+yKA0+lnS>zN=Q4N*@R(@Z3Z4CJZW5*B9S9+qa} zz>#iT8{H7^;;MHOa6R42mOmoppVk^y=89^sOo?TzUMDQaFAnEsmkO@Q0g~~&OAMgQ zC!+t}e=e{WtKghw_h=J2Rv^`IdCHn=aZMfdr=veRTW5F@r_;^mk*5=>?l%L>b}}b; z@c3WYh^=mZ=Ju>`e2Q0~Z=awmMG*LwB9r-=AsLjpcj;vTp(8`c^rnRfW1KY`*o??p zjC_eK0)r$jSSWe&PMhghG-s>@rgfTTCaQMEB-b2FuSa%Tg29h-a`xfzfK!Znk@)a4 zql>HdkVnbB&v9_~X+8!2V#ppg7QVVKw)03vALY*TNe&kO1CsE@ZmQ?cG3?^ndDlU* zk>^CbrZ|b+qv?+ho}*9I7emnZHWXV240u}zV=SC^Cs?9*LKV)jzGR^*1m$#cf{2?m zW3DR$zUZ9|Yw_HRKMOpMsch;Y&zzE?*`?PY)(GB&T~qKyNsqI!4y~5iZNGF7QaJ$~ zyDQN%{?^L%TjtDHVj?3}L}{bwJsBy9J3AP^cS!`G_XHURrwbaV3ww~svDn-C9TzQ_PGJRX|R?`Mcy;ldeQ1A zLnnNjY{?LUMfx~-1DM5bo`d+k;|#?d1K`rL0T@flkiM!*op%!g>AVPrVUcyQRK`pd zbA}5!Y$$_NQ4Sg(dNRLf_xN1mLvp1JcHtV12ff_T{hiveM;p-^4Lk|QWnskxMiP_U zqAKw>cEYpDM3xK*#ln`5q+GZ%|4bpvZi%ik$8-Aj@yCVh!&Hp|-U#L2FxasXxkl@( zFqkO*j}c}UCx@n~d_J8oD$Vy+G;_4%y$-yGSNnz1&vH-*Xuac>!TiGMxi#hNcoHwI zC^0AUV23~Qb#?^PVReo+e&PK1We{Q<_Q*|xCR&OjaP~oysMg|DJ2rlDp7j{9 zV^e@qdHd+4D~a{=K3ylxjS}YVP%eXZXGS5NYf|=-3if!;nF2QsLav5yas~l+u{5fo zxhTgsJI~myX|`kkog?2=05gP|7706d=%hrgZP|ixQSyee4R+u%pJ$eC;t4*%ba$v( z4KIY2Zuumam=0`_z~F9AV?6$;HrF07*A{>89kA?jX1lw48hL`heyhqnz~AN?Y$ zo$Pz*iuob8PIiS0lugN#Ued(-u2X7b$Kf+0)}JKkdC=+$0_*yqbcsw8atxXO(P*#D zB9?31P~)7hqu9SdsH@-cBg(wB2lR=LASf5=&dKtHL&|yVd@CEpg1sYW=Ruo1R8QnH z<`i(Fc=`;jhDx z>k|wkW_prJk;RqN>h8MZ0C$l_^cG8)`VpB53d-6m(Q)U}TdeiM20I(mB27lP$)-Q# z;_nw#;BGtlN*CNF!orTSy)*QU0l-xGrY7L`psi-@4HB>mwmu=SJjWf6A7r-G*b~$) zt;@JasCEiuK#NMIw^KfLc5M2GgD};gZL`H(1eabQfbTMpQFu#;K9XqgX_ANkI7TbzH`A4S)TG&4Hc(%q z3V(88J?>Hpd;Z8?zVfR=Wyi=Fq^z}c3q zCDD7dbk$I61RWQ30yD?-R3i!D7G%;W5 zz4R>A?q00(EdJgnZD6Ue6G)Cq&l`9C$SvI{*x>}d9q9l1Y9> za9Mh*^mO4qeGOy+j|KO*?S@z;d`r9=#EW<`9Ac1?>?u&|!42M)om(k&7eY@jSQlyA zF6`OK`b{GFb`prbDWdU*JqVUl7R&hP-7bz>Mbus&0qVo*{P8hEi$d=nWx^JhueLtm z`3y*7yF;L-C10TDm^QBVO!9D-^O=ml!#{w~@GZ+15PmYetwdL`c06?vtmkZ`vg-)z zhH^FN0evCp9KHnnaYw55-p9btYc}R;!&YX~;hE3Zn8xE_^+hQrj{$dK;niJ5KLdiizA;|m1NhP3=l z1S`!@!A$8^X>c&BBtiLB0_PzbY@x8IW zk|m+SRt6mkRmPEKt`3^(V<(Bf(`BG;sHa?45EH+ZJ7NT+*n(PZQ zhiO-A9v`hx%Mljpnw6GX;ALk?E@29Z>sBxaR&7zf65I%m>9zu0@Yw72G75>K2vk~W z5(O1&>x?Fg+asVtWVvD3xT4uutQuQ}8Gw+RIG>G7tnjOZ0t*ea&bEr+`yMD~CDaLF zzr2*p7}`u-^^I|@enrQsnRK|XkL)jRnj**dloed0guG@l%)}DbVusR`nK(^~w>PwF zYzCAW;Ntwg6Ppm1#}@S({ar1E>tJhyRI!n9cB%Em2oC9{#mcf(WyH$Kbe7%9Kz;Gv zv}S>&y!sBpowSAv2j^5*gReA|aew;^xBLb`J<$G;;yR(?a;{uXYx91GO_L6T*76(c z(yizNYI^&%N}$N%v<+BfBlM>Rw=UY(Fa54rQhrEj_=1*E;d!&V>vK_PDK-V3;Y4Tq zuxr^z*-W6dnqA}y73z)VeckRhtuU)vnTrA1B)7&#G_s1L=gi`(?s?bhm|BDzr*`E^ z;0H&CE}`QeP%7V7osz>l1z@Y4egN(Zt;(2`yPCI)N~x<%?OPJ96U8DJHTe!%SGU84dY@3j88Iruq=E#1oU4l#hwI#?W+_eBnn zh*1>L)uqw>KwzopYM8IHSf3%5#JTlF0z_>t9b~WM#F!+w!bUK`1CC%brX;%*yhS>i zh-IXCUuWw9uEcVyeTuFMEU=9!E7lF?2{q_((Pkq*QaK7rJPoZHbNHcH=u@dM}8v^V_=bu)u>uC(5u&YG+mD&%LW_kptfBr5IPp8ph37_ zeAomWjV+=J#88o_#)Ia@hzGlim~gvhFVre$RkL+0RT8~SxXnOcgTHrx>Rx$<&GU;4hRvNH1_ik{t}*q?Aq7esw>|l~fdCx~pMjpZjiEQAE_bRw zg%sWBR8VLd-*JJ5<6BD}O&H#tIZy^FX zY!uj$bpds@sKuq#b`N&Nj$7GO=VNnSJYW~e{*F||a%aMS-8eOk|9B<;|XB9l-ZD;XmultTPCreHp-Z*nJ64LK}Yrf%p6rU=8|x(=Ogut`>0pXeST?C*Nd|&?k4{1RQw{>E=)U%1r(G{ zki)x;`2dYb?;SQJ8#|T^uQEncC6bKuPH#`nGw@#5l+jI&mm4~3kQV0t`Tm2?g2LPw?=WQ%AJw*Vz$Of0Iz%mXc8^Zf zn}DSO&mP-EEY~D7uG+niWSp9y=s)cXd*fK%OP#7QykHGB%j?0Sl1 z98xpeG^dd2)lPpGr-pdBJ(zS1zrE3x$?353m4fVkP*NW;0^k)7lI4x=o+mI3o4s)KRZf18M$v+$Hiy>lIYxY+NwN1w zyLaa!U$7_rP5%fU=4yrg$xD9|hz6)LaKh$Paik12AaVFZhy(p5b2JdRq};)8g@Is#v3nTk-a@|@ zIBNPa?ua?Ue{=WaPq^f>P$k(ycuN0bcNhxp2XPA|K4MQpk!<9cXNdS{df*}^@}G@d z3Eszv3CY=q#bKwXnkgtqrDs$A@Hn9lG1!1P5XrZUr`&ne*q`Wh~X$#=V zZr;-8>0fi>q8SHcr#8k;BCPq?eRT{M-ox253+pH~-@0P2ukN(HA4 zoU!<@WN8RvTGZ^ebToiWaG7Ggv+(uSyZ*pqCJVBH5g$scAq`1g^15|KtTz;a=?HY3 z-(0=s;MCVW9yzNx&~fS_k#X#?%#Tx051q+r+d&5n;J!=QjYY^lht*1UN%t=5(^~aj zv1_|Q&tL65NlSskkXSw(BD0@f_bxx(??zdI4kY4h@OUJDN&u@23A7ps_3I!~U03|F zUkQdQsfHem$d3szQ(ARTK?$!Q+8a7m!Qw=~OgI-pSQ1Hjbtpj=5zNX{E8&RPOyd}C zCEMVeey~aD6<)94__+z`5Mwvn7_D(wGSo@q6MKf!+dXC{HP%f^cY6i2qnodVpce0? zrG22z*Y{b~=HKA7&trM5}?_5ufbvOMxFDCxXg7QLNUa{i@VZX)^S+~*yvvJQl1l) zYGtHvsv<^F5?DM^inch0K^geZ7%y2K&OxJ`m_XA+J7kF<9R)3nQy zhSRof+qP9{XQf?f+qUh@O53*0O53*iXOF&l`=6QX>h7cGIl9-;UJ-lkh*`Q#QGHr?7xY&s;y>q#@y{X>Kg&{-o}an1%U6+8Tqf=#ukqY@@4j49)8 zEtBk1$rGk-;{9$VF{nBDh32MAt=d!n*l|Y?S*Sbu>E?jodTB6Njh~a*lG+v87TOt0 zA=k;&;S|pfrRihe#TKg>w$yc^&(B5xMHZa61aBpdZn@p^g>9OS2wC@Bfm9(InUmEi zi)>xt4f8v%-&--IpI}ZhS5W>Wx68s}cvbupx8DkW7xYD;A!;`_++M1vY)7vsw?Umg zZg&$>r;y8a)L_Gf8nr$gLXx5Ps;n~uQ;I(!nmE6rx{ zSa~65m3uH%W^cVLYB$Cq5si;*(*Pn38lPw)2k0J)$9R&FDqCw{=D?!d4auo7EIGmk znZ?+UbRfaBDFRj&JnE799_DP~*v>l=vLQzb1tUBoqpSQtftzS$>aj`TGQMkssdU1N zhA299D&&d5gM4fM-FvL|Pcrmb8H>UQlBL44IwUWG<4w4$7`;XY)aZ93(Z(C(Jn4j= z-HpeW_a*JssBhe?fMz+akiq*b-in7eW%OLTC3Hzvu#2k_4AZ1EqPN$WCTD zK0y$lzN==@Hibya9x%JYw*Y7-P~AEvg2qb=oAzM6FkJz|G;&1FK4Up#t>>Pz zP7D*B4A?Z505O9iT?GpfQICFY9Mt9Rf}Jj26!pxIwM+0v`cFbPD{t>D8)*!2ls_4J zv*3G-;)8ck2K@cX^EnA$dq;iUHxBRg-f&+7_(F>kCUbi!WODRBTb&<V(1* zK3_?69$OM($3pQD67qW^SfXvkdz>+l-$4}#n-2Z<hdyaQY}xM=lG|j8G%*_E){uo}qMSoB>$NJ;hL#y3OkZ)nRZa zSVZ{O|fRwtqmv`Jc?p zP~Xt(|1guSp$$iUen(vZ!u~C)?f-?d{a3=-|BL7PxA?37NR#?6;`i^L|6At8cbUKN zvnVxbm$e~OGCYu8=`ftxq944wGq=#S$mxnWAqtjDB%+}4w4;B<6?N%AqD4dye1USU zD$Djn>&EB4@*_S2s#dM!7PVbB3RQI;r}P9Ac&+5nnH_SNpSwyg22uHb3L>Q|taY_Z2ceffI!qpnvDz+6GtoSuB=!(X`7kb>b zhK;xQ=#`?0hmvJhZxht8BpK^3iWRspw2!wRZH`iVJX+a>{AZJ+N^7mS$w$T_8hw7I z{wKzfuk{SYb!Vth+S07_gD7>A0KAe7U%1_#p)*{FZK^7dZHLZye+Mwkr7+!ZAB-6a zv1K>){K(o8{pinCD7oy}1PG|6tG0z`+=hUh zzX2biSG<;*V+gugt~nMRb3ECI zMhg9d7wUh$BfZ$+mli)&mTcX4pd=cL30Ri0*G%ItEYyY2IJob6?Y(R?v%x+GsV2ug zh9tk)m>|T2%yyBacI}5inpB`eOA*KTb}Q5V>{}FR6Wm z1s0=xdF-^vp?25lzC)J|krQ5F!duCYIIw1*H1t_`(J0*l=O~sSm*{G!7ctjvQLAL0 z-9?LiNL}MOG!MtYwt<3bL}b+3$fI%3DvSyHq-ho5UbiuM zRk}EfWx2@_wmwZCA`Y5(tF$_8F z2zivw7dVyX_L%G`&OXZYw|fbR^0$}Vh#WWFK-B#LVkD$-)4z*>x)Mc1|LGMu+-$M?MDuV?jbsSk}*0S9uu<#kDEAtyKqFZ3J<;%#%UAovoBxS#949P zb-n{Ws^0FqO!64wA^XHI5P*1$B&V1>HuMD2DoF)m?M>q?eo-s!9W(6VDhck$q@zc% z1P_i`UK?m2J8&F^z}Ah>^Y?9@vrxYXYW3e*3QO^U>eha$3;Qf^>f_n7vy$+djOt#DdCREO4%lM4;v`GWT#1o=_2=dM-@;$bJ6~KC!@`+_)0Q-J(dwl z#D=ceU3h%vU(SwPzC0~9ODKarzwx0z!-K;Yw8-*LP+|&^OZl^C%SrrvY zhh0?!FiDC0-Wjm^wd`8nh&o2sx;UpA++pRQ(lmdLgmNO@0H~lIJbCt)HHkt`0=< zJwg;QdmJ3hm7r(zh9wns4)ky~0*yaI+!)RLGG=Napj0(MHo{DxnT^;=r1_n3;Tx2; zT+&Yb@aq1CW`d02lc}Au(|2_9?R}45?Z2bWsx>%FA5+uVpbNsT=o0|Kx5%?mxw~9o zPHEugYgxyic9j)0V!@PkNp9AQ`_g>Np{s1YaYKrds;*;}6>D_T~^Nkje8W z3xaZ59_5P8mRVSpFY_^J<3!{SBXV@X;V!_Tj?B=fectI{%Url4WOFP1V?WXv=Bka^ zdlKdc>`I|tQDL7V%xRZ0;PfKcH8)i3oYc}l;K?nmHQzTK$L~!VkOJlo9B-NK4Rf3W z#*+3{$g_Ek)mT=6q_8*gGSSpv8$zQq9Kc%Qb09^041?MEYce4Ak4ymUk=h7!P^g|#TU34veChlG-Q@3ar1tM95>n&= zRZ?nartDJ6FAh%w6O@D)+_{##Lk7GVpideq>B{23%OfTY#{^ zBzdmQjJm*JpbaT2m$pdvbSaSe=vl_`Gg0-=rRJP(cOj^lr-z6+spE}ml={VplDh@& zHEUEqwUK)IP((EiE0%5a!MEyYGgu5HDlQ^ELb@BUjNoJcxW&h=jO1ExC2r(6Q7VXs z?WV)oHccaVp@${;N}9!O63=Q+QW|3~H)wJdThUf>e~#Rat8G?TSKo78Mk&VwIvi@v zu}NBsf0g^3=8*snk|77KW6}W4C2LGt0Mkme37DI*$;HZZ$3oWB%@dv4Ahd{Y9O24E z(!q12yo|fB&uZw$iBrdm|aHXDIc0Zz@*%Z9A zS$2B&elGAEElJ9aI~@i&eQ$h$FbM(mY08X3zL55M@aw6 zWxlOp|6cGf@eW%QVX@0atByCBR?Gf9)>unJM`)Qa6r{MuQjhU~YAl+vi~@+~K8XA1t62iTf-8904g#>sJ!f~6NDR3RWT+r*6MRT%epuu!Xn_^T;9R~(e>xO5 zfv4D^4~Ec_?2MLN7$W=yo%nk}PO(2cE(JSkju^sS$gg}CBzQyq_S=tw{8B{p9DbPK z{lf{YvWRHXwR>;5II|!uVB!AmIIfT-+R3A4{p`Q%7cDFO!{ZHUwM|V$gA@p!~myCr7VL)!7cCrPj zqz5J4(S0NXeO-vM0iO#`40w@vPVL7a{fObO6z6cgzu=MEkVWa@mm%MTXBa!r41aRN zc%6df21&+<={>Hu3?owM8l;$rlj{6}Y90HjiTC7*Mly?VXwx?Q+=$VAL3kGDeEA9t zrBpC8t5qZZhIr8u`C~6~h|!@6XSe}t2ETkOs{7dsm7Jni3R`|m(-v}hz#795Od*hX z*c~1`Adu8>l6K2Az>j1l%oZ(YoFDu0 zjO$L2bNC8n28gMbHb5p%g&=Sk!zld9wJ%%L+}}KnFxE0UuZcaR3jz_s3;cQl81)Q8r8{H zk_R79wQMT!&BlodO{?*lTl`+62RcG_KEV^e#c4kp>xHB=-a9HABhQb8eqA?Eku}JV z@1=tX06=L?LN?ICNm$6{$qotdCSj74do_=2Cd<>UGL0E)32N-wPpT?d+jd%8hz2hS zn6DjrhjmQeU}x~;8*}2&5MJf(@p5kg9i>h1pqV#7P2rSDq+Fy@+F(b=Q#pxCCmqBU zvnir+Hka&+Tgq{Xl~r(36@U2UW~uN6SimjuRM`)Kl_LS&W5rP)=mA8HJIndkYIE4`bg#H7&dThEV13Kij*Z zv3go8ycx+{8a8xH%%A-lcD4L-`JJ%)(d?&-1(+qTR&xDkSTlF zB5V3F=J`d=^%dx^L7Q$;UXot)!5DD=lgR-_(F!IiQPx`8@WUPE=(5 zTYv4Q%#u-J_1_b&wC`5;ueZ5x6XyTW=A4v&*vtzcG~y0#M$S8*c^Bq^ zrl8qM*OuXx;Rq*$D+>f_=IK~)mJgH}f?pLiunz!!B8Yo~l1KA8?E7*soh_?8b9HER zuRNKUF3t78Pxn|$Zc+zY={^I@t)?rpC_S1?v;bHr+6W!a{oIl==&b_5Ko1M;!yu5D zGotBBwacyCZI`Dt3OevBPT1>&1~guQ3OIGdlduC2$cR!z+irB%gTp+se>+wg@SMtI zG#D8mIN`JrX0)Ax&Tu&?UYcHEb!nc)<-g=SBIL(?raUA3@n-lU@{EDlZiG0q8_E!- zYlL}sej@qQ@&;3p7)8XdXR3}{kw9q>+^-OCfT*W-e26o-ZR}zfLFb(1b6Om2)7?kH!2}4~3;7NDlRfp{yZLp@0kHx5zjvsUg@96DuQD;&!wo zb&KIP+&&aE<_r~6)RaMy)VDKP*u04OM5syiM6s*t@J#ctdJfhI`rbQxV`52s<(E8r zbzN4e$}7eS)kY=dgPf*U7S$UUZ7Y`8IIVuB-9zx4GWQtl#8Ugh&_aGW%YsSkUG0mK zN>>kH9vk6!lWb_a0dVQc^5>>!fpv8pw{w(wNW{R zg_25hQ5j!s*W{Mh+wB8h8_AtdtT*NAhzNI8(2XQnQoIy0*OmRKOC#WFb~HgKRG-iU z(IJ8~K$9vn$s<}EJ+UeRtH?)ZFk9U#>IAlYSFdWSt9^%S!pZId^T4=RXo_5{?q3hd zf1fV?BM$Wcnl9SdSeP6CA1>GUiXq3c?-S_sEp7WpOwfN{;BRqo|5oZRG$Tp%hthX! z)kfBbvUIMXCuF1*O^t>j%_xa1is3NYvJvr`3MCR1-+mnp^yb_zwH4e#0`l-O+o@50 z!lTSPZ2K|#>%WE0&ogt!QzqcpFQ=VkXJ0<)uqSTABTJEM72yX7PV}6L2lO*c z_1q@>#s@$R+~kJQdnaz+CTG0sq<)`CE0*) zB=ar#O}-cU(gT=5D^`>AgaEbi(FHqhw;KVN+a3n&TKz#h^K5X~5$A*AdXozeiuTCR zbM_#J&I!XWRR_?}4`2?##H=+EnL=#t%!AR?e-8;ia2Zemmu3|Ag5st< z*8#TIJxq~znkd&l1LMqQKt5m*Hac&?ga-D>)<`GcIH-dd@sStblYxm}P=CAM@I6B! zplkSzY2i}vKn^I7Dt8S^#ZHB?H%T5=E$UQnm5a43eEG*ZWaG_HKIr{2N?Ew8k_fb! zsOcb_qwMO^p!K|{#zpe|l2O!Jiz?_Gr#raK z;YoO82Q~3km;|&%#v^PSS?AM%4*DC07ePM62TCRBtGp&{gY`VU$OMoVjRl|Zc%IeC z212_S`{3owkpcNa5-OL9u}#FH5Q{POkc@`7*;@uB#H-ey^u)Qh{5j?Dp}p+*`6e>mq3 z5ETmwqb?C=<3*Z1s}{vV&!M2golHX>LQ=&zk}dd`!Er)4spLf5Dhu1X4^FZae)tmv zPdPM(r&Vbiq*3Q;8=Ne7t5`)8>-H|99490n?5>H0%+WLMD3J(?G!0E*XgmEd@;#KS zC4?xonHswBtm5n4kBCI(4 zVRt1NxAdFjDUasr-b6bW>twDZ3$bdfZ+D2e?#9)jPnC*G+g|yn8ZI)c!01x#CwcS+ z(WNAvn{XuH=nbrvqNSe)TSw0CoV+`SV#k+R5-&&6d6m|TD$+w!iVt&HN43XiAL(iF zDEQr5GX|)>Veo?~aKg=iTlRH`1gf~7LQ0di(ELC@&cjD?$&8U0Pp1)Zxk4UA2YPPIweK73s4E{c{LsF zd58)&kIktvJskpN1TDUAH3ZslQOfycwNIZqBgvA!AwJ59Bebzrla-*#svS8bi5iBw zps}KY=?D0`loQ%BE-OfFiqOw0Rl0rls5*kOF6KaZci{-f*cQU21|y9GgZW?VV&}s> z`>e*zWcLa5orc})HzaFs&^nwKynZ=Xf|Twubip5G*pSTf;I_?-yRC{~K0R4-kXkY+#HcvW4yRGJb6Tp3VGjB97Z|`&2&PW;=7-y^krb9dlG$R{7!hK3^4TDt ziQMM!&3i`j_?c{99l0ZS%LJjJ*-u>C@^PGS@uK{q@ORf`dIXG=$_3QFDX=2Zo^>&cK9tkREYgnrTp?L!|8X@A zI$_HzH?AHIh668i-aF6ft*FokMP43gGG3mHwm?WzFsUIN3nMw$U1OIoAl8qJFd%lY zlL_bqh6!IU`O2JwOcn*=08w||6!SL8Hrx{`d6e0Yc|UDHBfB*8&Y+Wi zQB>jk^+=wOB}5tr^_nox<5^;$D%>fn8q||!%KF*~sNA6P#tA70s)KTIdf+v(8r75T z)|1dStD4H2@?AIL*+e6KLxNZ zqzQ&!w^17?W9mU*6k=cGu$f!#?buAViE>L;e`{jYG9}mCPT{gf5v~_Tvpw`mU?ia? zu~89)F&}-Zskr(Uxdws8J2uK0V8K_hWm@FtFX7eky~U#7oVB#JPspYx1YKLRKMxif zMDTWNM2qN#=qnehcsU$sD-EL8QlD{ibYbyq`^&j2dge3K%|q2&nB8K1?XV*FlxR2-|x3O&#%oK6d9`Ai?Zya7Ow_ zyIe>63&bXNFeHUdg4|;!H`r|E2X!KMrC$E+*myJ6=vqIPoA3d$#8TPo>C-s`VyQ?U|H__EdC z!P7LI)+ghK^3B4bp4pms$~yZgGwVIuQHPdt`w+MD&dfuGM>4K`;bB#^yP1cnwPH#Lx z^0#+qFV!BzZx29_y3u08#G%lf!*{Im2b*Lsr@}p+1Pg|MA`7NT$svn|h^xMXsf3aF za2lvLp?>2_vq+I@3}tmgKo1)8#%beZ7?m}Rq9}E+3qQ^=6^7Vw9oANw>Yp;bj=6Zm zI!Ch`e4a1B?!Y!@i)}vfx7vZ`WYt^5)hn@K-#>cve>8%YMcxo2RvwbcY}dA;bJ^6R^=ij*2u-p3uuT+@_~Z@wS6ErRrS1qwhKL+4rA!7V! z{$tyLFgLwoa=gY(A zwy&uo8QY8KR?vLYl)&k*E*O<@WVs5zj;dZr8JpXCpKB8uU=99Vb>Ec+#9vSyp@z#S z`OVXO$#~_7=#dDBcN5m}#SnVCk4J%(>|Q(No0T-Q%VPMgRN6G|fe+kR`YhdG37lJv z-=$*r=_Rd9xD6#6g8sG!U8DmxT!M0GDOUJa25I0Kxm2R}sXFK|%Iw}Ts4Mm#S*Ky$ zon~Q}AgeUjPHd+gY&!g0g48FlcgAd!W^6oY>a&``bCU(p49!p=Wh(9)fg-}VQSXnz z39Rp0wNMs@_&E}~oP9u7xfRS(w~RcDtE$ed$F>y!yIrde3sw*##Ad9OJ-OajN{N#b zs_{T%1hnW#o5AaxZ#AO)2GYM`^(u(cm&(&>aJ3z7M*zoX5`VM_38zGT1ETkogMmk! zIAOcgV98qfW7sJ_C6^igD5KZaMnWy9XgWm1n-+>=zwI~hhP;8NM*=-r&{l@;$Fczj zGO^u+_@tQLAC0AsDBl`@-i@IKJ>}mV?{)-uyazAKG{@8G+m@a0!-*>uqu>Z)Htjf| z&1gG<_Y0J3zk*=q;G2QJS{qN8QkVJj^>vG9&YF1&V^C){-wnOAWPyi1X>~07 zB1km_5UGbD7!VA=lg~QpM4UWrmbWaYnN9zkJ+=3@;62C1EYX7WC$rD)5358m(lz6t z&jvWwpQUW+s=_eB>{(g1(HLZ1?|RklP1qaZz20{E5%RS^n>=WPYc~~FN2DMsX3&xO z-&ZD+;{|YHT)4Y9!t1i#xNr4_IFX8=codKslDJ?9b)xLpS~U?lgOduIfOS&d1vbSf zn_Dr})%9{hR#OFd){cS#BjN8n8F8YLDLiD)-w-DT0!#Tq6U6c7D=m8Q~$Ds zQSu?=dGXQYQj*(JD*1wZ%lG`wso!f*fQ?4mDW3o*CtJ!&KPcQNOn*dSfub&13Tgnm z8|dfOHQx(LRw!^WSI-LhIkFPpFYrv;x9R0xnjW~wkH$5My30zDGMOVWS^Nq_kve2a zV~&ZHam$oR)3C>*U17Cu^<5X;aganQu|B^6DhIrI#w+#kNiPPrxAhwoNkwRDZ_e@YQT0_rL70-%!sV8#b5abd+NoA?4s(vWmUka9v` z;(~~S6FPwt3e_m9Tu*cjrCbj2T@3O4LOnh$kNp%f32Y1$^ZsQl_JTmdxuR8|SF$keu4;3*NHjgVXG} zVu2q)_VoR8!ije)lNO7U&#xsvpg}O`RGHg^>OE#U2^Zm3}Zcu0gu4~HC9i7y*XyA<$Q&iwHv$Hk^Gei?6G)2r#o_l}^uP3I` z#vctS6NjrA{>C!a>4r=9xzJDF{@FX3JNH(}KLzWe@bfXMj2 z0Aiuaw9=Xw$~Pb;=R|#28ZtO%A_!|ei2n>}h|)TS2`dlfqW6IqzQZ# z@yT;WY~7jQ#1$O8$wL6dJ{4YR&2r`;SUeIuiWsh@wsp42x!qz*BNyLoy;eK4x)moJ zS!d0G)4^>dd!??++uF&aeNlFL^|;E)+RpvRbQi!=|EA9{rw4-echLjX0!P)O$`e1yliEK___Dmd+G( z)nVz-7k4U#m?*2kX8vbL+z(#7*4;4|!4!FO`(DOHZeEjEyhuyan3vtq{ORBc?_Rvx zo55u~3SI^dg{5^b_;|iG_+m&go6Iq=iH$Rz+s015E8%xP4=OO+Mi|=_3yRzR&{=o| zhy*Q#Ylc%`B;XJ#I-430hI`MLF8mbGSV(e<_5!o**QZE)cgKCg6A^J9Y*U`OrD$IM z^|yBfMdGcvZldgWm-SOgfSUvYiuX$2a40h|7?oAj$oZ;#ur|bD5l*oZL2n?X{j)%$ zkzXis3PyUWB2b4EKq9@8dV(g>C_i>0BTdYw_0ybD-g%}ZKVkJ9)Dv!K_3zYCoxzTb zKbzzgZ?wY?uX)ox1XO)UW^gw1N`hg-*o?dH^)c>RNIYHw^SQh=qg6f^s5vX>%w6YY znj`-#fG$wix5$5I)<+l@CTaX|_P%^C9H7fpEJZ?y{uTFpm8yi;Le-0vzDMQ1#m-Ff zRIRD>yS{F*MN?}Kp3-Z0V*ayMgM@({>@0~M5=Pu-Iuq@Yxt`?Q%Ry{8Ir6L6Q_#%E znyiXA=^8q2sySY5{)zH~OuSoB9oc;4RjtZG@8AMU-OUQv zsYji>$)PB#@WCA~0oVAb<0-ytl4B~T&~MvOe2p{3wksjFMjuU>BWk{K826)mU!(0d z3eJW|4zm)3!(~LzR9@#HyQs&{22fFVFum* z`1cnAo~8EhODFjn+J8wd60Fq$01IKL-yzhwK{DtW)u4k15U&UbNvMw+dW@fG8#m^k zV~Z9(19Fn6ya2wCzJPKb2^^%3jgm*7q-7-q-B5;Pzq~T?Zfs3dNL{-?84ZIwQQrg4 ztX2tkBHa6aE*ESdh#j`*H`XIATR2WV2_34k_tyZsYn8N`cG~v4bVM!ZxStL)tU%*7 zL!Z1_OB}ITO&8Eb;wCrLq52;_^~utM^yTY=aew?%`WmL2K6YhiJm zwddL>%Ahz3HiO8oG{t&HmI6;Z>6TU1udn9f|IHfcRGpIUq-`6%@2K0W1ks8n10OoNwM2SA@tdGTVByjLn zuw@T$tQY_Uvs1)x)M`ba1-4K77xR+nXaX8XT>6W3!k$A$U*Jy=Td(K>YE-yxBgE4# z+1LpF>N;xFpj2XbH3O&DK)18pBdvD=HqY&hiJIBWzw_&cb(dD=#>>#**f}f+@OpH~ z2wuqS-l*jrVCd6{N1kc?WP?LXwSJ@9LnLm?WuZjEdi_zcwFdBGq91s;F|v8fm;@t> z?{v-H;-1#&V~UNbe!qH$@{iY6VH0?eu;R5#Q-`@rElj2C(g%QfXx2KMW`KKzZetiT zNk$&7qC_P~)6(Fz6@kL4HF$aPti`c`Of~ng^U+dOYjyEpv(ey6(L1ZYAG98nf9#0QK9O`E3WN2QVw8BihNYJYN~4 zTr5bU9STHD@idBDOelp2NNefcR1&OJmi&Sr%#H>Sx=_O?`fLR9k%u%>OrwNTNPSk4 zOf&^8u1Vx)*J8*yYR-uk-P3o z8iezpiwubJnT{G~$HY?oG95u_&L&Y4U2tODLSWqxoFrj3xbR|}4tL9*Caoj*D=Qsd2!fS`f3kyJoGx7#g7%HATl3Cen{Riv7mx2 z(U4+T)Q)_dd&pf!R$@?JC;I)?MTTi~T;Vk}33;d2p3DtWlm8dc_>QX_)ehPoMe)F6 z0Wj(H1%IzmtDdJk25rh&9pRPah(_bY`PbNlCKc^^GTL>dqkX-JXW-}{P-&8rr&n_k zDJJ}JQ(9GFDzEfqzWemVB9ja*IXqn8jygNxt;r}(8T@44DuR;uNP&8z(Ek3efb^cCfi?Ast z?Q&I~UVDXAr~83CQCUSNsNHx~y;ksvn))l4{}H1rVn7Pxb8rCfC<5vFGf32JX9o#- zvDD<6V!ib~>-#!w2Gn8~%_hSTx~L~4K!ik%Qi+b}2bR_s_0fxo7m%ChpoTYZ&n@

AUYiOdY(BWrHQ5Sec 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 A@c;k- 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 27421f95ffd6fad43cfc3007af43fdbdf97cf47b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16875 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 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 d9a2627ac2cdeb714eca5e9e0a294f14471380ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3168 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 bk9haeV&D+>KPI^Q%)o$v6yN%B65J)Js>=G2 z_2)Nw+ZF82!mcQ?sx36p4 zz;cu2(}d{)Ru4AZ)_cFrcCKxk_O3Gb^plGJA9v+_3p7)l?aqivj~q7>KorG07kv93{x%_Bcr(X+l!PfuldVx^0c)^>?f6=fD%?GWe~*?bhS^ z8zKaN?&Jev6#|ABfD;c=*S%1;NhzhE zpkTvf3(kkDmfPkO8SWIR-`C~MHa)+h{kb%k|0%`Y77OpGRNl_-|9zRy>p{Ar_=U-- zhWt}Y?$wkVu5uGs{rtK?=7EykCzq3Frx2aeir0a%wT|AO(6DUKS$zyd<$GJ+*{L zjZ#Q{iBWL}p%cPH9SN3UOv3XIV_=WapOv-9`YV^@+}c6)4gwegnK12`wGg^F=(P~SoNR37pw&s}rh#S)jtw&; Z5kY`n$Om|{0@X7x2m#?^po=oVJOJyQ_74C6 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 4a89b145223b5ff9c04ad74e5610599d0cfeb42d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35033 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 O3AljU@dQW!fd2tOk-7c= 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 635a72f6cb6b6dd4e5030b8f0b3293dde0f9f5f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15536 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�k*KDAl5AZKq^ilt9s$=&lp^_kPXQ8EqzL}S+Wka7(G%?K03y4zVYsGRP8w3O0}RItLs{$ejiD4 z^TpnHa{}HcKdB8Zdy3{L$(MYmZRIg(t~^D!oAz1Ss?xG;#KhOtcStG9sj1Fi(?h>K zuR6W3LazOQ+nCG9P>(i@dc&7z38UBVN8DZHgk>AWmP4a56=W`<40YnE*iy*Sdvm2{ zFMjQ}sdjC+_%yL}KfI{v#Q)rp zV8jn<9xHCbHYf@~)g9Cw6QI12R1>G8dHYHoPVMvK)jeZT5O`-#sO`zW^3H``IVQ11 zfd}h?r*ldSEjEcndOm3N(C7)pk;O`k9WI=_k_N1cl(gWq=X1EcU!^rsk)TNg^z|C%r(ii z<_|6?h=BZGT3COKolfE#Va9|l>qUiC7**t|K}A^+6*dYo@D=`WD11;b&Krea?Fqsr z2&^AH8Rk~3?}Di9fz{>UAj+y$WBgn$|1Oq-G0d`+5t4;zSb_OIP=Yz&K*{3Fuf+ky zOE8UDz3hsa5Lqjj(O#Ah_s!K>c@c~R=S3E>_6NxC(jyqnL@m2z=0(;LVqD(9Ehs&L zx#0B3;v#>ByGE7-D=>-6&Y8iHwOW2cVZ9g$RsqLImWm`}Q2glmfK)aLm0)4;JYor3 zGZO7T6y9h$frXhu%g>eBxL7NP@#j0?RsHANO9~hYZeuLy$Pb~QrUphcQOkarjf}Mn cxLAJIXlu3ypd8Gbc^ID;n*bn;aXSF~2aUx&<^TWy 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 9e5d8b529abd3d4812484b1d08dc382f5f676748..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4573 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 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 a327a028987ed94e222c57f1b615dfc2a5873e46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4479 zcmWIWW@Zs#VBp|jU|?_rVg?2#FbNc5;ABWiO-n4zDbWuNVP#+}DEltN%n*Q6gCLTI zoXouJl*E!m+&aG}e9cb+TJ;!+#j)wk%}*)KNrl_|*l63yGuKb+oz&mNB)IG7O4pg4 zmNP>;RcHRJ+;#J(s^!c|(Or>8e^!2E{dshz=)TUGm1SW(VLmf|hP^SEyC|Sdt}9PF za?PQt0Oz$+K6t%ud{dHCz!dd_W%CKn-L8H=beZt@%m$nL3KEmE6Vp@UGxPPbOY)2K zic^a+6LT`F5=%1k^YpUwOMqcep_f&hpNBgT41(KZS{WG_RDpr+MxiAJ@gTeOvh#A` zVHP@ZqyKFOkz;Q^AKh2udBU4X^(<4;sg#9&Jr1oR5)v8OrCSdzwM}{#^?L<}aMRTN zeJhoXm4l~y+01S@QgDCo_v-oc-`{&!&yl+3qww=*9na4_Kju`I-1K$vrJK#(>vJ>b z==K}u|9esLPVlzXkp~LD7j`>;o26qO&NKJugOKUFRy6)wR3D#VeKlj+=@l(i%@^mz zD$LvY$mX+0TwQJFx!2eA-#&?q%q$gA2~PiMB6rB*#LjY7De-$xRCoR6UhiL0_UmVQ z+S+pq|2&#*x5fBdD4$B}yr)&K7Vg_8x+?qodnXPq!PN~%3>u!q7OAb+ct+@)ism^j z|Hl_3%TGV|SpCtS8Fz*; zCp*S*rzqF7Q?s;zSxOU#UC1}zARd;0k<-)TB!Q#}Nfif<{^;^HGmh8at+L-^y~S1y zV?*`LS{qf2?fvzw_p7KIug8~}^htIVZr|qLW!hP0^x|NZd-`+Iq*1G2ER49-FjSqLxcd( zoqRy7LckCMa9N2xjJq|B=YHS^)#Wy=>FxmnKmMNMQi@M%JTWE-<-O(wXrSj>ZNIF_9`#W zf3}~uywqX+gm)`$&g|d{516r0e4CNU_vw@0;3=4dkbF~=TAW{6l$=_OJA{G$hqoG# z1EM6cC_S}=NR3iRcEU6QYtYh)_zE)<;_Oz&YeZ&Va!zSVY6@{?A?ILB=RgWaYM7J< zY%Y|gqMF3WB*K8ZO$G{W1i)ya1$g7u4r-wxfFY0x(~j9*LpKM#y@oI+8=E<3tv7Vj zK)PWKO;GC!0l0z9Aq-cM)7C<_61}a3u(E(cE3ve^&@Bg92J9*i&1>o}@sE9xS8&<;J z!|fsTDj#8P8!_f$X#n7JIjFWrfVcQ!G3XGNkx-lSau-pTxgc0C3k}+7SWPApJ zs$>K(1NQV`^AZ7}zMDrf}A$6^eY>KdQBK-Dz@Y{F|GX622~WYnDb53kAS kRXOqIdH|h|9+HS4K(E*Xyjg*XjDbN62(JSpa}wC|024sO^Z)<= 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 8eb66ad6d0aeffcb752f49ee01d356b2494ccd8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 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 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 cd74f993ec0cb48dfe61baffc8f760012fcf34e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3785 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 ZB}_>U4gLGexL2Pyr~ts~)$0Jj{{g3S$d>>B 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 a379947107e77804faa916137cb619ab87d7f91b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4264 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 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 9e495c5044b04b4bdebec1f5b7e62107c4898032..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24770 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

w@7ujM!w#o0G0;wdR{z-=_?W^-)mVcklR5#i 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 D<30Fc 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 629031e4271723451d226d7dc3185022653cbb45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6132 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 bcO8Oovg72UeOQ9}WlsGMTMqT(0Kk6$rAwXd 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 0b80091ecafdd80471a8f9c28a146615cb44d652..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4283 zcmWIWW@Zs#VBp|jU|>)LVg?2#FbNc5;ABWiO-n4zDbWuNVP#+xShPflnIQnD20gjX(Cr_T) zeCni+mY2?|E52Sje!b_tbx!Jde?6nWZFACwq*G_MKWfn2{>c5hMx)w-=`+J8MNOGL zBYA0ux7&IJ^^GdJ`!%k29^}l}C48quz2x%Tf?0fcd}fW!eFcfh*@@|?@tOI0MX8B7 zxq8|8B|vZMW#*Nn7Ud=8=oRE7mZaqu<>C$lW9D8bZbk-%a3J=i%npNin00#DdAaaV z^PR9a>#&1Bi@$p0zMgsub>9N<@O3U;+nwxh2zWbo`B*77$?(lrRgQnG*Lu?X$$7Cfo_vh@7Cu9jkoW?7!52sagey9mq1;AReBGkR$VP zl0edgq>2Mae{^{(8ON{JFf~*U*SFrUqHgT3Z@gY*tA@TIJDy~$NrGc=dvCML@zb(E zuSo;3DJdoz#6xok4nK9z8{Gf6$82-Sn@IG5@nP@JT#LdWiF)n z=vIchT^x_2W$@dC+quW}H$(^koyrHqDg+Dxrk#A0A^_Qyn#S^Y+$V?4=%W1ml6YuY zhueis-2zWRuH(VwLKI^R;&Hl=5zT$p|5tx#1sb{^hy{?`mz$WEnU-2y0x#)G&lqwu z8S=C}-24CNqsC?~Ss9tULLv2qOQcM1n59pW`|&#<;e_r(s|)w>HMH5MNcbnVQ3@#mPn ztjesZU$?(#KC1Sc-*jz;Lb(a=x&;SCdD1SNDu2g}yNnS+a(hu~aeir0a%wT|@C5o6 zUJ)aEyCktFJ+*{LjZ#Q)7sn?S<(iogXSX6=BOv*i8Ybl>mSmQt5@(V!UN>dt zCFhi;q^6*n#mFQA%Ir9rIiS!+0E|XYfH!XKphgb@7y_9v?U+p0Mx|5a1|NN7jzrZn=c3(^C`0tM`H%vevo}IAA{;}1b77$CfUb` z6oYONdX0{-$d#EKi*VHM=+=U)f%y+qcO$?h60F7TE%cfkVZI6r@!rzMTCd~t3#jr& zfVsrjh1(P8RXM`o7epD1tzyULcu*yd070yH0}8jZ(W`WXk<$qniBZAhb1kS!M}SY* zj7F)M;ngX+q39Jn!ca>#EJ1)`C|Y%oZZvuok1%=?lF=9yKDsd=ZLr(|s%;U#j~(Gc zEOjqFgFtmJ0(2r71glVSh8Rd2%srr*7y%ApF$PP$jL%)5dKm#&fxUNFaR+l3!a(#| tns{^l@tTWXXX7&$RA(c=OuXh|*5Co&tiY7Oz#s;M2Y`Y16f|1F008OaH*f#| 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 ed256742287bb397a1ccb0a087f255661c73c958..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 140310 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�G%orI*8NOBp4GraL8D8i;K@NIDc0n_CPc*VP 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 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 250ee0826c6eb54758717bdd5ac825a894a83920..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1430 zcmWIWW@Zs#VBp|jU|?_rVg?2#FbNc5;ABWiO-n4zDbWuNVP#+}DEltN%n*R0AuddJ zcOFo~1Rxeb(vX{&mzkDYTmskV+hfSbWXN;i+n(02+@l5F%V$Uaa1d2+5}eL*Yszgi4(#RKr~T{AmteQE?MzO6<|TLj z_8A@ysq@Y1a{8LMZzJ3ChrFR@FYf=DrL;^vrIjDmksuGjd?kqF$ehf)?3BckMBLtM zmfpNp52)J+h{dt#%*{_J%}Is(@^R3%ljrnLo;-6|*Hc%o)Bnur)B2t|C(oSME<1Pr z?Ai05eb1cPy!lbvii^xE8PS3g*_Eo;>?%l1&Q45EjnB?60lHkT1Q@)yUH*7!(X@X+ zBeyUzFz6F&nn66sP`&KDT)2-~C+^QWY#?w6<%-!N*V42c@50^nTy&`*%6{yYVN2yi z5!XzkVh!#V=a}<-+otiF&P%%*C*2Y`Z+iZ(t(#4sT(~U!C~vfp93*xZPazl#^2 zp7yz=vK_iHAZ^H50b$HFBx5i$2D&-u83SRC05HM9oC7xp zEt{a52GR|46)5Q=Kn*t2@MZ*bv(YmG!t7JTnvEq}pxXj673Kp_(m((?W<+>kNhkr{ TtZX2~>_DgvG$9hGoPhxV=^L63 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 75ffad47207f5fd435e96a97850a59f000dd769f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5850 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 A(f|Me 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 7a42bd238c713a5a317998906bda18611ef56bf5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6101 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 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 7d489eb0cef11316fc145ef19cff8b32f925909b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 64456 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 II8g=QxuK7pExd@!c)tq@{7{q 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 I``kX%`YOUxVB%Gsqo_0wy@Y1? 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 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 0ea2d1c93e9cb21b2d4fd56e88a7c8dd190d40e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4097 zcmWIWW@Zs#VBp|jU|>)LVg?2#FbNc5;ABWiO-n4zDbWuNVP#+xShPflnIQnD20R3y9R#+S*QG|y^8IC0`$1zp!%{BWR%OpnCat9{N`=hV zZys88E-z)hceQiJ{R96S?;mJPJ~}bkUq*7hdG17up7eeB>FN7sPUYDz^MQYf*A5G& zj#RDUqpYVB3YER=i$1DMtPcESlx}(Z!A*^zkL=S9m6Xq^Xyjp2V_UDoaW4Cy=#Pye zU9y@B(zowwEBr1bDerLMd~3%yA-3S>UpH-KW;I^g^;}R~-0H0Ok9wgyywhBc2Rwfi zAh%)pVMXZ=9vh!X{OOy*+07F2b4F{_%x_L_J}DlKa_9~+(w_1~$)14USK0_ekU#G0GmMCjp_;NYtC<~IU)C6d)hQBweFKUPbeQ<+VC?^C25+9>(6&b zn;lMuYdwv)V|-6nWNCos&AN{oO6M+5;bh%<+>5VrPw#iWn|)pf0f@D{M4N@yMjXBX}ok?jyt??>$9J=?)S@Yi`M?Xsg$c=c=?O>E}zWm zr-{AiB%7uj(V1(1ns4^O9UfElwyd^#!@TzU8uR$M{=%*?uP2(%@BVQm&Na8etkJA8 zQ2&RS|vMs{R5UI?p#T;?p>c{^Jm>6wV+cG z?`}T6mU}tr))wBg)pOq8yU286cj^q6bFRv3mrm?A@;mU0m-n-DjP-x+c}}e%uj?66 z%OIC$HG+YR3=AKEnAEIhh%>8A@b^DtAaQK>#lO{#D-N?b*;GDq=~y0nHSqw8O4G&J zD;Kx4-JG=6Sgtevp#JlTo89djWt$HiJbd~4%k}y5&Gc(#2F&Ga+uJ_9z(Df8f0g15 z@0Ce<2gE`?mb2-Jcd0fVd(gWrWsT5^Hk;T$QQe41Wz)F!D5YGfZgQJquqxujGn4MC z3;E|L{a{v@)OkikT&MNd*?aP(^F#%mPaPG^ee(PNt+|mO#X>4(efJUn^^0dtwq~*$ zt4Ww)Xz|QFv8i5aKAWfXewBRSeo!=G{l=bY5ALQZ1;uo&t7lg;WZ2y?qsr8SGv~U> zj!u(u(}PDYA5mK18K-@_%Of}3zkKUGm$hr23aT6Y%3uy#?RIMVj5%9+59lnVWHvD9|)vAl4(sBm;Okj$E`pP7+9(kW_Kt z=#Q>$TkHKQ>c;Dt@YEA>*la4w&o7CGRu;G&+|(`bln>}w9w1gBXpBKTN)3SQOhz>K zsh>9OSqwCk1(**6kPOaE%*#wmEiQpqBfWiw+=mPVTEG7h<6UO4VuIs^gO2KjLWj1_ z`)cC*>S@s5pJA&Wy!iH@`u<}_&vnjzBF&~%%hXq$dD7}sw3gk{J?~B4&igXgR(1J$ z%l%w#f4w#Eh+d$qgVE#Zs@ZO>Spg4NnCy>*NM8+K>8yUh)N7MgtWr1kg#~+EKZ?wI ztW|X8FSpP8j=X@d1N>ak3l!hv6FXO_PKYPD2;Z7~cJ}XHqN>44p zQwsw_1D?#Jkb(rJF*Cn7KCvj*%mjCIAsd05QZbEyG!KX~Ng1zWGV_viN>fr(h%*bn ztMU>{GRsnlGf4=^RYj@A`K3k4=xrQECJ_eQ?L|;vApl0}F~A$Qc2MgP0Stjmn0Cyz zB)U21ZApYV+1SiMYgwY32GR{Hn?UVB1o#SMVz`Q!)*-qT=&eJ96<)y1ONtdZ+luH` zqSulLE4Q+cW+l!#6WvOX6|jH=B~k>)WFu@PBFUngi(cd-%-u|kx!5ZJbW1>n!W<7O z?GZqh9gE|!m;LC5qL=*$LsJMEidGY#8;xH2BaGgQWHd%yfNl&(8_e6Fq80(B1G|W@ zlnE>R;8hAfV?YHj0$jji43=UTpMj`pNf>B4y1Nhtq8G~e%mo$72oQ_cT+AXGpUJ2$ pUWwOa^a2~7xu60Y0X`#{i>3Gu@MZ;O4+aK7AlwV=mg;eVcmV$d(cu69 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 9273ad959ced1a28102dcd8e595c5946f3a70175..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10565 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 Fe*k(2FM$96 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 7fea36f1ab4691a42851052d58dbddf68173ffdc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10627 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 QLw$IuK3rkH`1$Go0WneX6951J 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 7073571cf1d711ff187c2dfbe70ff58640227105..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4261 zcmb`J3pmsJAIE<)Qb_KM(%*55#n#+!Nr;)-kxRLc%_U}Lo6|v|)Jao>DV55#D3^(l zh>o1xoi0c^IyqEQgs2>Ke*6FbGmS~lbH00id$v8#=k@t~zx#aOpSL3h$}0f?Yu5q* zpa4Dq!1LdC@F!p$fWrr1si72vi!&bp5y{CDg946R14I`Ega(I&;II@d_sI1%wP|Kx zDlhPnUM`YA#8E@>3z>&dH(C%jy=^ucXmy+Rc6d))C!(dL{c72v`#04Rl?#_>((NFt4@_4Hi|rNaEQCz%m_SbLc-l5MV~o99PF(B8Lsg+ z_SsVjL(CN7KT=NcEKSLZf>A?_$qh}Wk~KW<8e}~(3D$e?PO6qQy!r@T1FyWuIND=# zMU3>bw^@x@uTUMO+2q(Lvw06{L~gd)d)qcA9~JW&DDix0WxLLVy1<8VT21>=Nov0C z4Q!>Rdf{ZhmG0JtqTIIQs#{58<|!9qy~3<#S7byw9dvQ7Upj+UF z!>-ltO8Pyh@1ey|Nnx&bI_$o%MU32FtFn>!>Fx1Vv1Q>T!@={>rF$Ll1=%oja}HB`D=Zxp2SlE=LMgmjCLC2fsGw|q>E%M1h}OT)|0 zT@%v?2p$|B_2xU6exhuH5D#*u)zR1LoFC?D?N|~jik$bCQ_~p)+JCCN9(LnKp3AAC z-S@3KM)sr{LOypfsxr^YwSUGdW+A%X7{@oNA!*@ITiT>e@+JSA-GAy(WU5g7DR$mg z_H|jI`7u>}FK&zrUZ7(+c`hR9BE=zV5R-qi+HR!M)BvlfmiGz>kYDmZhfm^IRLw+KDDSrDK z?tVB)4j$?k0RRwL1SVj^f&=hm%7V($T|2^_Acg8*s7>=t@X+?NnT2bdt|{puX2=U${*Vg zJQj2_K%(ephJE$&tmGg4-cZq;)To)_m7LVpkg?g6GJF3iLD4|{HBVok_|u+PjH8t& zoL{3fxpkLuRaiQt8sgeEl%X1{8uFrjX39(THv#I~C}O!w!B)F8!fe?{x0uBA zXqw(98`zw5E6Kb2*4$=Y#D}1k&=^mYegFHs$ydg59x8NF1>Fn{tIII+85I1yKEiu< z<2Y}0VVK`k$ZOl9b+fz&^(s5%w&Qihohz09XzBL<>jVQ1)7MQG`P_X)hIZ%S#|?fp zU-$(drKYFtou==65oCYlOg6u%Ma~bSy#|@DN_Jvx8v}M3$Qr~X7{m)*$S7-=zMOJl z@0h6RK7qlMu(P=VQ7RRpiF&wof=`lbtJ7ov!M&}*6I)AoRO!UZ>PrRHbVIi*!}arz z@21owxNW)H3Dtq(oJk#~vI4p5TzY#Q$2KO?R6XrxBi%?AL$=)|XtdFFQ*m+aH6l+9O(KnY4o-g@i`Hu)L_BzGs^l4o96d4j zfHG4&l+he9r$65de`s2-J~@59dOqFrCtv$OS;Hfq(RVj8(@nr6| z0lWkYLa?}LDcFcWJY|)EvWs%E0)vTUA8Z6c3$?o3iYp?5!~8?3I6Q83ti^)1w4DFh ztAB$OhNT2Ya@aEnj|9No-7-&`y_R@gM-2CHrq^Y!ZQw^%_)_P~$>DUqY);5>91b7M zNn=K{W(U*Avezj1aYmJP>3P`clA+&|j*A20Ovl8t zj*r=??DZ4hx6`rvmXpglh}qo6)o_;|%bZD=P*(BG8O&brHOmyg{6yx2a!zD6G~%03 zj`_@q=A6lFblW2Ik_pX;VTQ4`%@kPnvIM`dI&vAgT?xdLTlR`u1Y(KSf@4?-!xUfk zs#ylJj1;c~WJ)o6{kba>aaydoY5yJ;|0sx4D Nuj}AN833aJ;2(?aBz*t? 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 36477fc6b519f2779d211c904ca56c9b48e96fca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8926 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 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 514eaa94b985a16cab370f5cb7ffbdf067954674..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3476 zcmWIWW@Zs#VBp|jU|?_rVg?2#FbNc5;ABWiO-n4zDbWuNVP#+}DEltN%n*R0A=mJQ z;R2wBzd$U2q#-vkFEcH*xCE}Tw|6(!AqN51-+#q6s~mJ{(bdt>*&?K!IP-{M-#g`V zeoa5V>bV@f^lM(Ro=J-P6Bol7Uiu{~XEn|V@Yq(a@chKRH(|feCa%5pqPplr{Jv|c z&pLA#={4~5%-5Wm6{g+Kq%QEoa>i?~)G2OTnC>i8S$s)#(+vi>v$|c2KWGRUH7-t9 zOV+qG*z|?nVpt1LpxPx{;b?}@~7*{kChH(k*o9rcFvuu5i(b<%SzjF&7rE9m8vUUD?2UG z;s)6h`q-ROkeHmEn4TJ+onHdM<~k%3_~5Id1> zu0cGCJ$l)Bx$w{k4cza4$U)+5?CsZIMLz^B&Xk!u$HV>lqeY5=OwAY99+32#b*-R1 zQ2npCWY6Nw%99tnvq~?2|M$$$4+tD#>~Hc znIa>@o|(7Cdg=-2Jc(6QXIoPr_9ROARln5s%L#YqO20{u3KyO#ZMj$K{_UuEJLbyI zRx)17r=@=NS8klL?bCBJx!18VX@{k^u$?Yk_{GO>GrQ>hCN6flau&wNE*Fk$?8@G# zy(i`Ij;6?G-3K=LHeJ6ieq-0f)P)&Wvox%}mbQA#QU1>Sq~gH+44*kI-y+^{{%_%9 zemMW~b&Z(G&KIsQcWahkX0Z8Y;puTIY{8uM7an#Tylwc{WAWv^y|;e3{os7g&3@Z( z*Z*x1%@^cxr&J4)BSSAUuOzi7FEIyqYn`K1~&>UFo=iQg%abjlf8p( zI|#IW-6W@Lf9z005(kS%Q}~oeQ>UqgbZ7>(FzGFrblj~~XQvx;>*iC2MICR7X6i(y z&*WBFd+~-^q|e{W@&E7I8fzVtmrZc`c~ZCc{R@sO(gyE~=BY_u>67e!7T~=}u$JYo z&f^Dh%LL`0=Twy(KUg*Yxn{4?iqpPN?dN!&|Jrlxq($d?P47h}Kb>y6o?HJyMbjeH z;?$uJW+&AW46Xb!&m6R|_guWFq1oa**SUAe`&QYWy?OjeW19JAN!zr^Olw}v=)a(` zMDvm7yvFK%T11_^sTL9aCbSBe(BZIsK8FU{*SlBzh`#ka>=l&Xs&x9 z5pUbQTI|6Fo9By;{a)7OW@3=r`eL8b7PYIBvb*rWvY1f2z{_mz z`Oanrt0wz;#&fKltSgy+I#1s)YqG4P?{&s%<%jKrNZ`OSUH-GM_jaGlM z>>F=>?NFL&XP{EtDSVCXS%7A9?xh>sT~=m&_kHyBRikti^YT+=CvF-2o>LYU5`Up| z(>|M)``K;1tncg&XD6;c#Qx;!Cic9LP1nQCFBDxUKJ@kAvqj&&Zu-W1&rvo@HE-f} z4!&xYE3NOh|B|&oAHaI~a*gVN+650U6-w^NQr-Ub0MqU>zq1%TVqb1M;1J~6AbHz3 z=W2Pg>vlGVtexANmZj(}G`p5{aMq3O%>1)fuuNCq!{Wb1y-4;=tMQYcR*uirugrJ& z$!h)Sz0I%Z&uyaq1lBX+&SFAHIiM)DIKQ+gIkgyf=0Prkk*mj&#G>@n5+XH9A=wGj z2rRElE8;86Oo+2v8LttUdC57YDXA&MnWc!=EJ$5K4U_VK)lgX~s!5DYA`G}&E1=Lu z0F1UvfH$gk^tKB^t09mH(~i-SK{p1Z4Y{p=FopwILBfo|Y&oEtgWhsLnB#=a9JF=> zx@pL+0#*J9a2A_sL^cJ`Z9#7eAZ$?u1__4W@!Nv0VSsKiviCsM9s+D)Ce>mrl_0ux z=#?PCI$>b7NSZ$p*5RlU(Je>zE2#WI08w^?A-e5jPI2FAVBi`I3yyl{pX#w7> TY#=xB0pTTJN?HTzhA{vDjK4rp 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 0ad0396eced933873aed97d56aba833151fbbfb8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36342 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 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 36a82e5f7b244a7d86912309646c4690c44fa554..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24269 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 Bn)3hv 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 8404658719259e258ff3761728116e22edf7a29b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4077 zcmWIWW@Zs#VBp|jU|>)LVg?2#FbNc5;ABWiO-n4zDbWuNVP#+xShPflnIQnD20Y2##GWFTiC1OuipII1MH1-rYnH}Q1 z%z?-GCfFQNkeHmEn4TJ+UzDzwo1d4SpOU1Pom8BkrZT51Btp9=s}JbXAUDVoWQSOpJICb!xZft8B)7T0MIN z8J31UExpaPWYT`7$4eVmtXQ>j)jiHV!JW56CQRG4Q!A?Rh2^4yKCh!hESi4Qsd_1E z6-@oX7N~kE%p`1L?V)P|dM!Wil=d#ETJ&i9?RDat1TW;L#Gbwy<9nW!FT^pwf9qkH zMMvaJ!sT~yAGr58|G>rlZ^BZi*0P?GO;~uCv+9s$)v4GI&eJ#FalCf;&SSf0W^Fsy zezRD>U)d9Jg!>Lt9)K5>zM4@MsQEd1%|zekKVHHY8-i?z8ip~iph_WAaQ5_r!s z^AvtQw)?1bd9`_6)NbB@Y5y2;XCQ5o5((~v_fh4!hCDD^$pWz%X=WP4LsB<#Vtbq< zkTfBw;=s`#UET)!HDdM6?e%T#RXz2sS6bNfvJxOBl0;@y_}HDvL)Q4X8w zMfv$9@zA0Rw#LFVbRq^7<78%xm+9}XLk9hToiq&@Zz=hZPr&j zthYRmJbYJS^Ru%lNXVS+g88|P)%)yi_SwwPym-7t&+$m#=LF^Djn(<*<1F^x)9qKD zroJGhPsW{V#$DFU0?XHJT$`J9`Ekd}>3ON*VTChUug)|L*!7OZ*l~gXg+;gK{ySn- zbWGQK3;&bVmQOgZK5y#$J8jy6{nbm1;tM|hm|oHOQ`zjn%V!^Mov==i-Mw2kE7Y>p zW`S3D0=wn5-&ylo9);93erDUfty(y6A$#lDNqcj7a$m1IeN7*CZbuH8lEk9))RJP{ zaRm%bc*QJ)6r@F|#rdU0$*DwYl|s@AvokZlI6ko`*UW@C+ZFK|k)2-xtoo^8QeI+7 zW?3q6CMn}}Q)XUrPH9SN3aVL*Od<@p8$O`$MgWXvP=GgX?Vx550vH0BFzuL)B6M@m z8$}3nvay+i)J?$=TY`q7 zRkr9xqgSv9qutmL0gO@Iq8kI!21{(9+6Vy>*b&CS>IrzQjn5cR-Gl%$k&MAoQ{giR zR8t|q6(oaT?!i)T;WH3aZy|sQrHxaM7=ye)Cb3t_)0<6YsE@llE X;LQq52@Jp*PK4nBko6tZzh?jd=$t`_ diff --git a/README.md b/README.md index b91c58811..9101d9aec 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,17 @@ It's recommended that you install the latest stable versions for compatibility a 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 15.0 or higher) * [Command Line Tools](https://developer.apple.com/downloads/) -* [JDK 19](https://www.oracle.com/java/technologies/downloads/) +* [JDK 21](https://www.oracle.com/java/technologies/downloads/) * [Gradle 8.2+](https://gradle.org) +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`. + Also, it's recommended to install the following plugins in the Android Studio directly: [Kotlin Multiplatform Mobile](https://kotlinlang.org/docs/multiplatform-mobile-plugin-releases.html) - @@ -103,6 +109,155 @@ 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: + +- `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: + +- `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) +- `APPLE_CERTIFICATE`: Apple certificate for signing +- `FASTLANE_MATCH_SECRET`: Password for match repository +- `MATCH_AUTH`: Basic authorization for match Git repository + +**Variables:** + +- `APP_IDENTIFIERS`: Comma-separated list of app bundle identifiers (e.g., "ac.at.lbg.dhp.more,ac.at.lbg.dhp.more.More-Notification-Service-Extension") +- `TARGETS`: Comma-separated list of Xcode targets corresponding to the app identifiers (e.g., "More,More-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 + +**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 @@ -293,4 +448,3 @@ Apache 2.0 with Commons Clause; see LICENSE.txt for further details 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..56fc04f3b --- /dev/null +++ b/androidApp/Gemfile.lock @@ -0,0 +1,227 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.2) + aws-partitions (1.1107.0) + aws-sdk-core (3.224.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.101.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.186.1) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + 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.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.227.2) + 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.12.2) + jwt (2.10.1) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.2.1) + nkf (0.2.0) + optparse (0.6.0) + os (1.1.4) + plist (3.7.2) + public_suffix (6.0.2) + rake (13.2.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.1) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.20.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.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.6.9 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 75805af29..0db29fb58 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,3 +1,6 @@ +import java.util.Base64 +import java.util.Properties + plugins { id("com.android.application") id("com.google.gms.google-services") @@ -6,6 +9,37 @@ plugins { id("com.google.firebase.crashlytics") } +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}") + } + } + } + + 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 @@ -28,6 +62,54 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + 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, falling back to debug keystore") + this.storeFile = File(System.getProperty("user.home"), ".android/debug.keystore") + this.storePassword = "android" + this.keyAlias = "androiddebugkey" + this.keyPassword = "android" + } + } + } + buildTypes { debug { buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") @@ -36,6 +118,19 @@ android { release { buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") + + val releaseSigningConfig = signingConfigs.getByName("release") + if (releaseSigningConfig.storeFile != null) { + signingConfig = releaseSigningConfig + } else { + println("Warning: No signing configuration available. Using debug signing.") + } + + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } compileOptions { @@ -47,6 +142,8 @@ android { } } +// ... rest of your dependencies ... + val composeVersion = "1.6.8" val workVersion = "2.9.0" val navVersion = "2.7.7" 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..575aefb9b --- /dev/null +++ b/androidApp/fastlane/Fastfile @@ -0,0 +1,231 @@ +# 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 + + desc "Run all tests" + lane :test do + gradle(task: "test", project_dir: "..") + end + + desc "Build the Android app (debug)" + lane :build do + begin + setup_android_signing + + gradle( + task: "assemble", + build_type: "Debug", + project_dir: ".." + ) + + 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 + + desc "Deploy to Google Play Beta" + lane :deploy_beta do + begin + signing_configured = setup_android_signing + + 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}") + + 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: "beta" + ).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: "beta", + 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 + + + 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: "beta", + 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 Beta") + 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 +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..50bcb041b --- /dev/null +++ b/androidApp/fastlane/README.md @@ -0,0 +1,48 @@ +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 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 + +---- + +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/fastlane/report.xml b/androidApp/fastlane/report.xml new file mode 100644 index 000000000..104063f34 --- /dev/null +++ b/androidApp/fastlane/report.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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..56fc04f3b --- /dev/null +++ b/iosApp/Gemfile.lock @@ -0,0 +1,227 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.2) + aws-partitions (1.1107.0) + aws-sdk-core (3.224.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.101.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.186.1) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + 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.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.227.2) + 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.12.2) + jwt (2.10.1) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.2.1) + nkf (0.2.0) + optparse (0.6.0) + os (1.1.4) + plist (3.7.2) + public_suffix (6.0.2) + rake (13.2.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.1) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.20.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.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.6.9 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..ff1f42416 --- /dev/null +++ b/iosApp/fastlane/Fastfile @@ -0,0 +1,123 @@ +# 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 + before_all do + 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 + + + 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 + + xcodes(version: "16.4", select_for_current_build_only: true) + end + + 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 + + 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..59301b00d --- /dev/null +++ b/iosApp/fastlane/README.md @@ -0,0 +1,48 @@ +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 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 + +---- + +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/report.xml b/iosApp/fastlane/report.xml new file mode 100644 index 000000000..ce7a14c2b --- /dev/null +++ b/iosApp/fastlane/report.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 6b91dc41f..6fc2ce0f1 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -432,8 +432,6 @@ 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 = ""; }; B79DBA5029D3431100A1F547 /* DashboardFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardFilterView.swift; sourceTree = ""; }; @@ -444,12 +442,7 @@ 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 = ""; }; 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 = ""; }; @@ -459,11 +452,9 @@ 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 = ""; }; @@ -521,22 +512,6 @@ path = Bluetooth; sourceTree = ""; }; - 1F0BFA2D29B8D399003B3AB9 /* Recovered References */ = { - 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"; - sourceTree = ""; - }; 1F27536229CC39CA00324417 /* Services */ = { isa = PBXGroup; children = ( @@ -864,7 +839,6 @@ 1FC9574C2C072B7900EB92D6 /* More-Notification-Service-Extension */, 7555FF7C242A565900829871 /* Products */, 7555FFB0242A642200829871 /* Frameworks */, - 1F0BFA2D29B8D399003B3AB9 /* Recovered References */, ); sourceTree = ""; }; @@ -1604,9 +1578,10 @@ CODE_SIGN_ENTITLEMENTS = "More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4.1.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; @@ -1621,8 +1596,9 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "ac.at.lbg.dhp.more.More-Notification-Service-Extension"; + PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1641,9 +1617,11 @@ 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_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4.1.0; DEVELOPMENT_TEAM = VX2DSGURUH; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -1658,8 +1636,9 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "ac.at.lbg.dhp.more.More-Notification-Service-Extension"; + PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1705,6 +1684,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; @@ -1769,6 +1749,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; @@ -1824,6 +1805,7 @@ ); 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; @@ -1839,6 +1821,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; @@ -1864,6 +1847,7 @@ ); 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; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index fc184701f..410274514 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.0.26 + 4.0.22 CFBundleURLTypes From be7bf824b5888113baf869723e0291272114e6c6 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 22 Jul 2025 14:48:28 +0200 Subject: [PATCH 30/59] Update deploy-beta workflow: trigger on pushes to `develop` and `main` branches --- .github/workflows/build.yml | 8 ++------ .github/workflows/deploy-beta.yml | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ee175590..0632a0188 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,9 @@ name: Build apps on: - push: - branches: - - '**' - tags-ignore: - - '**' pull_request: branches: - - '**' + - main + - develop jobs: ios: diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 1f761b0e7..4e097b562 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -12,6 +12,7 @@ jobs: name: iOS runs-on: macos-15 environment: AppStore + if: (github.ref_name == 'develop' || github.ref_name == 'main') && startsWith(github.ref, 'refs/tags/') env: FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} APPLE_CONNECT_KEY_ID: ${{ secrets.APPLE_CONNECT_KEY_ID }} @@ -51,6 +52,7 @@ jobs: name: Android runs-on: macos-latest environment: PlayStore + if: (github.ref_name == 'develop' || github.ref_name == 'main') && 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 }} @@ -70,4 +72,4 @@ jobs: run: echo "FASTLANE_BUILD_NUMBER=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - name: Deploy to Google Play Beta - run: fastlane deploy_beta + run: fastlane deploy_beta \ No newline at end of file From 2162c83261ca89d14ddf3d07fc9fc0a7f8c23d4a Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 22 Jul 2025 16:01:53 +0200 Subject: [PATCH 31/59] Simplify `deploy-beta` workflow trigger to only check for tag references --- .github/workflows/deploy-beta.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 4e097b562..8e3bf75e9 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -12,7 +12,7 @@ jobs: name: iOS runs-on: macos-15 environment: AppStore - if: (github.ref_name == 'develop' || github.ref_name == 'main') && startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') env: FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} APPLE_CONNECT_KEY_ID: ${{ secrets.APPLE_CONNECT_KEY_ID }} @@ -52,7 +52,7 @@ jobs: name: Android runs-on: macos-latest environment: PlayStore - if: (github.ref_name == 'develop' || github.ref_name == 'main') && startsWith(github.ref, 'refs/tags/') + 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 }} From 1c7d413a77f90cadf4e15845333d69c688114c67 Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:48:03 +0200 Subject: [PATCH 32/59] 293 android api 36 upgrade (#296) * Upgraded Android API version to 36 * #293: Upgraded to the latest Android SDK 36 For this upgrade to be successful, I had to implement more permissions Add permission handling utilities and activity lifecycle integrations for observations - Introduced `PermissionUtils` for streamlined permission checks and requests. - Added `ActivityProvider` to manage the current activity reference safely. - Integrated permission handling and lifecycle hooks into observations and related components. - Adjusted permissions for API 34+ where required. - Validated and normalized endpoint URLs during storage. * #293: Remove unused code and simplify permission handling logic for observations --- androidApp/build.gradle.kts | 6 +- androidApp/fastlane/report.xml | 16 +- androidApp/src/main/AndroidManifest.xml | 6 +- .../consent/composables/ConsentButtons.kt | 30 +-- .../android/activities/main/MainActivity.kt | 22 +- .../observations/GPS/GPSObservation.kt | 19 +- .../HR/PolarHeartRateObservation.kt | 21 +- .../ObservationManagerExtension.kt | 69 ++++++ .../ObservationServiceExtension.kt | 50 +++++ .../android/observations/PermissionUtils.kt | 137 ++++++++++++ .../accelerometer/AccelerometerObservation.kt | 21 +- .../services/ObservationRecordingService.kt | 197 +++++++++++++++--- .../more/app/android/util/ActivityProvider.kt | 37 ++++ iosApp/fastlane/report.xml | 15 +- iosApp/iosApp.xcodeproj/project.pbxproj | 4 +- shared/build.gradle.kts | 2 +- .../services/network/RegistrationService.kt | 4 +- .../services/store/EndpointRepository.kt | 8 +- .../util/StringUtils.kt | 44 ++++ 19 files changed, 621 insertions(+), 87 deletions(-) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/util/ActivityProvider.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 0db29fb58..64ba788b1 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -42,11 +42,11 @@ 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 + targetSdk = 36 versionCode = 26 versionName = "4.0.26" } @@ -148,9 +148,11 @@ val composeVersion = "1.6.8" val workVersion = "2.9.0" val navVersion = "2.7.7" val polarSDKVersion = "5.6.0" +val ktorVersion = "2.3.12" 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") diff --git a/androidApp/fastlane/report.xml b/androidApp/fastlane/report.xml index 104063f34..8ed4e9fc6 100644 --- a/androidApp/fastlane/report.xml +++ b/androidApp/fastlane/report.xml @@ -5,24 +5,12 @@ - + - - - - - - - - - - - - - + diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 32c5c382f..3b6deebb2 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -40,7 +41,6 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_more_logo_blue_round" android:supportsRtl="true" - android:usesCleartextTraffic="false" android:theme="@style/Theme.MorePlatform"> + android:foregroundServiceType="location|connectedDevice" />
- \ No newline at end of file + 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..da7b42bff 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 @@ -33,13 +32,13 @@ 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 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.observations.PermissionUtils import io.redlink.more.app.android.ui.theme.MoreColors - @Composable fun ConsentButtons(model: ConsentViewModel) { val context = LocalContext.current @@ -133,17 +132,24 @@ fun checkAndRequestPermissions( model: ConsentViewModel, extraPermissions: Set = emptySet() ) { - val permissions = model.permissions + val permissions = model.permissions.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() + ) + 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 +163,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 +177,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 +201,4 @@ fun checkPermissionForBackgroundLocationAccess( } .create() .show() -} \ No newline at end of file +} 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..2045e4056 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 @@ -53,7 +53,9 @@ import io.redlink.more.app.android.activities.studyStates.StudyClosedView 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.tasks.TaskDetailsView +import io.redlink.more.app.android.observations.PermissionUtils import io.redlink.more.app.android.shared_composables.MoreBackground +import io.redlink.more.app.android.util.ActivityProvider 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 @@ -62,10 +64,28 @@ 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) { @@ -457,4 +477,4 @@ fun MainView( } } } -} \ No newline at end of file +} 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..3e7ec284e 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 @@ -14,6 +14,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.location.LocationManager +import android.os.Build import android.util.Log import androidx.core.app.ActivityCompat import com.google.android.gms.location.LocationResult @@ -29,10 +30,18 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch private const val TAG = "GPSObservation" -private val permissions = setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION -) +private val permissions = if (Build.VERSION.SDK_INT >= 34) { + setOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.FOREGROUND_SERVICE_LOCATION + ) +} else { + setOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) +} class GPSObservation( context: Context, @@ -124,4 +133,4 @@ class GPSObservation( 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/HR/PolarHeartRateObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt index eaa2d5ff9..889895a19 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 @@ -35,11 +35,20 @@ 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.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, @@ -192,4 +201,4 @@ class PolarHeartRateObservation : hrReady.update { state } } } -} \ No newline at end of file +} 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..f8ffd1011 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationManagerExtension.kt @@ -0,0 +1,69 @@ +package io.redlink.more.app.android.observations + +import android.app.Activity +import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.observations.Observation +import io.redlink.more.more_app_mutliplatform.observations.ObservationManager +import io.redlink.more.more_app_mutliplatform.util.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() + } + } + + 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..638d84738 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/ObservationServiceExtension.kt @@ -0,0 +1,50 @@ +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() + } + } + } 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..3be145e28 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/PermissionUtils.kt @@ -0,0 +1,137 @@ +package io.redlink.more.app.android.observations + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.observations.Observation + +/** + * 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: androidx.activity.ComponentActivity) { + val permissionLauncher = activity.registerForActivityResult( + androidx.activity.result.contract.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 { + val permissions = observation.observationType.sensorPermissions + return hasAllPermissions(permissions, context) + } + + /** + * 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() + 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..b42bd954b 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 @@ -10,11 +10,13 @@ */ package io.redlink.more.app.android.observations.accelerometer +import android.Manifest import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager +import android.os.Build import android.util.Log import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.observations.observationTypes.AccelerometerType @@ -24,7 +26,22 @@ private const val TAG = "AccelerometerObservation" class AccelerometerObservation( context: Context -) : Observation(observationType = AccelerometerType(emptySet())), SensorEventListener { +) : Observation( + observationType = AccelerometerType( + if (Build.VERSION.SDK_INT >= 34) { + setOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.FOREGROUND_SERVICE_LOCATION + ) + } else { + setOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + } + ) +), 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 +83,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/ObservationRecordingService.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/ObservationRecordingService.kt index e6cd5604f..7e1fa3a1c 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,21 +10,26 @@ */ package io.redlink.more.app.android.services +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.Context import android.content.Intent import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.SystemClock 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.app.android.observations.PermissionUtils +import io.redlink.more.app.android.observations.showPermissionAlertDialog +import io.redlink.more.app.android.util.ActivityProvider 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 @@ -33,7 +38,6 @@ import io.redlink.more.more_app_mutliplatform.util.Scope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -70,23 +74,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,29 +100,92 @@ class ObservationRecordingService : Service() { SERVICE_RECEIVER_RESTART_ALL_STATES -> { restartAll() - return super.onStartCommand(intent, flags, startId) + return 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!" } + + // Restart the service if it's killed when the app is removed from recent apps + 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 + ) + + val alarmManager = + applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + + Napier.i { "Scheduled service restart after task removal" } + } } override fun onDestroy() { Napier.i { "ObservationRecordingService is destroyed!" } running = false + + // Attempt to restart the service if it's destroyed but has running schedules + 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 + ) + + val alarmManager = + applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + } + super.onDestroy() } override fun onUnbind(intent: Intent?): Boolean { running = false + + // Attempt to restart the service if it's unbound but has running schedules + 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 + ) + + val alarmManager = + applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + } + return super.onUnbind(intent) } @@ -165,16 +232,39 @@ 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(Context.ALARM_SERVICE) as AlarmManager + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent + ) + } } private fun restartAll() { @@ -246,28 +336,81 @@ 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: android.app.Activity + ) { + val observations = + MoreApplication.shared?.observationFactory?.observations ?: emptySet() + + if (observations.isEmpty()) { + startWithoutPermissionCheck(scheduleIds) + return + } + + 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() + pendingScheduleIds.removeAll(scheduleIds) } - log { "Waiting till app goes into foreground to start observations..." } - delay(1000) } + } else { + startWithoutPermissionCheck(scheduleIds) } - if (validToStart.isNotEmpty() && MoreApplication.shared?.appIsInForeGround == true || running) { + } + } + + /** + * Starts observations without permission check + * @param scheduleIds The schedule IDs to start + */ + private fun startWithoutPermissionCheck(scheduleIds: Set) { + Scope.launch(Dispatchers.IO) { + // Always start observations regardless of foreground/background state + if (scheduleIds.isNotEmpty()) { val serviceIntent = Intent(MoreApplication.appContext, ObservationRecordingService::class.java) serviceIntent.action = SERVICE_RECEIVER_START_ACTION - serviceIntent.putStringArrayListExtra(SCHEDULE_ID, ArrayList(validToStart)) + serviceIntent.putStringArrayListExtra(SCHEDULE_ID, ArrayList(scheduleIds)) try { Handler(Looper.getMainLooper()).post { if (running) { @@ -279,8 +422,6 @@ class ObservationRecordingService : Service() { } catch (e: Exception) { Napier.e(e.stackTraceToString()) } - } else { - Napier.w { "Application not in foreground" } } } } @@ -321,4 +462,4 @@ class ObservationRecordingService : Service() { } } } -} \ No newline at end of file +} 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..f02c48ced --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/util/ActivityProvider.kt @@ -0,0 +1,37 @@ +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/iosApp/fastlane/report.xml b/iosApp/fastlane/report.xml index ce7a14c2b..01f043458 100644 --- a/iosApp/fastlane/report.xml +++ b/iosApp/fastlane/report.xml @@ -5,27 +5,32 @@ - + - + - + - + - + + + + + + diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 6fc2ce0f1..e7600ca8c 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -1596,7 +1596,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more; + PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more.More-Notification-Service-Extension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1636,7 +1636,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more; + PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more.More-Notification-Service-Extension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a3fc65b33..f431fd5d7 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -73,7 +73,7 @@ kotlin { android { namespace = "io.redlink.more.more_app_multiplatform" - compileSdk = 34 + compileSdk = 36 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { minSdk = 29 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 index 7c701bff2..115ad9a59 100644 --- 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 @@ -22,6 +22,7 @@ import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Stu 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 +import io.redlink.more.more_app_mutliplatform.util.validateAndNormalizeUrl class RegistrationService( private val shared: Shared @@ -45,7 +46,7 @@ class RegistrationService( StudyScope.launch { val (result, networkError) = shared.networkService.validateRegistrationToken( token.uppercase(), - manualEndpoint + manualEndpoint?.validateAndNormalizeUrl() ) result?.let { endpoint = manualEndpoint @@ -144,7 +145,6 @@ class RegistrationService( .addNeededObservationTypes(study.observations.map { it.observationType }.toSet()) } - fun reset() { study = null participationToken = null 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/more_app_mutliplatform/services/store/EndpointRepository.kt index b20edc916..11a0a8ff7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EndpointRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EndpointRepository.kt @@ -10,6 +10,8 @@ */ package io.redlink.more.more_app_mutliplatform.services.store +import io.redlink.more.more_app_mutliplatform.util.validateAndNormalizeUrl + class EndpointRepository(private val sharedStorageRepository: SharedStorageRepository) { private var cache: String = "" @@ -18,8 +20,10 @@ class EndpointRepository(private val sharedStorageRepository: SharedStorageRepos } fun storeEndpoint(endpoint: String) { - sharedStorageRepository.store(ENDPOINT_KEY, endpoint) - cache = endpoint + val validEndpoint = endpoint.validateAndNormalizeUrl()?.ifBlank { DATA_BASE_PATH_ENDPOINT } + ?: DATA_BASE_PATH_ENDPOINT + sharedStorageRepository.store(ENDPOINT_KEY, validEndpoint) + cache = validEndpoint } private fun loadEndpoint(): String { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt new file mode 100644 index 000000000..cd123e497 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt @@ -0,0 +1,44 @@ +package io.redlink.more.more_app_mutliplatform.util + +/** + * Validates and normalizes a URL string by adding https:// scheme if no scheme is present. + * + * @return The normalized URL with https:// scheme, or null if the URL is invalid + */ +fun String?.validateAndNormalizeUrl(): String? { + if (this.isNullOrBlank()) { + return null + } + + val trimmedUrl = this.trim() + + return if (trimmedUrl.contains("://")) { + if (trimmedUrl.isValidUrlFormat()) { + trimmedUrl + } else { + null + } + } else { + val normalizedUrl = "https://$trimmedUrl" + if (normalizedUrl.isValidUrlFormat()) { + normalizedUrl + } else { + null + } + } +} + +/** + * Basic URL format validation + */ +private fun String.isValidUrlFormat(): Boolean { + return try { + val schemeIndex = this.indexOf("://") + if (schemeIndex == -1) return false + + val afterScheme = this.substring(schemeIndex + 3) + afterScheme.isNotEmpty() && !afterScheme.startsWith("/") + } catch (e: Exception) { + false + } +} \ No newline at end of file From 94f936bf4ef47e9dcbb8c1578c862330ed5f003a Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:44:40 +0200 Subject: [PATCH 33/59] Prehab2rehab develop (#325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add `prehab2rehab_develop` branch triggers to workflows * P2r 301 drop ios 15 (#303) * PR-301: Remove unused files and extensions across the iOS project, update deprecated SwiftUI modifiers, and clean up redundant code. * PR-301: Refactor `ScheduleView` and `NotificationView` to use `ScrollViewReader` for better scroll management. * P2R 301: Remove unused `TextFieldViewModifier` from `Navigation.swift`. * P2r #311 app crashes for corrupted encrypted shared preferences store (#313) * P2R #311: Implemented a new private shared preference class, that only holds the data private for the app and does not encrypt it, as encryption suffers during edge cases, like app reinstallation, phone restoring or phone transferal. This caused the app to crash without any hope to regain access other than deleting and redownloading the app and removing all data manually. This commit includes following changes: - Added a new shared preferences in a private mode, restricting the data being accessible only by the app - Implemented a migration script, that transfers the data from the old encrypted repository to the new one - Added a fallback method, if the encrypted shared preferences fail to initialize, keeping the app from crashing, but leaving the user without credentials - Made sure that left over data are cleaned in events, like corrupted credentials * P2R #311: Add license header to EncryptedSharedPreferences file * Fixed a crash by removing redundant `stopObservations` call and minor formatting changes * Android fix: added `BLUETOOTH_ADVERTISE` permission, switched play store track to `internal`. * #316: Improve `ObservationRecordingService` stability: (#317) - Ensure foreground service starts correctly to avoid ANR crashes. - Add fallback mechanisms for notification creation. - Handle AlarmManager null scenarios gracefully during service restart. * #316: Refactor data-clearing logic * #316: Switch Google Play deployment track from `beta` to `internal`. * Prehab2rehab 294 ios upgrade (#297) * Upgraded to iOS 26 * Upgrade Pipeline to iOS 26 builds * Update workflows to use Xcode 26.0 instead of 26_beta * Update workflows and Fastfile to use Xcode 26.0-beta * Add step to download iOS 26.0 platform in workflows * Update workflows to use `universal` architecture variant for iOS 26.0 download steps * Update workflows to use iOS 26.0-beta-4 for platform download steps * Fix iOS 26.0 platform download step to use correct build version * Update iOS platform download step to log available SDKs and switch to `arm64` architecture * Simplify iOS platform download step in build workflow * Remove build version and architecture variant from iOS platform download step * Add `FASTLANE_XCODEBUILD_SETTINGS_RETRIES` to deploy and build workflows * Add comment about removing iOS 26.0 platform download step after GitHub resolves Xcode 26 beta issue * #294: Final build for iOS 26 Public Release * #294: Final build for iOS 26 Public Release * #294: Final build for iOS 26 Public Release * #2914: Update iOS workflows: use `macos-latest`, switch to Xcode 26.0 stable, and remove iOS 26 platform download step. * #294: Add temporary iOS 26.0 platform download step to workflows; to be removed after GitHub resolves Xcode 26 beta issue. * #294: Update iOS workflows to use `macos-15` runners. * P2r 298 #300 multiplatform mobile database migration from realmio to room (#315) * #298, #300: Migrated the shared module to Kotlin V2 and Room Database and adapted Android, with iOS coming in next commit * #298, #300: Completed migration from Realm to Room database, updated dependencies, implemented BluetoothDeviceEntity, and improved iOS/Android integration. * #298: Update README to reflect Gradle version change (8.6 → 8.13) * #298: PR fixes: Refactor `ObservationDao` hidden parameters, remove obsolete OpenAPI Gradle configurations, and use `AppDatabase` in `BluetoothDeviceRepository`. * #298: PR fixes: Refactor `ObservationDao` to make `hidden` parameter non-nullable and clean up obsolete methods and constants in `ObservationDataRepository`. * Android Version upgrade to 4.1.2 --- .github/workflows/build.yml | 10 +- .github/workflows/deploy-beta.yml | 10 +- .gitignore | 4 +- README.md | 39 +- androidApp/build.gradle.kts | 21 +- androidApp/fastlane/Fastfile | 8 +- androidApp/src/main/AndroidManifest.xml | 3 + .../more/app/android/MoreApplication.kt | 9 +- .../bluetooth/BluetoothViewModel.kt | 10 +- .../dashboard/DashboardViewModel.kt | 10 +- .../filter/DashboardFilterViewModel.kt | 14 +- .../dashboard/schedule/ScheduleViewModel.kt | 5 +- .../android/activities/info/InfoViewModel.kt | 7 +- .../leaveStudy/LeaveStudyViewModel.kt | 4 +- .../android/activities/main/MainActivity.kt | 3 +- .../android/activities/main/MainViewModel.kt | 5 +- .../limeSurvey/LimeSurveyViewModel.kt | 5 +- .../questionnaire/QuestionnaireViewModel.kt | 5 +- .../activities/setting/SettingsViewModel.kt | 4 +- .../studyDetails/StudyDetailsView.kt | 18 +- .../studyDetails/StudyDetailsViewModel.kt | 3 +- .../composables/AccordionWithList.kt | 9 +- .../composables/ObservationList.kt | 4 +- .../ObservationDetailsViewModel.kt | 6 +- .../activities/studyStates/StudyClosedView.kt | 2 +- .../TaskCompletionBarViewModel.kt | 5 +- .../activities/tasks/TaskDetailsView.kt | 4 - .../activities/tasks/TaskDetailsViewModel.kt | 3 +- .../more/app/android/firebase/FCMService.kt | 18 +- .../AndroidObservationDataManager.kt | 7 +- .../observations/AndroidObservationFactory.kt | 15 +- .../observations/GPS/GPSObservation.kt | 5 +- .../HR/PolarHeartRateObservation.kt | 9 +- .../observations/HR/PolarObserverCallback.kt | 9 +- .../accelerometer/AccelerometerObservation.kt | 5 +- .../services/LocalPushNotificationService.kt | 5 +- .../services/ObservationRecordingService.kt | 383 ++++++++++++---- .../bluetooth/AndroidBluetoothConnector.kt | 29 +- .../services/bluetooth/PolarConnector.kt | 23 +- .../app/android/workers/DataUploadWorker.kt | 18 +- .../workers/NotificationDataHandlerWorker.kt | 9 +- .../android/workers/ScheduleUpdateWorker.kt | 4 +- build.gradle.kts | 20 +- gradle/wrapper/gradle-wrapper.properties | 4 +- iosApp/fastlane/Fastfile | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 67 +-- .../xcshareddata/swiftpm/Package.resolved | 74 ++-- .../xcshareddata/xcschemes/iosApp.xcscheme | 4 +- iosApp/iosApp/AppDelegate.swift | 6 +- .../DataUploadBackgroundTask.swift | 2 +- iosApp/iosApp/ContentViewModel.swift | 50 +-- .../Extensions/BluetoothDeviceExtension.swift | 6 +- .../KotlinMutableSetExtension.swift | 19 - .../iosApp/Extensions/ListViewExtension.swift | 50 --- iosApp/iosApp/Extensions/SetExtension.swift | 6 +- .../Extensions/ViewModifierExtension.swift | 52 --- .../AccelerometerBackgroundObservation.swift | 9 +- .../AccelerometerObservation.swift | 4 +- .../iosApp/Observations/GPSObservation.swift | 4 +- .../Observations/IOSObservationFactory.swift | 10 +- .../PolarVerityHeartRateObservation.swift | 6 +- .../Bluetooth/IOSBluetoothConnector.swift | 27 +- .../Services/Bluetooth/PolarConnector.swift | 58 +-- .../iosApp/Services/DataUploadManager.swift | 52 ++- iosApp/iosApp/Services/FCMService.swift | 1 - .../LocalPushNotificationService.swift | 10 +- .../iosApp/Services/TaskScheduleService.swift | 2 +- .../BluetoothConnectionViewModel.swift | 8 +- .../Components/CircleActivityIndicator.swift | 40 +- .../Views/Components/ExpandableContent.swift | 32 +- .../ExpandableContentWithLink.swift | 32 +- .../Views/Components/ExpandableText.swift | 9 +- .../Views/Components/InlineAbortButton.swift | 2 +- .../Views/Components/ModuleListItem.swift | 4 +- .../Views/Components/MoreBackButton.swift | 44 -- .../Views/Components/NotificationItem.swift | 2 +- .../Views/Components/TriggerSlider.swift | 154 ------- .../Components/TriggerSliderSettings.swift | 47 -- .../Views/Components/WebView/WebView.swift | 12 +- .../Components/WebView/WebViewViewModel.swift | 1 - iosApp/iosApp/Views/Consent/ConsentView.swift | 3 +- .../DashboardFilterViewModel.swift | 2 +- .../Views/Dashboard/DashboardViewModel.swift | 4 +- iosApp/iosApp/Views/Info/InfoView.swift | 4 +- iosApp/iosApp/Views/Info/InfoViewModel.swift | 2 +- .../LimeSurvey/LimeSurveyViewModel.swift | 2 +- iosApp/iosApp/Views/Login/LoginButton.swift | 3 +- iosApp/iosApp/Views/Login/LoginView.swift | 3 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 3 +- iosApp/iosApp/Views/MainTabView.swift | 2 +- iosApp/iosApp/Views/Navigation.swift | 111 +---- .../Views/Notification/NotificationView.swift | 28 +- .../ObservationDetailsViewModel.swift | 2 +- .../SimpleQuestionObservationViewModel.swift | 2 +- .../iosApp/Views/Schedule/ScheduleView.swift | 65 +-- .../Views/Schedule/ScheduleViewModel.swift | 2 +- .../StudyDetails/StudyDetailsViewModel.swift | 6 +- .../TaskCompletionBarViewModel.swift | 2 +- .../TaskDetails/TaskDetailsViewModel.swift | 2 +- shared/build.gradle.kts | 47 +- .../1.json | 419 ++++++++++++++++++ .../database/DatabaseManager.android.kt | 14 + .../services/network/HttpClientReceiver.kt | 42 +- .../store/EncryptedSharedPreferences.kt | 86 +++- .../store/PrivateSharedPreferences.kt | 104 +++++ .../store/SharedPreferencesRepository.kt | 4 +- .../more/more_app_mutliplatform/Greeting.kt | 19 - .../more/more_app_mutliplatform/Shared.kt | 119 +++-- .../database/AppDatabase.kt | 42 ++ .../database/DatabaseManager.kt | 59 +-- .../database/RealmDatabase.kt | 156 ------- .../database/dao/BaseDao.kt | 49 ++ .../database/dao/BluetoothDeviceDao.kt | 37 ++ .../database/dao/DataPointDao.kt | 30 ++ .../database/dao/NotificationDao.kt | 158 +++++++ .../database/dao/ObservationDao.kt | 98 ++++ .../database/dao/ObservationDataDao.kt | 165 +++++++ .../database/dao/ScheduleDao.kt | 104 +++++ .../database/dao/StudyDao.kt | 62 +++ .../entities/BluetoothDeviceEntity.kt | 27 ++ .../database/entities/DataPointEntity.kt | 13 + .../database/entities/NotificationEntity.kt | 138 ++++++ .../entities/ObservationDataEntity.kt | 99 +++++ .../database/entities/ObservationEntity.kt | 65 +++ .../database/entities/ScheduleEntity.kt | 91 ++++ .../database/entities/StudyEntity.kt | 67 +++ .../repository/BluetoothDeviceRepository.kt | 67 +-- .../repository/DataPointCountRepository.kt | 63 ++- .../repository/NotificationRepository.kt | 163 +++---- .../repository/ObservationDataRepository.kt | 63 +-- .../repository/ObservationRepository.kt | 108 +++-- .../database/repository/Repository.kt | 39 -- .../database/repository/ScheduleRepository.kt | 269 ++++++----- .../database/repository/StudyRepository.kt | 64 ++- .../database/schemas/DataPointCountSchema.kt | 22 - .../database/schemas/NotificationSchema.kt | 118 ----- .../database/schemas/ObservationDataSchema.kt | 83 ---- .../database/schemas/ObservationSchema.kt | 61 --- .../database/schemas/ScheduleSchema.kt | 104 ----- .../database/schemas/StudySchema.kt | 72 --- .../extensions/CollectionExtension.kt | 10 +- .../extensions/CoroutineExtension.kt | 10 +- .../extensions/DateTimeConverter.kt | 23 +- .../extensions/MapExtension.kt | 19 - .../extensions/RealmExtensions.kt | 28 -- .../models/FilterModel.kt | 2 +- .../models/NotificationModel.kt | 9 +- .../models/ObservationDetailsModel.kt | 15 +- .../models/PermissionModel.kt | 39 +- .../models/ScheduleModel.kt | 11 +- .../models/SimpleQuestionModel.kt | 7 +- .../models/StudyDetailsModel.kt | 18 +- .../models/StudyState.kt | 2 +- .../models/TaskDetailsModel.kt | 15 +- .../navigation/DeeplinkManager.kt | 19 +- .../observations/Observation.kt | 49 +- .../observations/ObservationDataManager.kt | 48 +- .../observations/ObservationFactory.kt | 15 +- .../observations/ObservationManager.kt | 98 ++-- .../observations/ObservationManagerModel.kt | 18 - .../limesurvey/LimeSurveyObservation.kt | 4 +- .../observationTypes/ObservationType.kt | 4 +- .../SimpleQuestionObservation.kt | 4 +- .../services/bluetooth/BluetoothConnector.kt | 5 +- .../bluetooth/BluetoothConnectorObserver.kt | 14 +- .../services/bluetooth/BluetoothDevice.kt | 48 -- .../bluetooth/BluetoothDeviceManager.kt | 49 +- .../bluetooth/BluetoothStateListener.kt | 15 - .../services/network/NetworkService.kt | 375 ++++++++-------- .../services/network/RegistrationService.kt | 15 +- .../network/openapi/api/ConfigurationApi.kt | 186 -------- .../services/network/openapi/api/DataApi.kt | 90 ---- .../network/openapi/api/NotificationApi.kt | 70 --- .../network/openapi/api/RegistrationApi.kt | 137 ------ .../network/openapi/auth/Authentication.kt | 23 - .../network/openapi/auth/HttpBasicAuth.kt | 27 -- .../openapi/infrastructure/ApiAbstractions.kt | 33 -- .../openapi/infrastructure/ApiClient.kt | 157 ------- .../infrastructure/Base64ByteArrayNew.kt | 44 -- .../network/openapi/infrastructure/Bytes.kt | 115 ----- .../openapi/infrastructure/HttpResponse.kt | 61 --- .../openapi/infrastructure/OctetByteArray.kt | 44 -- .../openapi/infrastructure/PartConfig.kt | 21 - .../openapi/infrastructure/RequestConfig.kt | 28 -- .../openapi/infrastructure/RequestMethod.kt | 18 - .../notification/NotificationManager.kt | 68 +-- .../more_app_mutliplatform/util/RegexData.kt | 2 +- .../more/more_app_mutliplatform/util/Scope.kt | 110 +++-- .../util/StringUtils.kt | 86 +++- .../more_app_mutliplatform/util/StudyScope.kt | 44 +- .../BluetoothController.kt | 40 +- .../dashboard/CoreDashboardFilterViewModel.kt | 5 +- .../dashboard/CoreDashboardViewModel.kt | 15 +- .../limeSurvey/CoreLimeSurveyViewModel.kt | 41 +- .../viewModels/login/CoreLoginViewModel.kt | 19 +- .../CoreObservationDetailsViewModel.kt | 6 +- .../schedules/CoreScheduleViewModel.kt | 14 +- .../settings/CoreSettingsViewModel.kt | 29 +- .../SimpleQuestionCoreViewModel.kt | 31 +- .../CoreBluetoothViewModel.kt | 6 +- .../studydetails/CoreStudyDetailsViewModel.kt | 57 ++- .../CoreTaskCompletionBarViewModel.kt | 11 +- .../tasks/CoreTaskDetailsViewModel.kt | 14 +- .../database/DatabaseManager.ios.kt | 25 ++ .../services/network/HttpClientReceiver.kt | 39 +- 205 files changed, 4173 insertions(+), 4090 deletions(-) delete mode 100644 iosApp/iosApp/Extensions/KotlinMutableSetExtension.swift delete mode 100644 iosApp/iosApp/Extensions/ListViewExtension.swift delete mode 100644 iosApp/iosApp/Extensions/ViewModifierExtension.swift delete mode 100644 iosApp/iosApp/Views/Components/MoreBackButton.swift delete mode 100644 iosApp/iosApp/Views/Components/TriggerSlider.swift delete mode 100644 iosApp/iosApp/Views/Components/TriggerSliderSettings.swift create mode 100644 shared/schemas/io.redlink.more.more_app_mutliplatform.database.AppDatabase/1.json create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.android.kt create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Greeting.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/AppDatabase.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/RealmDatabase.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BluetoothDeviceDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/DataPointDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/NotificationDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDataDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ScheduleDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/BluetoothDeviceEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/DataPointEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/NotificationEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationDataEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/StudyEntity.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/Repository.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/DataPointCountSchema.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationDataSchema.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ObservationSchema.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/ScheduleSchema.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/StudySchema.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/MapExtension.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/RealmExtensions.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManagerModel.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDevice.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateListener.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/ConfigurationApi.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/DataApi.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/NotificationApi.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/api/RegistrationApi.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/Authentication.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/HttpBasicAuth.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiAbstractions.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/ApiClient.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Base64ByteArrayNew.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/Bytes.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/HttpResponse.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/OctetByteArray.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/PartConfig.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestConfig.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/infrastructure/RequestMethod.kt create mode 100644 shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.ios.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0632a0188..04e7f5504 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: branches: - main - develop + - prehab2rehab_develop jobs: ios: @@ -20,6 +21,7 @@ jobs: FASTLANE_MATCH_SECRET: ${{ secrets.FASTLANE_MATCH_SECRET }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_AUTH }} + 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 }} @@ -36,7 +38,13 @@ jobs: - name: Set Xcode Version uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.4 + xcode-version: '26.0.0' + + # Needs to be removed, after github fixed an issue with the Xcode 26 beta version + - name: Download iOS 26.0 Platform + run: | + xcodebuild -downloadPlatform iOS + working-directory: . - name: Build iOS app run: fastlane build diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 8e3bf75e9..837d19e84 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -4,6 +4,7 @@ on: branches: - develop - main + - prehab2rehab_develop tags: - '[0-9]+.[0-9]+.[0-9]+' @@ -23,6 +24,7 @@ jobs: FASTLANE_MATCH_SECRET: ${{ secrets.FASTLANE_MATCH_SECRET }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_AUTH }} + 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 }} @@ -39,7 +41,13 @@ jobs: - name: Set Xcode Version uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.4 + xcode-version: '26.0.0' + + # Needs to be removed, after github fixed an issue with the Xcode 26 beta version + - name: Download iOS 26.0 Platform + run: | + xcodebuild -downloadPlatform iOS + working-directory: . - name: Set FASTLANE_BUILD_NUMBER from tag run: echo "FASTLANE_BUILD_NUMBER=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV diff --git a/.gitignore b/.gitignore index 9f06134df..6aae8c35a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ shared/src/commonMain/kotlin/generated *.cer* *.p12 *.zip -*.p8 \ No newline at end of file +*.p8 +.kotlin +*.db \ No newline at end of file diff --git a/README.md b/README.md index 9101d9aec..76a9529a5 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,10 @@ It's recommended that you install the latest stable versions for compatibility a 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 15.0 or higher) +* [XCode](https://apps.apple.com/us/app/xcode) (Must be of version 16.0 or higher) * [Command Line Tools](https://developer.apple.com/downloads/) * [JDK 21](https://www.oracle.com/java/technologies/downloads/) -* [Gradle 8.2+](https://gradle.org) +* [Gradle 8.13](https://gradle.org) 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. @@ -162,7 +162,8 @@ The iOS fastlane configuration includes the following lanes: - `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. +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 @@ -174,11 +175,16 @@ The Android fastlane configuration includes the following lanes: 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. +- **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. +- **Firebase Integration**: When not running in CI mode, the build process supports optional + Firebase App Distribution for testing. ### Environment Variables @@ -198,11 +204,14 @@ To run the pipeline, you need to set up the following environment variables: **Variables:** -- `APP_IDENTIFIERS`: Comma-separated list of app bundle identifiers (e.g., "ac.at.lbg.dhp.more,ac.at.lbg.dhp.more.More-Notification-Service-Extension") -- `TARGETS`: Comma-separated list of Xcode targets corresponding to the app identifiers (e.g., "More,More-Notification-Service-Extension") +- `APP_IDENTIFIERS`: Comma-separated list of app bundle identifiers (e.g., " + ac.at.lbg.dhp.more,ac.at.lbg.dhp.more.More-Notification-Service-Extension") +- `TARGETS`: Comma-separated list of Xcode targets corresponding to the app identifiers (e.g., " + More,More-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) +- `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 @@ -251,12 +260,18 @@ To run the pipeline under your own account, follow these steps: ### 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. + 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. + 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. + 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 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 64ba788b1..8cce83e6f 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -5,8 +5,10 @@ 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 { @@ -47,16 +49,13 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 26 - versionName = "4.0.26" + versionCode = 30 + versionName = "4.1.2" } buildFeatures { compose = true buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.11" - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -147,8 +146,9 @@ android { val composeVersion = "1.6.8" val workVersion = "2.9.0" val navVersion = "2.7.7" -val polarSDKVersion = "5.6.0" -val ktorVersion = "2.3.12" +val polarSDKVersion = "6.6.0" +val ktorVersion = "3.2.3" +val roomVersion = "2.7.2" dependencies { implementation(project(":shared")) @@ -162,7 +162,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") @@ -185,4 +184,8 @@ dependencies { implementation("androidx.camera:camera-camera2:1.3.0") implementation("androidx.camera:camera-lifecycle:1.3.0") implementation("androidx.camera:camera-view:1.3.0") + + implementation("androidx.room:room-runtime:$roomVersion") + + ksp("androidx.room:room-compiler:$roomVersion") } diff --git a/androidApp/fastlane/Fastfile b/androidApp/fastlane/Fastfile index 575aefb9b..f096a6d1f 100644 --- a/androidApp/fastlane/Fastfile +++ b/androidApp/fastlane/Fastfile @@ -157,7 +157,7 @@ platform :android do 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: "beta" + track: "internal" ).max || base_code else key_base64 = ENV["GOOGLE_PLAY_KEY_IN_BASE64"] @@ -169,7 +169,7 @@ platform :android do 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: "beta", + track: "internal", json_key: File.expand_path(key_file_path) ).max || base_code else @@ -210,14 +210,14 @@ platform :android do 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: "beta", + 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 Beta") + UI.success("Successfully deployed to Google Play Internal") end rescue => e UI.error("Error uploading to Google Play: #{e.message}") diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 3b6deebb2..be5cc5b6d 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + @@ -94,6 +95,8 @@ () - val connectedDevices = mutableStateListOf() + val discoveredDevices = mutableStateListOf() + val connectedDevices = mutableStateListOf() val connectingDevices = mutableStateListOf() val isScanning = mutableStateOf(false) val bluetoothPowerState = mutableStateOf(BluetoothStateListener.bluetoothEnabled.value) @@ -120,11 +120,11 @@ class BluetoothViewModel : ViewModel() { ViewManager.bleViewOpen(false) } - fun connectToDevice(device: BluetoothDevice) { + fun connectToDevice(device: BluetoothDeviceEntity) { coreBluetoothViewModel.connectToDevice(device) } - fun disconnectFromDevice(device: BluetoothDevice) { + fun disconnectFromDevice(device: BluetoothDeviceEntity) { coreBluetoothViewModel.disconnectFromDevice(device) } } \ No newline at end of file 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 index 553d97b60..b15090208 100644 --- 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 @@ -13,8 +13,9 @@ 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.MoreApplication 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.database.entities.StudyEntity import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -23,10 +24,11 @@ import kotlinx.coroutines.launch class DashboardViewModel( val scheduleViewModel: ScheduleViewModel -): ViewModel() { - private val coreDashboardViewModel: CoreDashboardViewModel = CoreDashboardViewModel() +) : ViewModel() { + private val coreDashboardViewModel: CoreDashboardViewModel = + CoreDashboardViewModel(MoreApplication.shared!!.database) - var study: MutableState = mutableStateOf(StudySchema()) + var study: MutableState = mutableStateOf(StudyEntity()) val studyTitle = mutableStateOf("Study Title") val studyActive = mutableStateOf(true) 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..ee666ac91 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 @@ -24,10 +24,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DashboardFilterViewModel(private val coreViewModel: CoreDashboardFilterViewModel) { - val currentTypeFilter = mutableStateMapOf() + 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 +65,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/schedule/ScheduleViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/ScheduleViewModel.kt index 0b5e04558..fb90efd79 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 @@ -33,9 +33,11 @@ import java.time.LocalDate class ScheduleViewModel( val scheduleListType: ScheduleListType ) : ViewModel() { - private val coreDashboardFilterViewModel = CoreDashboardFilterViewModel() + private val coreDashboardFilterViewModel = + CoreDashboardFilterViewModel(MoreApplication.shared!!.database) private val coreViewModel = CoreScheduleViewModel( + MoreApplication.shared!!.database, MoreApplication.shared!!.dataRecorder, coreFilterModel = coreDashboardFilterViewModel, scheduleListType = scheduleListType @@ -117,7 +119,6 @@ class ScheduleViewModel( 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 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..004f0ca88 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 @@ -13,19 +13,20 @@ 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.app.android.MoreApplication 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 -class InfoViewModel: ViewModel() { - private val coreViewModel = CoreStudyDetailsViewModel() +class InfoViewModel : ViewModel() { + private val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!.database) val model = mutableStateOf(null) init { viewModelScope.launch(Dispatchers.IO) { - coreViewModel.studyModel.collect{ + coreViewModel.studyModel.collect { withContext(Dispatchers.Main) { model.value = it } 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..8783a6b73 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,7 +19,7 @@ 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.database.entities.StudyEntity import io.redlink.more.more_app_mutliplatform.models.PermissionModel import io.redlink.more.more_app_mutliplatform.viewModels.settings.CoreSettingsViewModel import kotlinx.coroutines.Dispatchers @@ -28,7 +28,7 @@ import kotlinx.coroutines.withContext class LeaveStudyViewModel : ViewModel() { private var coreSettingsViewModel = CoreSettingsViewModel(MoreApplication.shared!!) - val study = mutableStateOf(null) + val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) init { 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 2045e4056..81b0059a4 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 @@ -31,6 +31,7 @@ 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 @@ -300,7 +301,7 @@ fun MainView( ScheduleListType.RUNNING -> viewModel.runningSchedulesViewModel.filterModel ScheduleListType.COMPLETED -> viewModel.completedSchedulesViewModel.filterModel ScheduleListType.ALL -> DashboardFilterViewModel( - CoreDashboardFilterViewModel() + CoreDashboardFilterViewModel(MoreApplication.shared!!.database) ) } ) 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..2b6cbea0c 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 @@ -96,7 +96,6 @@ class MainViewModel(context: Context) : ViewModel() { val alertDialogOpen = mutableStateOf(null) private var lastBleViewState = false - init { viewModelScope.launch(Dispatchers.IO) { AlertController.alertDialogModel.collect { @@ -105,12 +104,12 @@ class MainViewModel(context: Context) : ViewModel() { } } } - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { ViewManager.studyIsUpdating.collect { studyIsUpdating.value = it } } - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { MoreApplication.shared!!.currentStudyState.collect { finishText.value = MoreApplication.shared!!.finishText studyState.value = it 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..7f257abab 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 @@ -26,7 +26,10 @@ import java.net.URI import java.net.URL class LimeSurveyViewModel : ViewModel(), WebClientListener { - private val coreViewModel = CoreLimeSurveyViewModel(MoreApplication.shared!!.observationFactory) + private val coreViewModel = CoreLimeSurveyViewModel( + MoreApplication.shared!!.database, + MoreApplication.shared!!.observationFactory + ) val limeSurveyLink = mutableStateOf(null) val dataLoading = mutableStateOf(false) val wasAnswered = mutableStateOf(false) 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 index 968029c99..8efa3928d 100644 --- 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 @@ -22,7 +22,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class QuestionnaireViewModel : ViewModel() { - private val coreViewModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(MoreApplication.shared!!.observationFactory) + private val coreViewModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel( + MoreApplication.shared!!.database, + MoreApplication.shared!!.observationFactory + ) val hasData = mutableStateOf(false) val observationTitle = mutableStateOf("") 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..b239bfa08 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,7 +14,7 @@ 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.database.entities.StudyEntity import io.redlink.more.more_app_mutliplatform.models.PermissionModel import io.redlink.more.more_app_mutliplatform.viewModels.settings.CoreSettingsViewModel import kotlinx.coroutines.Dispatchers @@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext class SettingsViewModel : ViewModel() { private var coreSettingsViewModel = CoreSettingsViewModel(MoreApplication.shared!!) - val study = mutableStateOf(null) + val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) init { 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..fe02a7af3 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 @@ -39,11 +39,15 @@ 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 - @Composable -fun StudyDetailsView(navController: NavController, viewModel: StudyDetailsViewModel, taskCompletionBarViewModel: TaskCompletionBarViewModel) { +fun StudyDetailsView( + navController: NavController, + viewModel: StudyDetailsViewModel, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { val backStackEntry = remember { navController.currentBackStackEntry } - val route = backStackEntry?.arguments?.getString(NavigationScreen.STUDY_DETAILS.routeWithParameters()) + val route = + backStackEntry?.arguments?.getString(NavigationScreen.STUDY_DETAILS.routeWithParameters()) LaunchedEffect(route) { viewModel.viewDidAppear() } @@ -70,11 +74,13 @@ fun StudyDetailsView(navController: NavController, viewModel: StudyDetailsViewMo horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - if (it.study.start?.epochSeconds != null && it.study.end?.epochSeconds != null) { + if (it.study.start != null && it.study.end != null) { BasicText(text = "${getStringResource(R.string.study_duration)}: ") BasicText( - text = "${it.study.start!!.epochSeconds.jvmLocalDateTime().formattedString()} - ${ - it.study.end!!.epochSeconds.jvmLocalDateTime().formattedString() + text = "${ + it.study.start!!.jvmLocalDateTime().formattedString() + } - ${ + it.study.end!!.jvmLocalDateTime().formattedString() }", color = MoreColors.Secondary ) 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..63dc38fd5 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 @@ -13,6 +13,7 @@ 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.app.android.MoreApplication import io.redlink.more.more_app_mutliplatform.models.StudyDetailsModel import io.redlink.more.more_app_mutliplatform.viewModels.studydetails.CoreStudyDetailsViewModel import kotlinx.coroutines.Dispatchers @@ -20,7 +21,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class StudyDetailsViewModel: ViewModel() { - private val coreViewModel = CoreStudyDetailsViewModel() + private val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!.database) val model = mutableStateOf(null) init { 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..9ca4641e8 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 @@ -36,10 +36,14 @@ 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.more_app_mutliplatform.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) } @@ -84,6 +88,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..45d866bd3 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 @@ -30,10 +30,10 @@ 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.more_app_mutliplatform.database.entities.ObservationEntity @Composable -fun ObservationList(navController: NavController, observations: List) { +fun ObservationList(navController: NavController, observations: List) { Column( verticalArrangement = Arrangement.Top, modifier = Modifier.fillMaxWidth() diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt index f447df5f0..190d51e22 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt @@ -13,6 +13,7 @@ package io.redlink.more.app.android.activities.studyDetails.observationDetails 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.models.ObservationDetailsModel import io.redlink.more.more_app_mutliplatform.viewModels.observationDetails.CoreObservationDetailsViewModel import kotlinx.coroutines.Dispatchers @@ -21,9 +22,10 @@ import kotlinx.coroutines.withContext class ObservationDetailsViewModel( observationId: String, -): ViewModel() { +) : ViewModel() { - private val coreViewModel: CoreObservationDetailsViewModel = CoreObservationDetailsViewModel(observationId) + private val coreViewModel: CoreObservationDetailsViewModel = + CoreObservationDetailsViewModel(MoreApplication.shared!!.database, observationId) val observationDetailsModel = mutableStateOf( ObservationDetailsModel( "", "", "", 0, 0, "" diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyClosedView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyClosedView.kt index 409988e8e..23cdaa251 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyClosedView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyClosedView.kt @@ -47,7 +47,7 @@ import io.redlink.more.app.android.shared_composables.Title import io.redlink.more.app.android.ui.theme.MoreColors @Composable -fun StudyClosedView(closedMessage: kotlin.String?) { +fun StudyClosedView(closedMessage: String?) { val context = LocalContext.current var loading by remember { mutableStateOf(false) } MoreBackground { 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..5c8d7b7dc 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,15 @@ import androidx.compose.runtime.MutableState 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.models.TaskCompletion import io.redlink.more.more_app_mutliplatform.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!!.database) val taskCompletion: MutableState = mutableStateOf(TaskCompletion()) init { 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..eb18e464d 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 @@ -27,7 +27,6 @@ import androidx.compose.runtime.LaunchedEffect 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 io.redlink.more.app.android.R @@ -51,7 +50,6 @@ import io.redlink.more.more_app_mutliplatform.observations.observationTypes.Lime import io.redlink.more.more_app_mutliplatform.observations.observationTypes.PolarVerityHeartRateType import io.redlink.more.more_app_mutliplatform.observations.observationTypes.SimpleQuestionType - @Composable fun TaskDetailsView( navController: NavController, @@ -61,7 +59,6 @@ fun TaskDetailsView( val backStackEntry = remember { navController.currentBackStackEntry } val route = backStackEntry?.arguments?.getString(NavigationScreen.SCHEDULE_DETAILS.routeWithParameters()) - val context = LocalContext.current LaunchedEffect(route) { viewModel.viewDidAppear() } @@ -163,7 +160,6 @@ fun TaskDetailsView( horizontalAlignment = Alignment.CenterHorizontally ) { - ObservationErrorListView( errors = viewModel.taskObservationErrors, errorActions = viewModel.taskObservationErrorActions 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..b120beb1b 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 @@ -28,7 +28,8 @@ import kotlinx.coroutines.withContext class TaskDetailsViewModel( dataRecorder: DataRecorder ) : ViewModel() { - private val coreViewModel: CoreTaskDetailsViewModel = CoreTaskDetailsViewModel(dataRecorder) + private val coreViewModel: CoreTaskDetailsViewModel = + CoreTaskDetailsViewModel(MoreApplication.shared!!.database, dataRecorder) val isEnabled = mutableStateOf(false) val polarHrReady = mutableStateOf(false) val dataPointCount = mutableStateOf(0L) 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 6ec83d14d..65a769679 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.more_app_mutliplatform.database.entities.NotificationEntity import java.util.UUID /* @@ -23,21 +23,27 @@ 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( + MoreApplication.shared!!, + 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, 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..4dfd607ee 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 @@ -17,9 +17,11 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import io.redlink.more.app.android.workers.DataUploadWorker +import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManager -class AndroidObservationDataManager(context: Context) : ObservationDataManager() { +class AndroidObservationDataManager(context: Context, database: AppDatabase) : + ObservationDataManager(database) { private val workManager = WorkManager.getInstance(context) private val workerConstraints = Constraints.Builder() @@ -33,7 +35,8 @@ class AndroidObservationDataManager(context: Context) : ObservationDataManager() workManager.enqueueUniqueWork( DataUploadWorker.WORKER_TAG, ExistingWorkPolicy.KEEP, - dataWorker) + dataWorker + ) onCompletion(true) } 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..a8279f72d 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,20 +18,25 @@ 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.database.AppDatabase 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class AndroidObservationFactory(context: Context, observationDataManager: ObservationDataManager) : - ObservationFactory(observationDataManager) { +class AndroidObservationFactory( + context: Context, + observationDataManager: ObservationDataManager, + database: AppDatabase +) : + ObservationFactory(database, observationDataManager) { init { observations.addAll( setOf( - AccelerometerObservation(context), - GPSObservation(context, gpsService = GPSService(context)), - PolarHeartRateObservation() + AccelerometerObservation(context, database), + GPSObservation(context, database, gpsService = GPSService(context)), + PolarHeartRateObservation(database) ) ) 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 3e7ec284e..a28d75c29 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 @@ -22,6 +22,7 @@ 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.database.AppDatabase import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.observations.observationTypes.GPSType import kotlinx.coroutines.CoroutineScope @@ -45,8 +46,10 @@ private val permissions = if (Build.VERSION.SDK_INT >= 34) { class GPSObservation( context: Context, + database: AppDatabase, private val gpsService: GPSService -) : Observation(observationType = GPSType(permissions)), GPSListener { +) : Observation(database, observationType = GPSType(permissions)), + GPSListener { private val locationManager = context.getSystemService(LocationManager::class.java) private val scope = CoroutineScope(Job() + Dispatchers.IO) 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 889895a19..0986707b7 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 @@ -23,6 +23,7 @@ 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.database.AppDatabase 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 @@ -39,6 +40,7 @@ private val permissions = setOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE ) @@ -56,8 +58,11 @@ private val permissions = ) } -class PolarHeartRateObservation : - Observation(observationType = PolarVerityHeartRateType(permissions)) { +class PolarHeartRateObservation(database: AppDatabase) : + Observation( + database, + observationType = PolarVerityHeartRateType(permissions) + ) { private val deviceManager = BluetoothDeviceManager private val deviceIdentifier = setOf("Polar") private val polarConnector = MoreApplication.polarConnector!! 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..61468e1bc 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,11 +10,11 @@ */ 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 java.util.UUID @@ -88,6 +88,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/accelerometer/AccelerometerObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/accelerometer/AccelerometerObservation.kt index b42bd954b..3facc3bf9 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 @@ -18,6 +18,7 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Build import android.util.Log +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -25,8 +26,10 @@ import io.redlink.more.more_app_mutliplatform.util.Scope private const val TAG = "AccelerometerObservation" class AccelerometerObservation( - context: Context + context: Context, + database: AppDatabase ) : Observation( + database = database, observationType = AccelerometerType( if (Build.VERSION.SDK_INT >= 34) { setOf( 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..d07e233e9 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 @@ -24,7 +24,7 @@ import io.github.aakira.napier.Napier 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.database.entities.NotificationEntity import io.redlink.more.more_app_mutliplatform.services.notification.LocalNotificationListener import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager.Companion.MSG_ID @@ -32,7 +32,7 @@ class LocalPushNotificationService(private val context: Context) : LocalNotifica 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) { notification.title?.let { title -> notification.notificationBody?.let { message -> val intent = Intent(context, ContentActivity::class.java).apply { @@ -86,7 +86,6 @@ class LocalPushNotificationService(private val context: Context) : LocalNotifica } } - override fun deleteNotificationFromSystem(notificationId: String) { context.getSystemService(NotificationManager::class.java)?.cancel(notificationId.hashCode()) } 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 7e1fa3a1c..e78b2fa7a 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 @@ -35,6 +35,7 @@ import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepositor 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.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -43,7 +44,8 @@ import kotlinx.coroutines.launch class ObservationRecordingService : Service() { private var observationManager: ObservationManager? = null - private val scheduleRepository = ScheduleRepository() + private val scheduleRepository = ScheduleRepository(MoreApplication.shared!!.database) + private val studyRepository = StudyRepository(MoreApplication.shared!!.database) private var observationFactory: ObservationFactory? = null private val scope = CoroutineScope(Job() + Dispatchers.IO) @@ -53,6 +55,30 @@ 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 { + if (!running) { + startForegroundService() + } + } catch (e: Exception) { + Napier.e("Failed to start foreground service: ${e.message}") + try { + val basicNotification = Notification.Builder(this, "default") + .setContentTitle("More Observation Service") + .setContentText("Service is running") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .build() + startForeground(1001, basicNotification) + 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) @@ -63,6 +89,7 @@ class ObservationRecordingService : Service() { if (observationManager == null) { observationManager = MoreApplication.shared?.observationManager ?: ObservationManager( + MoreApplication.shared!!.database, it, AndroidDataRecorder() ) @@ -114,25 +141,36 @@ class ObservationRecordingService : Service() { super.onTaskRemoved(rootIntent) Napier.i { "ObservationRecordingService taskRemove!" } - // Restart the service if it's killed when the app is removed from recent apps - 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 - ) + try { + if (runningSchedules.isNotEmpty()) { + val restartServiceIntent = + Intent(applicationContext, ObservationRecordingService::class.java) + restartServiceIntent.action = SERVICE_RECEIVER_RESTART_ALL_STATES - val alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 1000, - pendingIntent - ) + val pendingIntent = PendingIntent.getService( + applicationContext, 1, restartServiceIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) - Napier.i { "Scheduled service restart after task removal" } + val alarmManager = + applicationContext.getSystemService(Context.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}") } } @@ -140,68 +178,136 @@ class ObservationRecordingService : Service() { Napier.i { "ObservationRecordingService is destroyed!" } running = false - // Attempt to restart the service if it's destroyed but has running schedules - 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 + try { + try { + stopForeground(STOP_FOREGROUND_REMOVE) + } catch (e: Exception) { + Napier.e("Failed to stop foreground service: ${e.message}") + } - val pendingIntent = PendingIntent.getService( - applicationContext, 2, restartServiceIntent, PendingIntent.FLAG_IMMUTABLE - ) + 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 alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 1000, - pendingIntent - ) - } + val pendingIntent = PendingIntent.getService( + applicationContext, 2, restartServiceIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) - super.onDestroy() + val alarmManager = + applicationContext.getSystemService(Context.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 - // Attempt to restart the service if it's unbound but has running schedules - 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 - ) - - val alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 1000, - pendingIntent - ) + 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(Context.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 super.onUnbind(intent) + 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 (studyRepository.getStudy().firstOrNull()?.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}") } } } @@ -217,7 +323,9 @@ class ObservationRecordingService : Service() { private fun stopObservation(scheduleId: String) { observationManager?.stop(scheduleId) runningSchedules.remove(scheduleId) - scheduleRepository.setCompletionStateFor(scheduleId, true) + StudyScope.launch(Dispatchers.IO) { + scheduleRepository.setCompletionStateFor(scheduleId, true) + } if (observationManager?.hasRunningTasks() == false) { stopService() } @@ -268,26 +376,87 @@ class ObservationRecordingService : Service() { } 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 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 channelId = try { + getString(R.string.default_channel_id) + } catch (e: Exception) { + "default" + } + + val notificationTitle = try { + getString(R.string.more_observation_running) + } catch (e: Exception) { + "More Observation Service" + } + + val notificationText = try { + getString(R.string.more_observation_notification_explanation) + } catch (e: Exception) { + "Service is running" + } + + val notification = buildNotification( + channelId = channelId, + notificationTitle = notificationTitle, + notificationText = notificationText + ) + startForeground(1001, notification) + 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( @@ -295,26 +464,47 @@ 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 = "More 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}") + return Notification.Builder(applicationContext, "default") + .setContentTitle("More Service") + .setContentText("Running") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) + .build() + } } companion object { @@ -405,7 +595,6 @@ class ObservationRecordingService : Service() { */ private fun startWithoutPermissionCheck(scheduleIds: Set) { Scope.launch(Dispatchers.IO) { - // Always start observations regardless of foreground/background state if (scheduleIds.isNotEmpty()) { val serviceIntent = Intent(MoreApplication.appContext, ObservationRecordingService::class.java) 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 index fb3af53f3..65ba55dc9 100644 --- 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 @@ -31,9 +31,9 @@ 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.database.entities.BluetoothDeviceEntity 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 @@ -242,7 +242,7 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { } @SuppressLint("MissingPermission") - override fun connect(device: BluetoothDevice): Error? { + override fun connect(device: BluetoothDeviceEntity): Error? { if (bluetoothState == BluetoothState.ON) { i { "Connecting to device: $device" } isConnecting = true @@ -272,7 +272,7 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { } @SuppressLint("MissingPermission") - override fun disconnect(device: BluetoothDevice) { + override fun disconnect(device: BluetoothDeviceEntity) { if (!disconnectFromSpecificConnectors(device)) { val androidBluetoothDevice = bluetoothAdapter?.getRemoteDevice(device.address) if (androidBluetoothDevice != null) { @@ -308,35 +308,35 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { } } - override fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) { + override fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) { i { "Connecting to $bluetoothDevice..." } updateObserver { it.isConnectingToDevice(bluetoothDevice) } } - override fun didConnectToDevice(bluetoothDevice: BluetoothDevice) { + override fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { i { "Connected to $bluetoothDevice!" } updateObserver { it.didConnectToDevice(bluetoothDevice) } isConnecting = false } - override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { + override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { i { "Disconnected from $bluetoothDevice!" } foundBluetoothDevices.remove(bluetoothDevice.address) updateObserver { it.didDisconnectFromDevice(bluetoothDevice) } } - override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { + override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { i { "Failed to connect to $bluetoothDevice!" } updateObserver { didFailToConnectToDevice(bluetoothDevice) } isConnecting = false } - override fun didDiscoverDevice(device: BluetoothDevice) { + override fun didDiscoverDevice(device: BluetoothDeviceEntity) { i { "Discovered $device!" } updateObserver { it.didDiscoverDevice(device) } } - override fun removeDiscoveredDevice(device: BluetoothDevice) { + override fun removeDiscoveredDevice(device: BluetoothDeviceEntity) { i { "Removing discovered $device..." } updateObserver { it.removeDiscoveredDevice(device) } } @@ -359,7 +359,7 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { updateObserver { it.isScanning(boolean) } } - private fun deviceConnected(bluetoothDevice: BluetoothDevice) { + private fun deviceConnected(bluetoothDevice: BluetoothDeviceEntity) { val (hasSpecialConnector, error) = connectToSpecificConnectors(bluetoothDevice) if (hasSpecialConnector) { if (error != null) { @@ -369,7 +369,7 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { didConnectToDevice(bluetoothDevice) } - private fun connectToSpecificConnectors(device: BluetoothDevice): Pair { + private fun connectToSpecificConnectors(device: BluetoothDeviceEntity): Pair { return specificBluetoothConnectors.keys.firstOrNull { device.deviceName?.lowercase()?.contains(it) ?: false }?.let { @@ -378,7 +378,7 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { } ?: Pair(false, null) } - private fun disconnectFromSpecificConnectors(device: BluetoothDevice): Boolean { + private fun disconnectFromSpecificConnectors(device: BluetoothDeviceEntity): Boolean { return specificBluetoothConnectors.keys.firstOrNull { i { "Disconnecting with special connector \"$it\"..." } device.deviceName?.lowercase()?.contains(it) ?: false @@ -388,10 +388,9 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { } ?: false } - } @SuppressLint("MissingPermission") -fun AndroidBluetoothDevice.toBluetoothDevice(): BluetoothDevice { - return BluetoothDevice.create(this.address, this.name, this.address) +fun AndroidBluetoothDevice.toBluetoothDevice(): BluetoothDeviceEntity { + return BluetoothDeviceEntity.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..ce0382e98 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 @@ -22,9 +22,9 @@ 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.database.entities.BluetoothDeviceEntity 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 @@ -87,7 +87,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 +100,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 { @@ -120,7 +120,6 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe } } - override fun onPolarFeatureReady(feature: PolarBleApi.PolarBleSdkFeature) { if (feature == PolarBleApi.PolarBleSdkFeature.FEATURE_HR) { Napier.i(tag = "PolarConnector::onPolarFeatureReady") { "HR ready!" } @@ -152,32 +151,32 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe 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) } } - 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) } @@ -235,6 +234,6 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe } } -fun PolarDeviceInfo.toBluetoothDevice(): BluetoothDevice { - return BluetoothDevice.create(this.deviceId, this.name, this.address) +fun PolarDeviceInfo.toBluetoothDevice(): BluetoothDeviceEntity { + return 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/workers/DataUploadWorker.kt b/androidApp/src/main/java/io/redlink/more/app/android/workers/DataUploadWorker.kt index fe42b61c3..cebc20cdd 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 @@ -30,16 +30,20 @@ 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 ?: CredentialRepository(sharedPreferences) + private val networkService = MoreApplication.shared?.networkService ?: NetworkService( + EndpointRepository(sharedPreferences), credentialRepository + ) private var stopped = false - private val observationDataRepository = ObservationDataRepository() + private val observationDataRepository = + ObservationDataRepository(MoreApplication.shared!!.database) override suspend fun doWork(): Result = withContext(Dispatchers.IO) { Napier.i { "Starting DataUploadWorker doWork()" } @@ -53,15 +57,13 @@ class DataUploadWorker ( Napier.i { "Worker started!" } return@withContext observationDataRepository.allAsBulk()?.let { bulk -> if (bulk.dataPoints.isNotEmpty()) { - return@withContext uploadDataBulk(bulk).apply { - observationDataRepository.close() - } + return@withContext uploadDataBulk(bulk) } 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() 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..6afd06290 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,7 +16,6 @@ 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.app.android.MoreApplication import io.redlink.more.more_app_mutliplatform.Shared import kotlinx.coroutines.Dispatchers @@ -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(shared, 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..20608235c 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 @@ -24,17 +24,19 @@ class ScheduleUpdateWorker(context: Context, workerParameters: WorkerParameters) CoroutineWorker(context, workerParameters) { private val shared: Shared + private val scheduleRepository: ScheduleRepository init { if (MoreApplication.shared == null) { MoreApplication.initShared(applicationContext) } shared = MoreApplication.shared!! + scheduleRepository = ScheduleRepository(shared.database) } override suspend fun doWork() = withContext(Dispatchers.IO) { Napier.i { "Running $WORKER_TAG! Updating Schedule..." } - ScheduleRepository().updateTaskStatesSync(shared.observationFactory, shared.dataRecorder) + scheduleRepository.updateTaskStatesSync(shared.observationFactory, shared.dataRecorder) return@withContext Result.success() } diff --git a/build.gradle.kts b/build.gradle.kts index 97133f66a..4b5e3d4ec 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.3") + classpath("com.google.firebase:firebase-crashlytics-gradle:3.0.6") } repositories { google() // Google's Maven repository @@ -10,15 +10,17 @@ 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.0").apply(false) + id("com.android.library").version("8.13.0").apply(false) + kotlin("android").version("2.2.10").apply(false) + kotlin("multiplatform").version("2.2.10").apply(false) + kotlin("plugin.serialization").version("2.2.10").apply(false) + id("org.jetbrains.kotlin.plugin.compose").version("2.2.10").apply(false) + id("androidx.room").version("2.7.2").apply(false) + id("com.google.devtools.ksp").version("2.2.10-2.0.2").apply(false) } tasks.register("clean", Delete::class) { delete(rootProject.buildDir) -} \ No newline at end of file +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index abae42cd6..629b5b4e1 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 +#Thu Sep 04 11:02:55 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.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile index ff1f42416..15d041855 100644 --- a/iosApp/fastlane/Fastfile +++ b/iosApp/fastlane/Fastfile @@ -88,7 +88,7 @@ platform :ios do ) end - xcodes(version: "16.4", select_for_current_build_only: true) + xcodes(version: "26.0-beta", select_for_current_build_only: true) end build_app( diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index e7600ca8c..eedffcc5d 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 */; }; @@ -64,9 +62,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 */; }; @@ -102,7 +97,6 @@ 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 */; }; 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 */; }; @@ -133,7 +127,6 @@ 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 */; }; @@ -200,7 +193,6 @@ 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 */; }; @@ -235,7 +227,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 1F709AF42C0D8FB700FC6F5A /* RealmSwift in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -247,8 +238,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 = ""; }; @@ -300,7 +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 = ""; }; @@ -339,7 +327,6 @@ 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 = ""; }; 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 = ""; }; @@ -388,7 +375,6 @@ 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 = ""; }; 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 = ""; }; @@ -457,7 +443,6 @@ EDBB2B4929B8CFF000CA973E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.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 = ""; }; @@ -478,7 +463,6 @@ files = ( 1FBD513A2BBC3E2D0029D185 /* FirebaseMessaging in Frameworks */, 1FBD513C2BBC3E8A0029D185 /* FirebaseCrashlytics in Frameworks */, - 1F709AF32C0D8FB700FC6F5A /* RealmSwift in Frameworks */, EDACF62729D2F31D0032327B /* PolarBleSdk in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -618,7 +602,6 @@ B79DBA3A29D3135E00A1F547 /* ForwardButton.swift */, 1F13BA482993985800938C1E /* InactiveText.swift */, 1F8847E129917F5D0023EF10 /* MoreActionButton.swift */, - EDD808D629BF4C3A005779DC /* MoreBackButton.swift */, 1F8847D729915C030023EF10 /* MoreTextField.swift */, B72C645E29DC812900F5A7C6 /* MoreTextFieldHL.swift */, B72C645F29DC812900F5A7C6 /* MoreTextFieldSmBottom.swift */, @@ -643,8 +626,6 @@ 07FD293D29D58F7C00853108 /* ExpandableContent.swift */, B78B4A8329F04BEA00A1BA58 /* ExpandableContentWithLink.swift */, EDD1DEFE29F7B595009BC8FB /* ScheduleListHeader.swift */, - 071ABEF429ED8C440013C1CF /* TriggerSlider.swift */, - 071ABEF629ED8C660013C1CF /* TriggerSliderSettings.swift */, 07F67D8929EECF1A006DCFB5 /* BasicNavLinkButton.swift */, 1F6A4E3829F6C7C000F0247F /* EmptyListView.swift */, 1FF5B28A2A8274C10076EF8E /* AppVersion.swift */, @@ -702,12 +683,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 */, @@ -717,7 +696,6 @@ 1F638B7329D6B46300455B66 /* CMLogItemExtension.swift */, ED74FF4529DEC73600BAA1EF /* KotlinLongExtension.swift */, 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */, - 1F9B81BC2A28CBF70013738A /* KotlinMutableSetExtension.swift */, 1FF5B28C2A8275790076EF8E /* Bundle.swift */, 3007C5E9B53BC561D105D2B3 /* SetExtension.swift */, ); @@ -1061,7 +1039,6 @@ EDACF62629D2F31D0032327B /* PolarBleSdk */, 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */, 1FBD513B2BBC3E8A0029D185 /* FirebaseCrashlytics */, - 1F709AF22C0D8FB700FC6F5A /* RealmSwift */, ); productName = iosApp; productReference = 7555FF7B242A565900829871 /* More.app */; @@ -1098,7 +1075,6 @@ packageReferences = ( EDACF62529D2F31D0032327B /* XCRemoteSwiftPackageReference "polar-ble-sdk" */, 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, - 1F9283BB2BC510D100D459A7 /* XCRemoteSwiftPackageReference "realm-swift" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -1257,10 +1233,8 @@ 1F43998F29C1010400687906 /* InfoView.swift in Sources */, B748DF4A29D1C08D0026C348 /* SimpleQuetionObservationView.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 */, 1F9C3E8E298AAC1A00B9AC82 /* LoginView.swift in Sources */, @@ -1283,12 +1257,10 @@ 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 */, 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 */, @@ -1317,7 +1289,6 @@ 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 */, 1F6C31512A121EA500EED533 /* WebViewViewModel.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, @@ -1366,7 +1337,6 @@ 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 */, @@ -1578,7 +1548,7 @@ CODE_SIGN_ENTITLEMENTS = "More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4.1.0; + CURRENT_PROJECT_VERSION = 4.0.26; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_HARDENED_RUNTIME = NO; @@ -1588,7 +1558,7 @@ INFOPLIST_FILE = "More-Notification-Service-Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "More-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", @@ -1596,7 +1566,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more.More-Notification-Service-Extension; + PRODUCT_BUNDLE_IDENTIFIER = "ac.at.lbg.dhp.more.More-Notification-Service-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1619,7 +1589,7 @@ CODE_SIGN_ENTITLEMENTS = "More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4.1.0; + CURRENT_PROJECT_VERSION = 4.0.26; DEVELOPMENT_TEAM = VX2DSGURUH; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1628,7 +1598,7 @@ INFOPLIST_FILE = "More-Notification-Service-Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "More-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", @@ -1636,7 +1606,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = ac.at.lbg.dhp.more.More-Notification-Service-Extension; + PRODUCT_BUNDLE_IDENTIFIER = "ac.at.lbg.dhp.more.More-Notification-Service-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -1704,7 +1674,7 @@ 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; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1763,7 +1733,7 @@ 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; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -1792,6 +1762,7 @@ 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", @@ -1810,6 +1781,7 @@ 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; }; @@ -1834,6 +1806,7 @@ 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", @@ -1852,6 +1825,7 @@ 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; }; @@ -1890,20 +1864,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 1F9283BB2BC510D100D459A7 /* XCRemoteSwiftPackageReference "realm-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/realm/realm-swift.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 10.49.1; - }; - }; 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; 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" */ = { @@ -1911,17 +1877,12 @@ repositoryURL = "https://github.com/polarofficial/polar-ble-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.5.0; + minimumVersion = 6.6.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1F709AF22C0D8FB700FC6F5A /* RealmSwift */ = { - isa = XCSwiftPackageProductDependency; - package = 1F9283BB2BC510D100D459A7 /* XCRemoteSwiftPackageReference "realm-swift" */; - productName = RealmSwift; - }; 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; package = 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7b609dffa..dd8ea5984 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" : "327c4396f40ddf59fbe2bb60e912e4fe804220437390a45dc9155b80b2ed8e73", "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,8 @@ "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" } }, { @@ -105,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/polarofficial/polar-ble-sdk.git", "state" : { - "revision" : "784f7ed2392bfa2f5a295e7b26a153ce6120d97f", - "version" : "5.5.0" + "revision" : "a077cbaef98c9ba8110eed815c0da25cef7ddbd3", + "version" : "6.6.0" } }, { @@ -118,24 +127,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 +144,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 736b9681f..b9dbb68f4 100644 --- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -52,12 +52,12 @@ diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 7c1b0846f..f405f81d3 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -24,18 +24,20 @@ import FirebaseCrashlyticsSwift class AppDelegate: NSObject, UIApplicationDelegate { static let appGroup = "group.ac.at.lbg.dhp.more.group" static let appGroupUserDefaults = UserDefaults(suiteName: appGroup) + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) static let navigationScreenHandler = NavigationModalState() static let polarConnector = PolarConnector() static let dataUploadManager = DataUploadManager() static let shared: Shared = { - let dataManager = iOSObservationDataManager() + let dataManager = iOSObservationDataManager(database: database) return Shared( localNotificationListener: LocalPushNotifications(), + database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: dataManager, mainBluetoothConnector: polarConnector, - observationFactory: IOSObservationFactory(dataManager: dataManager), + observationFactory: IOSObservationFactory(database: database, dataManager: dataManager), dataRecorder: IOSDataRecorder() ) }() diff --git a/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift index 49543ae0f..3c189e525 100644 --- a/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift +++ b/iosApp/iosApp/BackgroundTasks/DataUploadBackgroundTask.swift @@ -40,7 +40,7 @@ class DataUploadBackgroundTask { } } -extension DataUploadBackgroundTask: BackgroundTaskHandler { +extension DataUploadBackgroundTask: @preconcurrency BackgroundTaskHandler { @MainActor func handleProcessingTask(task: BGProcessingTask) { print("DataUploadBackgroundTask::handleProcessingTask - Starting Background Processing Task") diff --git a/iosApp/iosApp/ContentViewModel.swift b/iosApp/iosApp/ContentViewModel.swift index 8e620f04e..3af3807ce 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -19,15 +19,15 @@ import BackgroundTasks class ContentViewModel: ObservableObject { private let registrationService = RegistrationService(shared: AppDelegate.shared) - + @Published var hasCredentials = false @Published var loginViewScreenNr = 0 @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 @@ -42,14 +42,14 @@ class ContentViewModel: ObservableObject { 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)) lazy var runningViewModel = ScheduleViewModel(scheduleListType: .running) lazy var completedViewModel = ScheduleViewModel(scheduleListType: .completed) @@ -58,45 +58,45 @@ class ContentViewModel: ObservableObject { viewModel.delegate = self return viewModel }() - + var notificationViewModel: NotificationViewModel - + var notificationFilterViewModel: NotificationFilterViewModel - + lazy var infoViewModel = InfoViewModel() lazy var bluetoothViewModel: BluetoothConnectionViewModel = BluetoothConnectionViewModel() - + 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 } } - + 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 } - + AppDelegate.shared.unreadNotificationCountAsClosure { [weak self] kInt in self?.unreadNotificationCount = kInt.intValue } } - + func showLoginView() { DispatchQueue.main.async { self.registrationService.reset() @@ -104,44 +104,44 @@ class ContentViewModel: ObservableObject { self.hasCredentials = false } } - + func showConsentView() { DispatchQueue.main.async { self.loginViewScreenNr = 1 self.consentViewModel.onAppear() } } - + func getTaskDetailsVM(navigationState: NavigationState) -> TaskDetailsViewModel { if let scheduleId = navigationState.scheduleId { taskDetailsVM.setSchedule(scheduleId: scheduleId) } return taskDetailsVM } - + func getSimpleQuestionObservationVM(navigationState: NavigationState) -> SimpleQuestionObservationViewModel { simpleQuestionVM.setScheduleId(navigationState: navigationState) return simpleQuestionVM } - + func getLimeSurveyVM(navigationModalState: NavigationModalState) -> LimeSurveyViewModel { limeSurveyVM.setNavigationModalState(navigationModalState: navigationModalState) return limeSurveyVM } - + 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() } @@ -161,7 +161,7 @@ extension ContentViewModel: ConsentViewModelListener { func decline() { showLoginView() } - + func credentialsStored() { reinitAllViewModels() DispatchQueue.main.async { [weak self] in diff --git a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift index 94ac7955c..08a5a4cd7 100644 --- a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift +++ b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift @@ -17,8 +17,8 @@ import Foundation import shared import PolarBleSdk -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/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/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/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/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift index 4b72ecd25..4f771b7cb 100644 --- a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift @@ -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(database: AppDatabase, sensorPermissions: Set) { + observationRepository = ObservationRepository(appDatabase: database) + super.init(database: database, observationType: AccelerometerType(sensorPermissions: sensorPermissions)) } override func start() -> Bool { diff --git a/iosApp/iosApp/Observations/AccelerometerObservation.swift b/iosApp/iosApp/Observations/AccelerometerObservation.swift index 135c01411..ce74b7a72 100644 --- a/iosApp/iosApp/Observations/AccelerometerObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerObservation.swift @@ -23,8 +23,8 @@ class AccelerometerObservation: Observation_ { private var timer: Timer? = nil - init(sensorPermission: Set) { - super.init(observationType: AccelerometerType(sensorPermissions: sensorPermission)) + init(database: AppDatabase, sensorPermission: Set) { + super.init(database: database, observationType: AccelerometerType(sensorPermissions: sensorPermission)) } override func start() -> Bool { diff --git a/iosApp/iosApp/Observations/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index 3c135f1ab..1a4324944 100644 --- a/iosApp/iosApp/Observations/GPSObservation.swift +++ b/iosApp/iosApp/Observations/GPSObservation.swift @@ -23,8 +23,8 @@ class GPSObservation: Observation_ { public var currentLocation = CLLocation() private var running = false - init(sensorPermissions: Set) { - super.init(observationType: GPSType(sensorPermissions: sensorPermissions)) + init(database: AppDatabase, sensorPermissions: Set) { + super.init(database: database, observationType: GPSType(sensorPermissions: sensorPermissions)) manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyBest } diff --git a/iosApp/iosApp/Observations/IOSObservationFactory.swift b/iosApp/iosApp/Observations/IOSObservationFactory.swift index b17de2b8d..b7a6daffb 100644 --- a/iosApp/iosApp/Observations/IOSObservationFactory.swift +++ b/iosApp/iosApp/Observations/IOSObservationFactory.swift @@ -17,10 +17,10 @@ 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"])) + override init(database: AppDatabase ,dataManager: ObservationDataManager) { + super.init(database: database, dataManager: dataManager) + observations.add(GPSObservation(database: database, sensorPermissions: ["gpsAlways"])) + observations.add(AccelerometerBackgroundObservation(database: database, sensorPermissions: ["cmsensorrecorder"])) + observations.add(PolarVerityHeartRateObservation(database: database, sensorPermissions: ["bluetoothAlways"])) } } diff --git a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift index 509f6651e..21600b5bd 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -37,7 +37,7 @@ class PolarVerityHeartRateObservation: Observation_ { 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 @@ -46,8 +46,8 @@ class PolarVerityHeartRateObservation: Observation_ { private let errorStringTable = "Errors" - init(sensorPermissions: Set) { - super.init(observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) + init(database: AppDatabase, sensorPermissions: Set) { + super.init(database: database, observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) } override func start() -> Bool { diff --git a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift index bfed049ce..4272ad165 100644 --- a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift @@ -18,13 +18,12 @@ 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 { let specificBluetoothConnectors: KotlinMutableDictionary = KotlinMutableDictionary() @@ -52,7 +51,7 @@ class IOSBluetoothConnector: NSObject, 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) { @@ -70,7 +69,7 @@ class IOSBluetoothConnector: NSObject, BluetoothConnector { } } - func disconnect(device: BluetoothDevice) { + func disconnect(device: BluetoothDeviceEntity) { if let cbPeripheral = discoveredDevices[device] { centralManager.cancelPeripheralConnection(cbPeripheral) } @@ -132,37 +131,37 @@ class IOSBluetoothConnector: NSObject, BluetoothConnector { centralManager.stopScan() } - 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) } @@ -173,7 +172,7 @@ class IOSBluetoothConnector: NSObject, BluetoothConnector { updateObserver{ $0.onBluetoothStateChange(bluetoothState: bluetoothState)} } - private func connectToSpecificDevice(device: BluetoothDevice) -> (Bool, KotlinError?) { + 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)) @@ -181,7 +180,7 @@ class IOSBluetoothConnector: NSObject, BluetoothConnector { return (false, nil) } - private func disconnectFromSpecificDevice(device: BluetoothDevice) -> Bool { + 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) @@ -259,8 +258,8 @@ extension IOSBluetoothConnector: CBPeripheralDelegate { } extension CBPeripheral { - func toBluetoothDevice() -> BluetoothDevice { - BluetoothDevice.Companion().create( + func toBluetoothDevice() -> BluetoothDeviceEntity { + BluetoothDeviceEntity.Companion().create( deviceId: self.identifier.uuidString, deviceName: self.name ?? "Unknown", address: self.identifier.uuidString diff --git a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift index 9689f1555..2c8e4d501 100644 --- a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift @@ -65,27 +65,23 @@ class PolarConnector: NSObject, BluetoothConnector { specificBluetoothConnectors[key] = connector } - func connect(device: BluetoothDevice) -> KotlinError? { - if let deviceId = device.deviceId { - do { - try polarApi.connectToDevice(deviceId) - return nil - } catch { - print(error) - return KotlinError(message: error.localizedDescription) - } + func connect(device: BluetoothDeviceEntity) -> KotlinError? { + do { + try polarApi.connectToDevice(device.deviceId) + return nil + } catch { + print(error) + return KotlinError(message: error.localizedDescription) } - return KotlinError(message: "No valid device ID") } - func disconnect(device: BluetoothDevice) { - if let deviceId = device.deviceId { - do { - try polarApi.disconnectFromDevice(deviceId) - } catch { - print(error) - } + func disconnect(device: BluetoothDeviceEntity) { + do { + try polarApi.disconnectFromDevice(device.deviceId) + } catch { + print(error) } + } func scan() { @@ -97,7 +93,7 @@ class PolarConnector: NSObject, BluetoothConnector { 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 @@ -124,37 +120,37 @@ class PolarConnector: NSObject, BluetoothConnector { } - 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 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) } @@ -209,17 +205,17 @@ class PolarConnector: NSObject, BluetoothConnector { 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)) + self.didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } func deviceConnecting(_ identifier: PolarBleSdk.PolarDeviceInfo) { print("Polar connecting: \(identifier.name)") - self.isConnectingToDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) + self.isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } func deviceConnected(_ identifier: PolarDeviceInfo) { print("Polar connected: \(identifier.name)") - self.didConnectToDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) + self.didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } } @@ -273,6 +269,14 @@ extension PolarConnector: PolarBleApiDeviceFeaturesObserver { } 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)") } diff --git a/iosApp/iosApp/Services/DataUploadManager.swift b/iosApp/iosApp/Services/DataUploadManager.swift index 5b0a9f74a..94110d635 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 ObservationDataRepository(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 7cf3c06b9..086aed3bb 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -15,7 +15,6 @@ import FirebaseMessaging import Foundation -import Realm import shared import UserNotifications diff --git a/iosApp/iosApp/Services/LocalPushNotificationService.swift b/iosApp/iosApp/Services/LocalPushNotificationService.swift index 031ac81f2..bb0666715 100644 --- a/iosApp/iosApp/Services/LocalPushNotificationService.swift +++ b/iosApp/iosApp/Services/LocalPushNotificationService.swift @@ -57,7 +57,7 @@ class LocalPushNotifications: LocalNotificationListener { } } - func displayNotification(notification: NotificationSchema) { + func displayNotification(notification: NotificationEntity) { if let title = notification.title, let body = notification.notificationBody { requestLocalNotification(identifier: notification.notificationId, title: title, subtitle: body) } @@ -65,12 +65,8 @@ class LocalPushNotifications: LocalNotificationListener { 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") - } - } else { - UIApplication.shared.applicationIconBadgeNumber = Int(count) + UNUserNotificationCenter.current().setBadgeCount(Int(count)) { error in + print(error ?? "Error setting badge count") } } diff --git a/iosApp/iosApp/Services/TaskScheduleService.swift b/iosApp/iosApp/Services/TaskScheduleService.swift index 67c56bffe..0976291c6 100644 --- a/iosApp/iosApp/Services/TaskScheduleService.swift +++ b/iosApp/iosApp/Services/TaskScheduleService.swift @@ -17,7 +17,7 @@ import Foundation import shared class TaskScheduleService { - private let scheduleRepository = ScheduleRepository() + private let scheduleRepository = ScheduleRepository(appDatabase: AppDelegate.database) private var timer: Timer? func startUpdateTimer() { diff --git a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift index 016ab4c58..7fe0a1205 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift @@ -20,8 +20,8 @@ class BluetoothConnectionViewModel: ObservableObject { private let coreViewModel: CoreBluetoothViewModel = CoreBluetoothViewModel(observationFactory: AppDelegate.shared.observationFactory, coreBluetooth: AppDelegate.shared.bluetoothController) private let deviceManager = BluetoothDeviceManager.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 @@ -98,11 +98,11 @@ class BluetoothConnectionViewModel: ObservableObject { ViewManager.shared.showBLEView(state: false) } - func connectToDevice(device: BluetoothDevice) { + func connectToDevice(device: BluetoothDeviceEntity) { coreViewModel.connectToDevice(device: device) } - func disconnectFromDevice(device: BluetoothDevice) { + func disconnectFromDevice(device: BluetoothDeviceEntity) { coreViewModel.disconnectFromDevice(device: device) } } 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/ExpandableContent.swift b/iosApp/iosApp/Views/Components/ExpandableContent.swift index d0d311ad3..b8471bece 100644 --- a/iosApp/iosApp/Views/Components/ExpandableContent.swift +++ b/iosApp/iosApp/Views/Components/ExpandableContent.swift @@ -28,15 +28,31 @@ struct ExpandableContent: View { UIToggleFoldViewButton(isOpen: $expanded) } - Divider().padding(.bottom) + Divider() - 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) + } + + } + .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..663eb2795 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/). // @@ -27,17 +27,31 @@ struct ExpandableContentWithLink: View { 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/ExpandableText.swift b/iosApp/iosApp/Views/Components/ExpandableText.swift index 9caa0dad4..a03251d21 100644 --- a/iosApp/iosApp/Views/Components/ExpandableText.swift +++ b/iosApp/iosApp/Views/Components/ExpandableText.swift @@ -65,8 +65,9 @@ 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) } } } @@ -81,11 +82,13 @@ struct ExpandableText: View { }) 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)) .font(.system(size: 16)) + } } } diff --git a/iosApp/iosApp/Views/Components/InlineAbortButton.swift b/iosApp/iosApp/Views/Components/InlineAbortButton.swift index 58fcfe473..5f1ffef2b 100644 --- a/iosApp/iosApp/Views/Components/InlineAbortButton.swift +++ b/iosApp/iosApp/Views/Components/InlineAbortButton.swift @@ -33,7 +33,7 @@ struct InlineAbortButton: View { .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..5f5f3c06b 100644 --- a/iosApp/iosApp/Views/Components/ModuleListItem.swift +++ b/iosApp/iosApp/Views/Components/ModuleListItem.swift @@ -17,7 +17,7 @@ import SwiftUI import shared struct ModuleListItem: View { - let observation: ObservationSchema + let observation: ObservationEntity var body: some View { VStack{ @@ -39,6 +39,6 @@ struct ModuleListItem: View { 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, version: 0, required: true, collectionTimestamp: Date().timeIntervalSince1970.asTimestamp())) } } 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/NotificationItem.swift b/iosApp/iosApp/Views/Components/NotificationItem.swift index 2c7cfde78..ce7b112a9 100644 --- a/iosApp/iosApp/Views/Components/NotificationItem.swift +++ b/iosApp/iosApp/Views/Components/NotificationItem.swift @@ -42,7 +42,7 @@ struct NotificationItem: View { 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: (notificationModel.timestamp).toDateString(dateFormat: "dd.MM.yyyy HH:mm:ss")) .padding(.top, 4) } Spacer() 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/WebView/WebView.swift b/iosApp/iosApp/Views/Components/WebView/WebView.swift index 80d30455c..391c8bf91 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebView.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebView.swift @@ -25,14 +25,10 @@ struct WebView: View { if viewModel.progress < 1 { ProgressView(value: viewModel.progress, total: 1) } - if #available(iOS 15.0, *) { - SwiftUIWebView(viewModel: viewModel, url: url) - .refreshable { - viewModel.webView.reload() - } - } else { - SwiftUIWebView(viewModel: viewModel, url: url) - } + SwiftUIWebView(viewModel: viewModel, url: url) + .refreshable { + viewModel.webView.reload() + } } } diff --git a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift index 4172572f4..d842b4685 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift @@ -60,7 +60,6 @@ extension WebViewViewModel: WKNavigationDelegate { print("WebView didStartProviisonalNavigation") } - @available(iOS 14.5, *) func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { print("WebView didBecome download") } diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 2d604fa25..7e933cf6c 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -92,7 +92,8 @@ struct ConsentView: View { } struct ConsentView_Previews: PreviewProvider { + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) 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())))) + ConsentView(viewModel: ConsentViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(database: database, dataManager: ObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) } } diff --git a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift index 58bbcaa11..633bd9b68 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift @@ -14,7 +14,7 @@ protocol DashboardFilterObserver { } class DashboardFilterViewModel: ObservableObject { - let coreViewModel: CoreDashboardFilterViewModel = CoreDashboardFilterViewModel() + let coreViewModel: CoreDashboardFilterViewModel = CoreDashboardFilterViewModel(database: AppDelegate.database) private let stringTable = "DashboardFilter" var delegate: DashboardFilterObserver? = nil diff --git a/iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift b/iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift index d4fda7533..6858c759f 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift @@ -16,11 +16,11 @@ import shared class DashboardViewModel: ObservableObject { - private let coreViewModel: CoreDashboardViewModel = CoreDashboardViewModel() + private let coreViewModel: CoreDashboardViewModel = CoreDashboardViewModel(database: AppDelegate.database) let scheduleViewModel: ScheduleViewModel @Published var studyTitle: String = "" - @Published var study: StudySchema? = StudySchema() + @Published var study: StudyEntity? @Published var filterText: String = "" init(scheduleViewModel: ScheduleViewModel) { diff --git a/iosApp/iosApp/Views/Info/InfoView.swift b/iosApp/iosApp/Views/Info/InfoView.swift index c5b88e344..1c0ec7eac 100644 --- a/iosApp/iosApp/Views/Info/InfoView.swift +++ b/iosApp/iosApp/Views/Info/InfoView.swift @@ -26,14 +26,14 @@ struct InfoView: View { Divider() VStack { InfoList() - .hideListRowSeparator() + .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) .listRowBackground(Color.more.primaryLight) .padding(.top, 7) Spacer() } .listStyle(.plain) - .clearListBackground() + .scrollContentBackground(.hidden) Spacer() diff --git a/iosApp/iosApp/Views/Info/InfoViewModel.swift b/iosApp/iosApp/Views/Info/InfoViewModel.swift index 4fb0c20ad..0c275a8ab 100644 --- a/iosApp/iosApp/Views/Info/InfoViewModel.swift +++ b/iosApp/iosApp/Views/Info/InfoViewModel.swift @@ -17,7 +17,7 @@ import shared class InfoViewModel: ObservableObject { - private let studyCoreModel = CoreStudyDetailsViewModel() + private let studyCoreModel = CoreStudyDetailsViewModel(appDatabase: AppDelegate.database) @Published var studyTitle: String? @Published var contactInstitute: String? @Published var contactPerson: String? diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift index f60743096..1aa1616c0 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift @@ -18,7 +18,7 @@ import shared import WebKit class LimeSurveyViewModel: ObservableObject { - private let coreViewModel = CoreLimeSurveyViewModel(observationFactory: AppDelegate.shared.observationFactory) + private let coreViewModel = CoreLimeSurveyViewModel(database: AppDelegate.database, observationFactory: AppDelegate.shared.observationFactory) let webViewModel = WebViewViewModel() diff --git a/iosApp/iosApp/Views/Login/LoginButton.swift b/iosApp/iosApp/Views/Login/LoginButton.swift index d6b1c51ff..1a4ed0d27 100644 --- a/iosApp/iosApp/Views/Login/LoginButton.swift +++ b/iosApp/iosApp/Views/Login/LoginButton.swift @@ -31,8 +31,9 @@ struct LoginButton: View { } 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())))) + .environmentObject(LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(database: database, dataManager: ObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) } } diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 7b2ee6ba0..f44dd3f19 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -99,7 +99,8 @@ struct LoginView: View { } struct LoginView_Previews: PreviewProvider { + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) 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())))) + LoginView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(database: database, dataManager: iOSObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) } } diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index effab8610..938d1616d 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -109,8 +109,9 @@ struct ScanQRCodeView: View { } struct ScanQRCodeView_Previews: PreviewProvider { + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) static var previews: some View { - ScanQRCodeView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(dataManager: iOSObservationDataManager()), dataRecorder: IOSDataRecorder())))) + ScanQRCodeView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(database: database, dataManager: iOSObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) } } diff --git a/iosApp/iosApp/Views/MainTabView.swift b/iosApp/iosApp/Views/MainTabView.swift index 362a84c4b..ca8c88f4a 100644 --- a/iosApp/iosApp/Views/MainTabView.swift +++ b/iosApp/iosApp/Views/MainTabView.swift @@ -61,7 +61,7 @@ struct MainTabView: View { .tag(2) } } - .accent(color: .more.primaryDark) + .tint(.more.primaryDark) .onAppear { UITabBar.appearance().barTintColor = UIColor(Color.more.primaryLight) UITabBar.appearance().unselectedItemTintColor = UIColor(Color.more.primary) diff --git a/iosApp/iosApp/Views/Navigation.swift b/iosApp/iosApp/Views/Navigation.swift index 618e3b844..713f664c2 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,45 +75,15 @@ 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) { @@ -165,7 +123,6 @@ struct NavigationWithDestinations: View { } } -@available(iOS 16, *) struct NavigationTitleViewModifier: ViewModifier { var text: String var displayMode: NavigationBarItem.TitleDisplayMode = .automatic @@ -177,16 +134,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 +141,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)) - } + self.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 { + self.modifier(TextFieldViewModifier(capitalization: .characters)) + } else if capitalization == .lowercase { + self.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)) - } + self.modifier(TextFieldViewModifier(capitalization: .sentences)) } } @@ -227,31 +160,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/Notification/NotificationView.swift b/iosApp/iosApp/Views/Notification/NotificationView.swift index 16afeaf5c..7fddbbf67 100644 --- a/iosApp/iosApp/Views/Notification/NotificationView.swift +++ b/iosApp/iosApp/Views/Notification/NotificationView.swift @@ -32,18 +32,22 @@ struct NotificationView: View { if notificationViewModel.notificationList.isEmpty { EmptyListView(text: "There are currently no notficiations to show".localize(withComment: "Empty notification list", useTable: stringTable)) } 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) + } + } } } } diff --git a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift index bb349e8c3..217668458 100644 --- a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift +++ b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift @@ -22,7 +22,7 @@ class ObservationDetailsViewModel: ObservableObject { init(observationId: String) { - self.coreModel = CoreObservationDetailsViewModel(observationId: observationId) + self.coreModel = CoreObservationDetailsViewModel(database: AppDelegate.database, observationId: observationId) coreModel.onLoadObservationDetails { observationDetails in if let observationDetails { self.observationDetailModel = observationDetails diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift index ef4f9a685..79afa53fc 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift +++ b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift @@ -20,7 +20,7 @@ protocol SimpleQuestionObservationListener { } class SimpleQuestionObservationViewModel: ObservableObject { - private let coreModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(observationFactory: AppDelegate.shared.observationFactory) + private let coreModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(appDatabase: AppDelegate.database, observationFactory: AppDelegate.shared.observationFactory) @Published var simpleQuestoinModel: SimpleQuestionModel? @Published var answers: [String] = [] diff --git a/iosApp/iosApp/Views/Schedule/ScheduleView.swift b/iosApp/iosApp/Views/Schedule/ScheduleView.swift index 32cf4aa32..2baeb0e1e 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleView.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleView.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,47 +24,48 @@ struct ScheduleView: View { 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".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)) + } else { + EmptyListView(text: "No tasks to show".localize(withComment: "No tasks in list shown", useTable: stringsTable)) + } } 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 +77,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..db1d167b9 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -28,7 +28,7 @@ class ScheduleViewModel: ObservableObject { init(scheduleListType: ScheduleListType) { self.scheduleListType = scheduleListType - coreModel = CoreScheduleViewModel(dataRecorder: recorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) + coreModel = CoreScheduleViewModel(database: AppDelegate.database, dataRecorder: recorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) loadSchedules() ViewManager.shared.studyIsUpdatingAsClosure { [weak self] kBool in diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift index 8abd3890d..5aef81963 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift @@ -16,7 +16,7 @@ import shared class StudyDetailsViewModel: ObservableObject { - private let coreModel = CoreStudyDetailsViewModel() + private let coreModel = CoreStudyDetailsViewModel(appDatabase: AppDelegate.database) @Published var studyDetailsModel: StudyDetailsModel? var studyStart: Date = Date() var studyEnd: Date = Date() @@ -26,10 +26,10 @@ class StudyDetailsViewModel: ObservableObject { if let self, let studyDetails { self.studyDetailsModel = studyDetails if let start = studyDetails.study.start { - self.studyStart = start.epochSeconds.toDate() + self.studyStart = start.toInt64().toDate() } if let end = studyDetails.study.end { - self.studyEnd = end.epochSeconds.toDate() + self.studyEnd = end.toInt64().toDate() } } } diff --git a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift index bddebf3ef..e9135ec2b 100644 --- a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift +++ b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift @@ -18,7 +18,7 @@ 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(database: AppDelegate.database) init() { loadTaskCompletion() diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift index 9c8af5ac6..783480e11 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift @@ -37,7 +37,7 @@ class TaskDetailsViewModel: ObservableObject { var simpleQuestionObservationVM: SimpleQuestionObservationViewModel init(dataRecorder: DataRecorder) { - self.coreModel = CoreTaskDetailsViewModel(dataRecorder: dataRecorder) + self.coreModel = CoreTaskDetailsViewModel(database: AppDelegate.database, dataRecorder: dataRecorder) self.simpleQuestionObservationVM = SimpleQuestionObservationViewModel() coreModel.onLoadTaskDetails { [weak self] taskDetails in if let self { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f431fd5d7..02e0e5917 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,7 +3,8 @@ plugins { kotlin("plugin.serialization") id("com.android.library") - id("io.realm.kotlin") version "1.14.1" + id("androidx.room") + id("com.google.devtools.ksp") } val generated = "$rootDir/shared/build/generated" @@ -11,21 +12,21 @@ 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.more_app_multiplatform.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.2.3" val napierVersion = "2.7.1" -val serializationVersion = "1.6.0" -val gsonVersion = "2.10.1" +val serializationVersion = "1.9.0" +val gsonVersion = "2.13.1" +val roomVersion = "2.7.2" +val sqliteVersion = "2.5.2" kotlin { androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = "11" - } + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) } publishLibraryVariants("release") } @@ -47,12 +48,15 @@ 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") } commonTest.dependencies { @@ -60,7 +64,7 @@ kotlin { } 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") } @@ -84,6 +88,19 @@ android { } } -task("testClasses").doLast { - println("This is a dummy testClasses task") +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") +} + +tasks.register("testClasses") { + doLast { + println("This is a dummy testClasses task") + } } \ 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/database/DatabaseManager.android.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.android.kt new file mode 100644 index 000000000..be8cf4fb4 --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.android.kt @@ -0,0 +1,14 @@ +package io.redlink.more.more_app_mutliplatform.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/services/network/HttpClientReceiver.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt index 5d24dd0db..b6fda548e 100644 --- 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 @@ -10,24 +10,36 @@ */ 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.* +import io.ktor.client.HttpClient +import io.ktor.client.engine.android.Android +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.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(Android) { install(ContentNegotiation) { json() - defaultRequest { - contentType(ContentType.Application.Json) - } - Logging { - logger = Logger.DEFAULT - level = LogLevel.ALL - } + } + + 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/EncryptedSharedPreferences.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EncryptedSharedPreferences.kt index cdc38a876..36347bbed 100644 --- 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 @@ -8,22 +8,104 @@ * (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 +import io.github.aakira.napier.Napier +import java.io.File +import java.security.GeneralSecurityException +import javax.crypto.AEADBadTagException private const val ENCRYPT_SHARED_PREF_FILENAME = "credentials_file" +private const val TAG = "EncryptedSharedPrefs" + +// TODO: Remove encrypted shared preferences after all active devices updated to this version class EncryptedSharedPreferences { companion object { fun create(context: Context): SharedPreferences { + return try { + createEncryptedSharedPreferences(context) + } catch (exception: AEADBadTagException) { + Napier.w( + exception, + tag = TAG + ) { "Failed to create EncryptedSharedPreferences, attempting recovery" } + handleEncryptionFailure(context, exception) + } + } + + private fun createEncryptedSharedPreferences(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) + + + return EncryptedSharedPreferences.create( + context, + ENCRYPT_SHARED_PREF_FILENAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private fun handleEncryptionFailure( + context: Context, + exception: Exception + ): SharedPreferences = when (exception) { + is AEADBadTagException, is GeneralSecurityException -> { + Napier.w(tag = TAG) { "Encryption/decryption error detected, clearing corrupted data" } + clearCorruptedData(context) + + try { + createEncryptedSharedPreferences(context) + } catch (retryException: Exception) { + Napier.e( + retryException, + tag = TAG + ) { "Failed to create EncryptedSharedPreferences after recovery, falling back to regular SharedPreferences" } + createFallbackSharedPreferences(context) + } + } + + else -> { + Napier.e( + exception, + tag = TAG + ) { "Unexpected error creating EncryptedSharedPreferences, falling back to regular SharedPreferences" } + createFallbackSharedPreferences(context) + } + } + + private fun clearCorruptedData(context: Context) { + try { + val sharedPrefsFile = + context.getSharedPreferences(ENCRYPT_SHARED_PREF_FILENAME, Context.MODE_PRIVATE) + sharedPrefsFile.edit().clear().apply() + + val prefsDir = context.filesDir.parent?.let { "$it/shared_prefs/" } + ?: throw IllegalStateException("Failed to determine shared_prefs directory") + val prefsFile = File(prefsDir, "$ENCRYPT_SHARED_PREF_FILENAME.xml") + if (prefsFile.exists()) { + prefsFile.delete() + } + + Napier.i(tag = TAG) { "Successfully cleared corrupted encrypted preferences data" } + } catch (clearException: Exception) { + Napier.e(clearException, tag = TAG) { "Failed to clear corrupted data" } + } + } + + private fun createFallbackSharedPreferences(context: Context): SharedPreferences { + Napier.w(tag = TAG) { "Using fallback unencrypted SharedPreferences - consider implementing additional security measures" } + return context.getSharedPreferences( + "${ENCRYPT_SHARED_PREF_FILENAME}_fallback", + Context.MODE_PRIVATE + ) } } } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt new file mode 100644 index 000000000..1c1725c4d --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt @@ -0,0 +1,104 @@ +package io.redlink.more.more_app_mutliplatform.services.store + +import android.content.Context +import android.content.SharedPreferences +import io.github.aakira.napier.Napier + +private const val PRIVATE_SHARED_PREF_FILENAME = "more_app_preferences" +private const val ENCRYPT_SHARED_PREF_FILENAME = "credentials_file" +private const val ENCRYPT_SHARED_PREF_FALLBACK_FILENAME = "${ENCRYPT_SHARED_PREF_FILENAME}_fallback" +private const val TAG = "PrivateSharedPrefs" + +// This is a replacement key value store for the encrypted shared preferences, as those would crash the app if the key was not right anymore, like after an app reinstall, phone restore or phone transfer where the data were backed up, but the key did not. +// TODO: Remove migration script after all active devices updated to this version +class PrivateSharedPreferences { + companion object { + fun create(context: Context): SharedPreferences { + val privatePrefs = createPrivateSharedPreferences(context) + + if (shouldMigrateFromEncrypted(context)) { + migrateFromEncryptedPreferences(context, privatePrefs) + } + + return privatePrefs + } + + private fun createPrivateSharedPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences( + PRIVATE_SHARED_PREF_FILENAME, + Context.MODE_PRIVATE + ) + } + + private fun shouldMigrateFromEncrypted(context: Context): Boolean { + val encryptedPrefsFile = + context.getSharedPreferences(ENCRYPT_SHARED_PREF_FILENAME, Context.MODE_PRIVATE) + val fallbackPrefsFile = context.getSharedPreferences( + ENCRYPT_SHARED_PREF_FALLBACK_FILENAME, + Context.MODE_PRIVATE + ) + + return encryptedPrefsFile.all.isNotEmpty() || fallbackPrefsFile.all.isNotEmpty() + } + + private fun migrateFromEncryptedPreferences( + context: Context, + privatePrefs: SharedPreferences + ) { + try { + Napier.i(tag = TAG) { "Starting migration from encrypted preferences" } + + val encryptedPrefs = try { + EncryptedSharedPreferences.create(context) + } catch (e: Exception) { + Napier.w( + e, + tag = TAG + ) { "Failed to access encrypted preferences, trying fallback" } + context.getSharedPreferences( + ENCRYPT_SHARED_PREF_FALLBACK_FILENAME, + Context.MODE_PRIVATE + ) + } + + val editor = privatePrefs.edit() + + for ((key, value) in encryptedPrefs.all) { + when (value) { + is String -> editor.putString(key, value) + is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value) + is Float -> editor.putFloat(key, value) + is Long -> editor.putLong(key, value) + else -> Napier.w(tag = TAG) { "Unknown type for key: $key, value: $value" } + } + } + + editor.apply() + + Napier.i(tag = TAG) { "Migration completed successfully" } + + clearOldPreferences(context) + } catch (e: Exception) { + Napier.e(e, tag = TAG) { "Error during migration from encrypted preferences" } + } + } + + private fun clearOldPreferences(context: Context) { + try { + context.getSharedPreferences(ENCRYPT_SHARED_PREF_FILENAME, Context.MODE_PRIVATE) + .edit().clear().apply() + + context.getSharedPreferences( + ENCRYPT_SHARED_PREF_FALLBACK_FILENAME, + Context.MODE_PRIVATE + ) + .edit().clear().apply() + + Napier.i(tag = TAG) { "Old preferences cleared successfully" } + } catch (e: Exception) { + Napier.w(e, tag = TAG) { "Failed to clear old preferences" } + } + } + } +} \ 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/more_app_mutliplatform/services/store/SharedPreferencesRepository.kt index 079329ab7..a4196a2f3 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedPreferencesRepository.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/SharedPreferencesRepository.kt @@ -14,7 +14,7 @@ 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/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Greeting.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Greeting.kt deleted file mode 100644 index 6a38eb7a3..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Greeting.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.more_app_mutliplatform - -class Greeting { - private val platform: Platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ 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 index 9ef308946..600e04167 100644 --- 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 @@ -10,15 +10,11 @@ */ package io.redlink.more.more_app_mutliplatform +import dev.tmapps.konnection.Konnection 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.AppDatabase 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 @@ -39,6 +35,7 @@ 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 @@ -46,6 +43,7 @@ import kotlinx.coroutines.sync.withLock class Shared( localNotificationListener: LocalNotificationListener, + val database: AppDatabase, private val sharedStorageRepository: SharedStorageRepository, val observationDataManager: ObservationDataManager, val mainBluetoothConnector: BluetoothConnector, @@ -53,16 +51,17 @@ class Shared( val dataRecorder: DataRecorder ) { private val viewManager = ViewManager - val deeplinkManager = DeeplinkManager(observationFactory) + val deeplinkManager = DeeplinkManager(database, 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 observationManager = ObservationManager(database, observationFactory, dataRecorder) + val bluetoothController = BluetoothController(database, mainBluetoothConnector) val notificationManager = NotificationManager( + database, localNotificationListener, networkService, deeplinkManager, @@ -78,6 +77,9 @@ class Shared( private var bluetoothListener: Job? = null private val mutex = Mutex() + private val konnection = Konnection.instance + + val studyRepository = StudyRepository(database) init { onApplicationStart() @@ -102,7 +104,7 @@ class Shared( updateStudyBlocking() notificationManager.createNewFCMIfNecessary() bluetoothListener?.cancel() - bluetoothListener = StudyScope.launch { + bluetoothListener = StudyScope.launch(Dispatchers.IO) { bluetoothController.listenToConnectionChanges( observationFactory ) @@ -125,8 +127,10 @@ class Shared( } private fun activateObservationWatcher(overwriteCheck: Boolean = false) { - StudyScope.launch { - if (overwriteCheck || StudyRepository().getStudy().firstOrNull()?.active == true) { + StudyScope.launch(Dispatchers.IO) { + if (overwriteCheck || studyRepository.getStudy() + .firstOrNull()?.active == true + ) { observationDataManager.listenToDatapointCountChanges() updateTaskStates() observationManager.activateScheduleUpdate() @@ -159,10 +163,8 @@ class Shared( oldStudyState: StudyState? = null, newStudyState: StudyState? = null ) { - Scope.launch(Dispatchers.Default) { - ViewManager.checkingForUpdate(true) + Scope.launch(Dispatchers.IO) { updateStudy(oldStudyState, newStudyState) - ViewManager.checkingForUpdate(false) } } @@ -176,7 +178,6 @@ class Shared( } 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}" } @@ -187,16 +188,23 @@ class Shared( 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 { + if (!konnection.isConnected()) { + Napier.d(tag = "Shared::updateStudy") { "No network connection, skipping study update" } + if (newStudyState != null) { + studyStateRepository.storeState(newStudyState) + } + return + } + val (study, error) = networkService.getStudyConfig() if (error != null) { Napier.e { error.message } @@ -206,13 +214,41 @@ class Shared( 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) { + var stateChanged = false + var activeStatusChanged = false + var versionChanged = false + + currentStudy?.let { current -> + val newState = study.studyState?.let { StudyState.getState(it) } + val currentState = current.getState() + + if (newState != currentState) { + stateChanged = true studyHasChanged = true + Napier.d(tag = "Shared::updateStudy") { "Study state changed: $currentState -> $newState" } + } + + if (current.active != study.active) { + activeStatusChanged = true + studyHasChanged = true + Napier.d(tag = "Shared::updateStudy") { "Study active status changed: ${current.active} -> ${study.active}" } + } + + if (current.version != study.version) { + versionChanged = true + studyHasChanged = true + Napier.d(tag = "Shared::updateStudy") { "Study version changed: ${current.version} -> ${study.version}" } } } - if (studyHasChanged || currentStudy == null) { + + val hasNoCurrentStudy = currentStudy == 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) StudyScope.cancel() stopObservations() @@ -234,7 +270,10 @@ class Shared( activateObservationWatcher(true) } viewManager.studyIsUpdating(false) + } else { + Napier.d(tag = "Shared::updateStudy") { "No study update needed - study data is unchanged" } } + if (newStudyState != null) { studyStateRepository.storeState(newStudyState) } @@ -246,10 +285,9 @@ class Shared( notificationManager.newFCMToken() studyStateRepository.storeState(StudyState.ACTIVE) StudyScope.launch { - finishText = StudyRepository().getStudy().firstOrNull()?.finishText + finishText = studyRepository.getStudy().firstOrNull()?.finishText } activateObservationWatcher() - updateTaskStates() observationFactory.updateObservationErrors() bluetoothListener?.cancel() bluetoothListener = StudyScope.launch { @@ -263,12 +301,12 @@ class Shared( StudyScope.cancel() stopObservations() bluetoothController.resetAll() - Scope.launch { + Scope.launch(Dispatchers.IO) { networkService.deleteParticipation() notificationManager.clearAllNotifications() notificationManager.deleteFCMToken() - clearSharedStorage() removeStudyData() + clearSharedStorage() onDeletion() observationFactory.clearNeededObservationTypes() viewManager.resetAll() @@ -276,6 +314,18 @@ class Shared( } } + fun clearRemainingData() { + Napier.i { "Clearing remaining data..." } + bluetoothController.resetAll() + Scope.launch { + removeStudyData() + observationFactory.clearNeededObservationTypes() + clearSharedStorage() + notificationManager.clearAllNotifications() + studyStateRepository.storeState(StudyState.NONE) + } + } + private fun stopObservations() { dataRecorder.stopAll() observationDataManager.stopListeningToCountChanges() @@ -285,16 +335,13 @@ class Shared( credentialRepository.remove() } - private suspend fun removeStudyData() { - DatabaseManager.deleteAllFromSchema( - setOf( - StudySchema::class, - ObservationSchema::class, - ScheduleSchema::class, - ObservationDataSchema::class, - DataPointCountSchema::class, - ) - ) + suspend fun removeStudyData() { + database.studyDao().deleteAll() + database.observationDao().deleteAll() + database.scheduleDao().deleteAll() + database.observationDataDao().deleteAll() + database.dataPointDao().deleteAll() + database.notificationDao().deleteAll() observationFactory.clearNeededObservationTypes() } @@ -307,4 +354,4 @@ class Shared( 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/AppDatabase.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/AppDatabase.kt new file mode 100644 index 000000000..e350c9a57 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/AppDatabase.kt @@ -0,0 +1,42 @@ +package io.redlink.more.more_app_mutliplatform.database + +import androidx.room.ConstructedBy +import androidx.room.Database +import androidx.room.RoomDatabase +import io.redlink.more.more_app_mutliplatform.database.dao.BluetoothDeviceDao +import io.redlink.more.more_app_mutliplatform.database.dao.DataPointDao +import io.redlink.more.more_app_mutliplatform.database.dao.NotificationDao +import io.redlink.more.more_app_mutliplatform.database.dao.ObservationDao +import io.redlink.more.more_app_mutliplatform.database.dao.ObservationDataDao +import io.redlink.more.more_app_mutliplatform.database.dao.ScheduleDao +import io.redlink.more.more_app_mutliplatform.database.dao.StudyDao +import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity +import io.redlink.more.more_app_mutliplatform.database.entities.DataPointEntity +import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity +import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity + +@Database( + entities = [ + StudyEntity::class, + ScheduleEntity::class, + ObservationEntity::class, + ObservationDataEntity::class, + NotificationEntity::class, + BluetoothDeviceEntity::class, + DataPointEntity::class + ], + version = 1 +) +@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 +} \ 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 index 2b631dbb1..7a4e22acc 100644 --- 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 @@ -10,50 +10,19 @@ */ 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 +import androidx.room.RoomDatabase +import androidx.room.RoomDatabaseConstructor +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO -object DatabaseManager: Closeable { - val database = RealmDatabase - private val schemas = setOf( - StudySchema::class, - ObservationSchema::class, - ScheduleSchema::class, - ObservationDataSchema::class, - DataPointCountSchema::class, - NotificationSchema::class, - BluetoothDevice::class - ) +@Suppress("KotlinNoActualForExpect") +expect object AppDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): AppDatabase +} - 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 +fun getRoomDatabase(builder: RoomDatabase.Builder): AppDatabase = + builder + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() \ 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 b6c5baa0d..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 = 5 - -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/dao/BaseDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt new file mode 100644 index 000000000..43380b742 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.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.more_app_mutliplatform.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) + + /** + * 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/more_app_mutliplatform/database/dao/BluetoothDeviceDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BluetoothDeviceDao.kt new file mode 100644 index 000000000..f24046efc --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BluetoothDeviceDao.kt @@ -0,0 +1,37 @@ +package io.redlink.more.more_app_mutliplatform.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/database/dao/DataPointDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/DataPointDao.kt new file mode 100644 index 000000000..209f1b56f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/DataPointDao.kt @@ -0,0 +1,30 @@ +package io.redlink.more.more_app_mutliplatform.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/database/dao/NotificationDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/NotificationDao.kt new file mode 100644 index 000000000..b5ac2a21e --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/NotificationDao.kt @@ -0,0 +1,158 @@ +/* + * 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.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.more_app_mutliplatform.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 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/more_app_mutliplatform/database/dao/ObservationDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDao.kt new file mode 100644 index 000000000..57592512f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/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.more_app_mutliplatform.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/database/dao/ObservationDataDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDataDao.kt new file mode 100644 index 000000000..471b76f57 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/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.more_app_mutliplatform.database.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/database/dao/ScheduleDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ScheduleDao.kt new file mode 100644 index 000000000..88d46b18f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ScheduleDao.kt @@ -0,0 +1,104 @@ +/* + * 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.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.more_app_mutliplatform.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 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 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("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) + + @Query("UPDATE schedules SET hidden = :hidden WHERE scheduleId = :scheduleId") + suspend fun updateHidden(scheduleId: String, hidden: Boolean) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt new file mode 100644 index 000000000..de29175e6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt @@ -0,0 +1,62 @@ +/* + * 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.dao + +import androidx.room.Dao +import androidx.room.Query +import io.redlink.more.more_app_mutliplatform.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") + suspend fun getAll(): List + + @Query("SELECT * FROM studies") + fun getAllFlow(): 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 +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/BluetoothDeviceEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/BluetoothDeviceEntity.kt new file mode 100644 index 000000000..8fd9ff6ee --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/BluetoothDeviceEntity.kt @@ -0,0 +1,27 @@ +package io.redlink.more.more_app_mutliplatform.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/database/entities/DataPointEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/DataPointEntity.kt new file mode 100644 index 000000000..35899885c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/DataPointEntity.kt @@ -0,0 +1,13 @@ +package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/database/entities/NotificationEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/NotificationEntity.kt new file mode 100644 index 000000000..5796d0f9d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/NotificationEntity.kt @@ -0,0 +1,138 @@ +/* + * 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.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +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.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, + channelId = null, + title = notification.title, + notificationBody = notification.body, + timestamp = notification.timestamp?.epochSeconds, + priority = 1, + read = false, + completed = false, + userFacing = notification.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/more_app_mutliplatform/database/entities/ObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationDataEntity.kt new file mode 100644 index 000000000..02a7de1ac --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationDataEntity.kt @@ -0,0 +1,99 @@ +/* + * 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.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.extensions.asString +import io.redlink.more.more_app_mutliplatform.observations.ObservationBulkModel +import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.ObservationData +import io.redlink.more.more_app_mutliplatform.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() + ) + } + + fun fromData(data: Any, timestamp: Long = -1): ObservationDataEntity { + val finalTimestamp = if (timestamp > 0) { + timestamp * 1000 // Convert to milliseconds + } 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/more_app_mutliplatform/database/entities/ObservationEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationEntity.kt new file mode 100644 index 000000000..1e789c652 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationEntity.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.more_app_mutliplatform.database.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.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 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, + required = observation.required, + version = observation.version + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt new file mode 100644 index 000000000..e3b164e9b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt @@ -0,0 +1,91 @@ +package io.redlink.more.more_app_mutliplatform.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.redlink.more.more_app_mutliplatform.models.ScheduleState +import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.ObservationSchedule +import io.redlink.more.more_app_mutliplatform.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 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 { + val newState = 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().running() -> ScheduleState.ACTIVE + else -> ScheduleState.getState(state) + } + } + } ?: ScheduleState.getState(state) + } + + return newState + } + + fun equalsSchedule(other: ScheduleEntity): Boolean { + return scheduleId == other.scheduleId + } + + 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, + ): 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 + ) + } else null + } + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/StudyEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/StudyEntity.kt new file mode 100644 index 000000000..76b29f07c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/StudyEntity.kt @@ -0,0 +1,67 @@ +package io.redlink.more.more_app_mutliplatform.database.entities + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.redlink.more.more_app_mutliplatform.models.StudyState +import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study +import io.redlink.more.more_app_mutliplatform.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?.let { StudyState.getState(it) } + ?: if (active) StudyState.ACTIVE else StudyState.PAUSED).descr, + finishText = study.finishText + ) + } + } +} 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 index ea2d5b511..09e102b05 100644 --- 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 @@ -10,59 +10,72 @@ */ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity 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.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.map -class BluetoothDeviceRepository : - Repository() { +class BluetoothDeviceRepository( + private val database: AppDatabase +) { private val deviceManager = BluetoothDeviceManager init { - Scope.launch { + Scope.launch(Dispatchers.IO) { pairedDevices().cancellable().collect { deviceManager.addPairedDeviceIds(it.toSet()) } } } - override fun count(): Flow = realmDatabase().count() + fun count(): Flow = database.bluetoothDeviceDao().getAllFlow().map { it.size.toLong() } - fun storePairedDevice(bluetoothDevice: BluetoothDevice) { + fun storePairedDevice(bluetoothDevice: BluetoothDeviceEntity) { 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) - } + val existingDevice = + database.bluetoothDeviceDao().getByAddress(bluetoothDevice.address!!) + if (existingDevice == null) { + val entity = bluetoothDevice.toEntity() + database.bluetoothDeviceDao().insert(entity) } } } } - 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 unpairDevice(bluetoothDevice: BluetoothDeviceEntity) { + bluetoothDevice.address?.let { + StudyScope.launch(Dispatchers.IO) { + database.bluetoothDeviceDao().deleteByAddress(it) } } } - fun pairedDevices() = realmDatabase().query() + fun pairedDevices(): Flow> = + database.bluetoothDeviceDao().getAllFlow().map { entities -> + entities.map { it.toBluetoothDevice() } + } + + private fun BluetoothDeviceEntity.toEntity(): BluetoothDeviceEntity { + return BluetoothDeviceEntity( + deviceId = this.deviceId, + deviceName = this.deviceName, + address = this.address + ) + } + + private fun BluetoothDeviceEntity.toBluetoothDevice(): BluetoothDeviceEntity { + return BluetoothDeviceEntity( + this@toBluetoothDevice.deviceId, + this@toBluetoothDevice.deviceName, + this@toBluetoothDevice.address + ) + } } \ 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 index 2d690e2bd..93dafc5f5 100644 --- 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 @@ -10,23 +10,24 @@ */ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.DataPointEntity 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.flow.conflate import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -class DataPointCountRepository : Repository() { +class DataPointCountRepository(private val appDatabase: AppDatabase) { private val mutex = Mutex() private val countQueue = mutableMapOf() private var storeJob: Job? = null - override fun count(): Flow { - return realmDatabase().count() + fun count(): Flow { + return appDatabase.dataPointDao().getCountFlow() } fun incrementCount(scheduleIdSet: Set, addCount: Long = 1) { @@ -38,7 +39,7 @@ class DataPointCountRepository : Repository() { } } if (storeJob == null || storeJob?.isActive == false) { - storeJob = StudyScope.repeatedLaunch(5000L) { + storeJob = StudyScope.repeatedLaunch(5000L, Dispatchers.IO) { storeCounts() }.second storeJob?.invokeOnCompletion { @@ -56,42 +57,38 @@ class DataPointCountRepository : Repository() { 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 - }) + StudyScope.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) + } } } } - fun get(scheduleId: String): Flow { - return realmDatabase().queryFirst( - query = "scheduleId == $0", - queryArgs = arrayOf(scheduleId) - ) + fun get(scheduleId: String): Flow { + return appDatabase.dataPointDao().getByScheduleId(scheduleId).conflate() } fun delete(scheduleId: String) { - realm()?.writeBlocking { - val existingObject: DataPointCountSchema? = this.query( - query = "scheduleId == $0", scheduleId - ).first().find() - existingObject?.let { - this.delete(it) - } + StudyScope.launch { + appDatabase.dataPointDao().deleteByScheduleId(scheduleId) } } } \ 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 index bfc8b8628..0feea507d 100644 --- 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 @@ -11,20 +11,21 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity import io.redlink.more.more_app_mutliplatform.util.Scope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -class NotificationRepository : Repository() { +class NotificationRepository(private val appDatabase: AppDatabase) { private val readNotificationIds = mutableSetOf() private val completedNotificationIds = mutableSetOf() private val deletedNotificationIds = mutableSetOf() private val mutex = Mutex() + fun storeNotification( key: String, channelId: String?, @@ -37,11 +38,11 @@ class NotificationRepository : Repository() { userFacing: Boolean = true, additionalData: Map? = null ) { - Scope.launch { + Scope.launch(Dispatchers.IO) { mutex.withLock { if (key !in deletedNotificationIds) { storeNotification( - NotificationSchema.toSchema( + NotificationEntity.toEntity( key, channelId, title, @@ -61,85 +62,68 @@ class NotificationRepository : Repository() { } } - 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 + suspend fun storeNotification(notification: NotificationEntity) { + mutex.withLock { + Napier.i { "Delete Notification: $deletedNotificationIds. Notification to store: $notification" } + if (notification.notificationId !in deletedNotificationIds) { + val updatedNotification = notification.copy( + read = if (notification.notificationId in readNotificationIds) { readNotificationIds.remove(notification.notificationId) - } - if (notification.notificationId in completedNotificationIds) { - notification.completed = true + true + } else notification.read, + completed = if (notification.notificationId in completedNotificationIds) { completedNotificationIds.remove(notification.notificationId) - } - realmDatabase().store(setOf(notification), UpdatePolicy.ERROR) - } else { - deletedNotificationIds.remove(notification.notificationId) - } + true + } else notification.completed + ) + appDatabase.notificationDao().insert(updatedNotification) + } 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 - completed = it.notificationId in completedNotificationIds - } - }, UpdatePolicy.ERROR) - notificationsToDelete.forEach { deletedNotificationIds.remove(it.notificationId) } + suspend fun storeNotifications(notifications: List) { + mutex.withLock { + val (notificationsToStore, notificationsToDelete) = notifications.partition { it.notificationId !in deletedNotificationIds } + Napier.i { "Delete Notification: $deletedNotificationIds. Storing notifications: $notificationsToStore. Notifications to delete: $notificationsToDelete" } + + val updatedNotifications = notificationsToStore.map { notification -> + notification.copy( + read = notification.notificationId in readNotificationIds, + completed = notification.notificationId in completedNotificationIds + ) } + appDatabase.notificationDao().insertAll(updatedNotifications) + notificationsToDelete.forEach { deletedNotificationIds.remove(it.notificationId) } } } - override fun count(): Flow = realmDatabase().count() + fun getCount(): Flow = appDatabase.notificationDao().getCount() - fun getAllNotifications() = realmDatabase().query() + fun getAllNotifications() = appDatabase.notificationDao().getAllFlow() - fun getAllUserFacingNotifications() = - realmDatabase().query("userFacing == true") + fun getAllUserFacingNotifications() = appDatabase.notificationDao().getByUserFacingFlow(true) - fun getUnreadUserNotifications() = - realmDatabase().query("userFacing == true AND read == false") + fun getUnreadUserNotifications() = appDatabase.notificationDao().getUnreadUserFacingFlow() - 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 - } - } - } + suspend fun update(notificationId: String, read: Boolean? = null, priority: Long? = null) { + 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) } } } - fun downloadMissedNotifications(networkService: NetworkService) { - Scope.launch { - storeNotifications(NotificationSchema.toSchemaList(networkService.downloadMissedNotifications())) - } - } + fun allUserFacingNotifications() = appDatabase.notificationDao().getByUserFacingFlow(true) - fun allUserFacingNotifications() { - realmDatabase().query("userFacing == true") - } - - fun countUserFacingNotifications() { - realm()?.query("userFacing == true")?.count() - } + fun countUserFacingNotifications(): Flow = + appDatabase.notificationDao().getCountByUserFacing(true) fun setNotificationReadStatus(key: String, read: Boolean = true) { if (read) { @@ -148,16 +132,10 @@ class NotificationRepository : Repository() { readNotificationIds.remove(key) } - Scope.launch { + Scope.launch(Dispatchers.IO) { mutex.withLock { - realm()?.write { - val notification = - this.query("notificationId == $0", key).first().find() - notification?.let { - it.read = read - readNotificationIds.remove(key) - } - } + appDatabase.notificationDao().updateReadStatus(key, read) + readNotificationIds.remove(key) } } } @@ -171,41 +149,30 @@ class NotificationRepository : Repository() { completedNotificationIds.remove(key) } - Scope.launch { + Scope.launch(Dispatchers.IO) { mutex.withLock { - realm()?.write { - val notification = - this.query("notificationId == $0", key).first().find() - notification?.let { - it.completed = completed - it.read = true - completedNotificationIds.remove(key) - } + appDatabase.notificationDao().updateCompletedStatus(key, completed) + if (completed) { + appDatabase.notificationDao().updateReadStatus(key, true) } + completedNotificationIds.remove(key) } } } fun deleteNotification(notificationId: String) { - Scope.launch { + Scope.launch(Dispatchers.IO) { 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" } - } - } + appDatabase.notificationDao().deleteById(notificationId) + deletedNotificationIds.remove(notificationId) + Napier.i { "Deleted Notification: $deletedNotificationIds" } } } } - fun deleteAll() { - realmDatabase().deleteAll() + suspend fun deleteAll() { + appDatabase.notificationDao().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 index 017e2b611..55737b071 100644 --- 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 @@ -11,34 +11,30 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity 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() +class ObservationDataRepository(private val appDatabase: AppDatabase) { + private var queue = mutableSetOf() private val mutex = Mutex() - init { - Scope.repeatedLaunch(10000L) { + Scope.repeatedLaunch(10000L, Dispatchers.IO) { if (queue.isNotEmpty()) { store() } } } - fun addData(dataList: List) { + fun addData(dataList: List) { StudyScope.launch { mutex.withLock { queue.addAll(dataList) @@ -46,49 +42,36 @@ class ObservationDataRepository : Repository() { } } - fun store() { + suspend fun store() { if (queue.isNotEmpty()) { - StudyScope.launch { - val queueCopy = mutex.withLock { - val queueCopy = queue.toSet() - queue.clear() - queueCopy - } - realmDatabase().store(queueCopy, UpdatePolicy.ERROR) + val queueCopy = mutex.withLock { + val queueCopy = queue.toSet() + queue.clear() + queueCopy } + appDatabase.observationDataDao().insertAll(queueCopy.toList()) } } - override fun count() = realmDatabase().count() + suspend fun getCount(): Int = appDatabase.observationDataDao().getCount() 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) } + val observationDataEntities = appDatabase.observationDataDao().getLatest(5000) + if (observationDataEntities.isNotEmpty()) { + observationDataEntities.mapAsBulkData() + } else { + null + } } } - fun deleteAllWithId(idSet: Set) { + suspend 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) } - } - + mutex.withLock { + idSet.forEach { dataId -> + appDatabase.observationDataDao().deleteById(dataId) } } } - - 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 index bd1ce9aa7..7d54eb4f4 100644 --- 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 @@ -11,81 +11,78 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity 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 +import kotlinx.datetime.Clock -class ObservationRepository : Repository() { - override fun count(): Flow = realmDatabase().count() +class ObservationRepository(private val appDatabase: AppDatabase) { - fun observations() = realmDatabase().query() + suspend fun getCount(): Int = appDatabase.observationDao().getCount() - fun observationWithUndoneSchedules(): Flow>> { - return ScheduleRepository().allSchedulesWithStatus() - .combine(observations()) { schedules, observations -> - observations.associateWith { observation -> schedules.filter { it.observationId == observation.observationId } } + fun observations() = appDatabase.observationDao().getAllFlow() + + fun observationWithUndoneSchedules(): Flow>> { + return appDatabase.scheduleDao().getByDoneFlow(false) + .combine(observations()) { schedules: List, observations: List -> + observations.associateWith { observation -> + schedules.filter { schedule -> schedule.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) - } - } + 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) } } - fun lastCollection(type: Set, timestamp: Long) { - realm()?.writeBlocking { - this.query("observationType IN $0", type).find() - .forEach { it.collectionTimestamp = RealmInstant.from(timestamp, 0) } + 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) + } } } - fun collectionTimestamp(type: String) = - realmDatabase().query("observationType == $0", queryArgs = arrayOf(type)) - .transform { - emit(it - .map { observationSchema -> observationSchema.collectionTimestamp } - .firstOrNull() - ) + fun collectionTimestamp(type: String): Flow = + appDatabase.observationDao().getByObservationTypeFlow(type) + .transform { observationList -> + emit(observationList.firstOrNull()?.collectionTimestamp) } - 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 collectAllTimestamps(): Flow> = + observations().transform { observationList -> + emit(observationList.associate { it.observationType to it.collectionTimestamp }) + } + + 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) } - fun collectTimestampOfType(type: String, newState: (RealmInstant?) -> Unit): Closeable { + fun collectTimestampOfType(type: String, newState: (Long?) -> Unit): Closeable { return collectionTimestamp(type).asClosure(newState) } fun collectAllTimestamps(newState: (Map) -> Unit): Closeable { - return collectAllTimestamps().transform { emit(it.mapValues { it.value.epochSeconds }) } - .asClosure(newState) + return collectAllTimestamps().asClosure(newState) } - fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable { + fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable { return observationWithUndoneSchedules().asClosure(newState) } @@ -93,15 +90,10 @@ class ObservationRepository : Repository() { emit(observationList.map { it.observationType }.toSet()) } - fun observationById(observationId: String) = realmDatabase().queryFirst( - "observationId == $0", - queryArgs = arrayOf(observationId) - ) + fun observationById(observationId: String) = + appDatabase.observationDao().getByObservationIdFlow(observationId) - suspend fun getObservationByObservationId(observationId: String): ObservationSchema? { - return realmDatabase().queryFirst( - "observationId == $0", - queryArgs = arrayOf(observationId) - ).firstOrNull() + suspend fun getObservationByObservationId(observationId: String): ObservationEntity? { + return appDatabase.observationDao().getByObservationId(observationId) } } \ 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 index 18c4c9823..9a995d90c 100644 --- 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 @@ -12,123 +12,104 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity 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.Dispatchers +import kotlinx.coroutines.IO 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 +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime -class ScheduleRepository : Repository() { +class ScheduleRepository(private val appDatabase: AppDatabase) { - override fun count(): Flow = realmDatabase().count() + fun count() = appDatabase.scheduleDao().countAsFlow() - fun allSchedulesWithStatus(done: Boolean = false): Flow> { - return realm()?.query("done = $0", done)?.asMappedFlow() ?: emptyFlow() + fun allSchedulesWithStatus(done: Boolean = false): Flow> { + return appDatabase.scheduleDao().getByDoneFlow(done) } - 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()) + fun allScheduleWithRunningState(scheduleState: ScheduleState = ScheduleState.RUNNING): Flow> = + appDatabase.scheduleDao().getByStateFlow(scheduleState.name) + + fun firstScheduleAvailableForObservationId(observationId: String): Flow { + return appDatabase.scheduleDao().getByObservationIdFlow(observationId) + .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?.epochSeconds ?: 0) > now - }.sortedBy { it.start?.epochSeconds }.firstOrNull() + && it.end > now + }.sortedBy { it.start }.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 -> + 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.start?.toLocalDate() == today || it.end?.toLocalDate() == today) + && (it.startInstant() + ?.toLocalDateTime(TimeZone.currentSystemDefault())?.date == today + || it.endInstant() + ?.toLocalDateTime(TimeZone.currentSystemDefault())?.date == today) }) - } ?: flow { emit(emptyList()) } + } } fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = - firstScheduleAvailableForObservationId(observationId).transform { it?.scheduleId?.toHexString() } + firstScheduleAvailableForObservationId(observationId).transform { it?.scheduleId } fun collectRunningState( forState: ScheduleState, - provideNewState: (List) -> Unit + provideNewState: (List) -> Unit ): Closeable { return allScheduleWithRunningState(forState).asClosure(provideNewState) } - fun getFirstAndLastDate(observationId: String): Flow> { - return realmDatabase().query( - query = "observationId = $0", - queryArgs = arrayOf(observationId) - ).transform { + 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)) } } - fun setRunningStateFor(id: String, scheduleState: ScheduleState) { - realm()?.writeBlocking { - this.query("scheduleId = $0", ObjectId(id)).first().find()?.let { - it.state = scheduleState.name - } - } + suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) { + appDatabase.scheduleDao().updateState(id, 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) - } + 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) } 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 } + return allSchedulesWithStatus().transform { schedules -> + val now = Clock.System.now().epochSeconds + val startTimes = schedules.mapNotNull { it.start }.filter { it > now }.toSet() + val endTimes = schedules.mapNotNull { it.end }.filter { it > now }.toSet() + val nextStart = startTimes.minOfOrNull { it } + val nextEnd = endTimes.minOfOrNull { it } + if (nextStart != null && nextEnd != null) { if (nextStart < nextEnd) emit(nextStart) else emit(nextEnd) return@transform @@ -137,44 +118,38 @@ class ScheduleRepository : Repository() { } } - suspend fun getPreviousSchedule(observationId: String, currScheduleId: ObjectId): ScheduleSchema? { - val scheduleList = queryAllSchedulesForObservationId(observationId) - var previousSchedule: ScheduleSchema? = null - - scheduleList.collect { list -> - val index = list.indexOfFirst { it.scheduleId == currScheduleId } + suspend fun getPreviousSchedule( + observationId: String, + currScheduleId: String + ): ScheduleEntity? { + val schedules = appDatabase.scheduleDao().getByObservationId(observationId) + val currentIndex = schedules.indexOfFirst { it.scheduleId == currScheduleId } - if (index > 0) { - previousSchedule = list[index - 1] - } - } - - return previousSchedule + return if (currentIndex > 0) schedules[currentIndex - 1] else null } - fun queryAllSchedulesForObservationId(observationId: String): Flow> { - return realm()?.query("observationId = $0", observationId)?.asMappedFlow() - ?.transform { emit(it.sortedBy { it.start }) } - ?: emptyFlow() + fun queryAllSchedulesForObservationId(observationId: String): Flow> { + return appDatabase.scheduleDao().getByObservationIdFlow(observationId) } 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 }) + return allSchedulesWithStatus().transform { schedules -> + val now = Clock.System.now().epochSeconds + val nextStart = schedules.mapNotNull { it.start } + .filter { it > now } + .minOfOrNull { it } + emit(nextStart) } } fun collectNextScheduleStart(provideNewState: (Long?) -> Unit) = nextScheduleStart().asClosure(provideNewState) - fun scheduleWithId(id: String) = realmDatabase().queryFirst( - query = "scheduleId = $0", - queryArgs = arrayOf(ObjectId(id)) - ) + fun scheduleWithId(id: String): Flow { + return appDatabase.scheduleDao().getById(id) + } fun updateTaskStates(observationFactory: ObservationFactory, dataRecorder: DataRecorder) { StudyScope.launch { @@ -188,31 +163,48 @@ class ScheduleRepository : Repository() { ) { 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 - } + + try { + val schedules = appDatabase.scheduleDao().getByDone(false) + + val stateUpdates = mutableListOf>() + val activeIds = 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 + || (autoStartingObservations.isNotEmpty() + && scheduleEntity.hidden + && newState.active() + && scheduleEntity.observationType in autoStartingObservations + && observationFactory.observation(scheduleEntity.observationType) + ?.bleDevicesNeeded() + ?.let { needed -> + BluetoothDeviceManager.connectedDevices.value.map { it.deviceName } + .containsAll(needed) + } != false) + ) { + activeIds.add(scheduleEntity.scheduleId) } } - }?.toSet() ?: emptySet() - if (activeIds.isNotEmpty()) { - dataRecorder.startMultiple(activeIds) + + StudyScope.launch(Dispatchers.IO) { + stateUpdates.forEach { (scheduleId, newState) -> + appDatabase.scheduleDao().updateState(scheduleId, newState.name) + } + } + + if (activeIds.isNotEmpty()) { + dataRecorder.startMultiple(activeIds) + } + } catch (e: Exception) { + Napier.e("Error updating schedule states", e) } } @@ -223,29 +215,36 @@ class ScheduleRepository : Repository() { 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 - } + + try { + val schedules = appDatabase.scheduleDao().getByDone(false) + + val activeIds = schedules.filter { scheduleEntity -> + observationFactory.observation(scheduleEntity.observationType) + ?.bleDevicesNeeded() + ?.isNotEmpty() == true && scheduleEntity.observationType in autoStartingObservations + }.mapNotNull { scheduleEntity -> + val newState = scheduleEntity.updateState() + + if (newState.active() && scheduleEntity.hidden && observationFactory.observation( + scheduleEntity.observationType + )?.bleDevicesNeeded() + ?.let { needed -> + BluetoothDeviceManager.connectedDevices.value.map { it.deviceName } + .containsAll(needed) + } != false + ) { + scheduleEntity.scheduleId + } else { + null } + }.toSet() + + if (activeIds.isNotEmpty()) { + dataRecorder.startMultiple(activeIds) } - }?.toSet() ?: emptySet() - if (activeIds.isNotEmpty()) { - dataRecorder.startMultiple(activeIds) + } catch (e: Exception) { + Napier.e("Error updating schedule states with BLE devices", e) } } } 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 index 333a321eb..8ef2115da 100644 --- 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 @@ -10,35 +10,55 @@ */ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity +import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study +import io.redlink.more.more_app_mutliplatform.util.StudyScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform + +class StudyRepository(private val appDatabase: AppDatabase) { -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 - ) + StudyScope.launch(Dispatchers.IO) { + val studyEntity = StudyEntity.fromStudy(study) + appDatabase.studyDao().insert(studyEntity) + } + + StudyScope.launch(Dispatchers.IO) { + val observationEntities = study.observations.map { ObservationEntity.toEntity(it) } + appDatabase.observationDao().insertAll(observationEntities) + } + + StudyScope.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 + ) + } } - }.flatten()) - realmDatabase().store(realmObjects) + appDatabase.scheduleDao().insertAll(scheduleEntities) + } } - fun getStudy(): Flow { - return realmDatabase().queryFirst() + fun getStudy(): Flow { + return appDatabase.studyDao().getAllFlow().transform { emit(it.firstOrNull()) } } - override fun count(): Flow = realmDatabase().count() + fun getStudyById(studyId: String): Flow { + return appDatabase.studyDao().getByIdFlow(studyId) + } + + fun getStudyCount(): Flow { + return appDatabase.studyDao().getCount() + } } \ 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 03fa036f5..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/schemas/NotificationSchema.kt +++ /dev/null @@ -1,118 +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 completed: 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, - completed: 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.completed = completed - 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, - completed = 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/CollectionExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CollectionExtension.kt index df2fc04c7..a6783db4a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CollectionExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CollectionExtension.kt @@ -11,12 +11,12 @@ package io.redlink.more.more_app_mutliplatform.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.database.entities.BluetoothDeviceEntity +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/extensions/CoroutineExtension.kt index 4e7269277..82a20f3a0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt @@ -11,13 +11,19 @@ package io.redlink.more.more_app_mutliplatform.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, + coroutineContext: CoroutineContext = Dispatchers.Default, + action: suspend CoroutineScope.() -> Unit +): Job { + return launch(coroutineContext) { 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/more_app_mutliplatform/extensions/DateTimeConverter.kt index 91a336a1d..6493ef618 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/DateTimeConverter.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/DateTimeConverter.kt @@ -10,7 +10,6 @@ */ package io.redlink.more.more_app_mutliplatform.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/MapExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/MapExtension.kt deleted file mode 100644 index 4bc664caa..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/MapExtension.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.more_app_mutliplatform.extensions - -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema - -fun Map>.equalsTo(other: Map>) { - if (keys == other.keys) { - - } -} \ 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/FilterModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/FilterModel.kt index ac56a62c7..0b2ace1cc 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/FilterModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/FilterModel.kt @@ -11,7 +11,7 @@ package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/models/NotificationModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationModel.kt index 2e0b41d46..f915a72d1 100644 --- 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 @@ -10,8 +10,7 @@ */ 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 +import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity data class NotificationModel( var notificationId: String, @@ -28,7 +27,7 @@ data class NotificationModel( ) { companion object { - fun createModelsFrom(notifications: List): List { + fun createModelsFrom(notifications: List): List { return notifications.mapNotNull { it?.let { val channelId = it.channelId @@ -41,13 +40,13 @@ data class NotificationModel( channelId = channelId, title = title, notificationBody = notificationBody, - timestamp = timestamp.toInstant().toEpochMilliseconds(), + timestamp = timestamp, priority = it.priority, read = it.read, completed = it.completed, userFacing = it.userFacing, deepLink = it.deepLink(), - notificationData = notificationData + notificationData = it.getNotificationDataMap() ) } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ObservationDetailsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ObservationDetailsModel.kt index d0a0abbf0..764fe6321 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ObservationDetailsModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ObservationDetailsModel.kt @@ -10,9 +10,8 @@ */ 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.ScheduleSchema -import io.redlink.more.more_app_mutliplatform.extensions.toInstant +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.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/PermissionModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionModel.kt index aae6e0228..90f05086c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/PermissionModel.kt @@ -10,11 +10,10 @@ */ 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.database.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study - data class PermissionModel( val studyTitle: String, val studyParticipantInfo: 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/ScheduleModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt index 8a891e5a2..bae884bd2 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt @@ -10,7 +10,7 @@ */ package io.redlink.more.more_app_mutliplatform.models -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity data class ScheduleModel( val scheduleId: String, @@ -33,19 +33,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/SimpleQuestionModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SimpleQuestionModel.kt index a7eb21870..a8d40e6b8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SimpleQuestionModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SimpleQuestionModel.kt @@ -10,7 +10,7 @@ */ 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.entities.ObservationEntity import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -25,7 +25,10 @@ class SimpleQuestionModel( var scheduleId: String = "" ) { companion object { - fun createModelFrom(observationSchema: ObservationSchema, scheduleId: String): SimpleQuestionModel { + fun createModelFrom( + observationSchema: ObservationEntity, + scheduleId: String + ): SimpleQuestionModel { val config: Map = observationSchema.configuration?.let { config -> Json.decodeFromString(config).toMap() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyDetailsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyDetailsModel.kt index fe5befc90..c9d3b9180 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyDetailsModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyDetailsModel.kt @@ -10,20 +10,24 @@ */ package io.redlink.more.more_app_mutliplatform.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.more_app_mutliplatform.database.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/models/StudyState.kt index cc54a86a4..ca55b4539 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt @@ -17,6 +17,6 @@ enum class StudyState(val descr: String) { CLOSED("closed"); 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/TaskDetailsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskDetailsModel.kt index 9d45513a5..3ee92451c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskDetailsModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/TaskDetailsModel.kt @@ -10,8 +10,8 @@ */ 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.ScheduleSchema +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.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/navigation/DeeplinkManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/navigation/DeeplinkManager.kt index 4416d768b..356c10ee8 100644 --- 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 @@ -1,8 +1,9 @@ package io.redlink.more.more_app_mutliplatform.navigation +import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity 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 @@ -13,10 +14,10 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.datetime.Clock -class DeeplinkManager(private val observationFactory: ObservationFactory) { +class DeeplinkManager(database: AppDatabase, val observationFactory: ObservationFactory) { private val deepLinks = mutableSetOf() - private val scheduleRepository = ScheduleRepository() - private val observationRepository = ObservationRepository() + private val scheduleRepository = ScheduleRepository(database) + private val observationRepository = ObservationRepository(database) fun addAvailableDeepLinks(deepLinks: Set) { this.deepLinks.addAll(deepLinks) @@ -49,7 +50,7 @@ class DeeplinkManager(private val observationFactory: ObservationFactory) { private fun deepLinkModifier( deepLink: String, - schedule: ScheduleSchema?, + schedule: ScheduleEntity?, protocolReplacement: String?, hostReplacement: String? ): String { @@ -73,11 +74,11 @@ class DeeplinkManager(private val observationFactory: ObservationFactory) { } } - private fun selectRoute(deepLink: String, schedule: ScheduleSchema?): String { + private fun selectRoute(deepLink: String, schedule: ScheduleEntity?): String { val now = Clock.System.now() return schedule?.let { scheduleSchema -> - if ((scheduleSchema.start?.epochSeconds ?: 0) <= now.epochSeconds) { + if ((scheduleSchema.start ?: 0) <= now.epochSeconds) { routeForObservation(deepLink) } else { TASK_DETAILS @@ -88,7 +89,7 @@ class DeeplinkManager(private val observationFactory: ObservationFactory) { private fun replaceRoute( deepLink: String, routeToReplace: String, - schedule: ScheduleSchema? = null, + schedule: ScheduleEntity? = null, protocolReplacement: String? = null, hostReplacement: String? = null ): String { @@ -109,7 +110,7 @@ class DeeplinkManager(private val observationFactory: ObservationFactory) { schedule?.let { val scheduleIdKeySet = paramsMap.getOrElse("scheduleId") { mutableSetOf() }.toMutableSet() - scheduleIdKeySet.add(it.scheduleId.toHexString()) + scheduleIdKeySet.add(it.scheduleId) paramsMap["scheduleId"] = scheduleIdKeySet } 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 index fd225715c..482ab2970 100644 --- 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 @@ -11,10 +11,11 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity 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 @@ -32,11 +33,11 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.datetime.Instant -abstract class Observation(val observationType: ObservationType) { - private val scheduleRepository = ScheduleRepository() +abstract class Observation(database: AppDatabase, val observationType: ObservationType) { + private val scheduleRepository = ScheduleRepository(database) private var dataManager: ObservationDataManager? = null private var notificationManager: NotificationManager? = null - private val observationRepository = ObservationRepository() + private val observationRepository = ObservationRepository(database) private var running = false private val observationIds = mutableSetOf() @@ -62,7 +63,7 @@ abstract class Observation(val observationType: ObservationType) { timestampCollectionJob?.cancel() timestampCollectionJob = StudyScope.launch { observationRepository.collectTimestampForObservationIds(observationIds).collect { - lastCollectionTimestamp = Instant.fromEpochSeconds(it.epochSeconds) + lastCollectionTimestamp = Instant.fromEpochMilliseconds(it) Napier.d(tag = "Observation::start") { "Last collection $lastCollectionTimestamp" } } }.second @@ -119,7 +120,7 @@ abstract class Observation(val observationType: ObservationType) { fun observationConfig(settings: Map) { this.lastCollectionTimestamp = (settings[CONFIG_LAST_COLLECTION_TIMESTAMP] as? Long)?.let { - Instant.fromEpochSeconds(it, 0) + Instant.fromEpochMilliseconds(it) } ?: Clock.System.now() if (settings.isNotEmpty()) { Napier.i(tag = "Observation::observationConfig") { "Applying new observation settings for ${observationType.observationType}: $settings" } @@ -134,10 +135,12 @@ abstract class Observation(val observationType: ObservationType) { protected fun collectionTimestampToNow() { Napier.d(tag = "Observation::collectionTimeStampToNow") { "Collecting timestamp" } lastCollectionTimestamp = Clock.System.now() - observationRepository.lastCollection( - observationIds.toSet(), - lastCollectionTimestamp.epochSeconds - ) + StudyScope.launch(Dispatchers.IO) { + observationRepository.updateLastCollection( + observationIds.toSet(), + lastCollectionTimestamp.toEpochMilliseconds() + ) + } } protected abstract fun start(): Boolean @@ -177,7 +180,7 @@ abstract class Observation(val observationType: ObservationType) { open fun ableToAutomaticallyStart() = true fun storeData(data: Any, timestamp: Long = -1, onCompletion: () -> Unit = {}) { - val dataSchemas = ObservationDataSchema.fromData( + val dataSchemas = ObservationDataEntity.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!" } @@ -186,7 +189,7 @@ abstract class Observation(val observationType: ObservationType) { } fun storeData(data: List, onCompletion: () -> Unit) { - val dataSchemas = ObservationDataSchema.fromData(observationIds.toSet(), data) + val dataSchemas = ObservationDataEntity.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) @@ -208,7 +211,11 @@ abstract class Observation(val observationType: ObservationType) { stop { timestampCollectionJob?.cancel() saveAndSend() - scheduleIds.keys.forEach { scheduleRepository.setRunningStateFor(it, state) } + scheduleIds.keys.forEach { + StudyScope.launch(Dispatchers.IO) { + scheduleRepository.setRunningStateFor(it, state) + } + } scheduleId?.let { observationShutdown(it) } @@ -221,7 +228,11 @@ abstract class Observation(val observationType: ObservationType) { stop { timestampCollectionJob?.cancel() saveAndSend() - scheduleIds.keys.forEach { scheduleRepository.setCompletionStateFor(it, true) } + scheduleIds.keys.forEach { + StudyScope.launch(Dispatchers.IO) { + scheduleRepository.setCompletionStateFor(it, true) + } + } observationShutdown(scheduleId) removeDataCount() handleNotification(scheduleId) @@ -252,7 +263,7 @@ abstract class Observation(val observationType: ObservationType) { } protected fun showNotification(title: String, notificationBody: String) { - val notification = NotificationSchema.build(title, notificationBody) + val notification = NotificationEntity.build(title, notificationBody) Napier.d(tag = "Observation::showNotification") { "Showing notification: $notification" } notificationManager?.storeAndDisplayNotification(notification, true) } @@ -271,8 +282,10 @@ abstract class Observation(val observationType: ObservationType) { StudyScope.launch { val scheduleSchemas = combinedFlow.first() val title = - if (scheduleSchemas.isNotEmpty()) scheduleSchemas.map { it.observationTitle } - .joinToString(", ", limit = 5) else fallbackTitle + if (scheduleSchemas.isNotEmpty()) scheduleSchemas.joinToString( + ", ", + limit = 5 + ) { it.observationTitle } else fallbackTitle withContext(Dispatchers.Main) { showNotification(title, notificationBody) } 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 index 20f9d17c1..3421747f4 100644 --- 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 @@ -12,18 +12,19 @@ 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.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity 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 io.redlink.more.more_app_mutliplatform.util.StudyScope 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() +abstract class ObservationDataManager(database: AppDatabase) { + private val observationDataRepository = ObservationDataRepository(database) + private val dataPointCountRepository = DataPointCountRepository(database) private var countJob: Job? = null private var scheduleCount = mutableMapOf() @@ -35,7 +36,7 @@ abstract class ObservationDataManager { Napier.i(tag = "ObservationDataManager::init") { "ObservationDataManager init!" } } - fun add(dataList: List, scheduleIdList: Set) { + 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) @@ -45,12 +46,16 @@ abstract class ObservationDataManager { fun saveAndSend() { Napier.i(tag = "ObservationDataManager::saveAndSend") { "Saving and sending observations" } - observationDataRepository.store() + StudyScope.launch(Dispatchers.IO) { + observationDataRepository.store() + } } fun store() { Napier.i(tag = "ObservationDataManager::store") { "Storing observations" } - observationDataRepository.store() + StudyScope.launch(Dispatchers.IO) { + observationDataRepository.store() + } } fun removeDataPointCount(scheduleId: String) { @@ -63,21 +68,20 @@ abstract class ObservationDataManager { fun listenToDatapointCountChanges() { if (countJob == null) { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Starting to listen for changes in datapoint counts" } - countJob = Scope.repeatedLaunch(10000) { + countJob = Scope.repeatedLaunch(60000, Dispatchers.IO) { 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 - } + val count = observationDataRepository.getCount() + if (count > 0) { + Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Observation data count: $count! Sending data..." } + uploadJob = Scope.launch(Dispatchers.IO) { + sendData() + }.second + uploadJob?.invokeOnCompletion { + uploadJob = null } } } else { - Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "No conncetion" } + Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "No connection" } uploadJob?.cancel() } }.second @@ -94,6 +98,8 @@ abstract class ObservationDataManager { } private fun deleteAll(idSet: Set) { - observationDataRepository.deleteAllWithId(idSet) + Scope.launch(Dispatchers.IO) { + 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 index d0e74fbfc..c76b6ca5e 100644 --- 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 @@ -11,6 +11,7 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -30,8 +31,10 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update - -abstract class ObservationFactory(private val dataManager: ObservationDataManager) { +abstract class ObservationFactory( + database: AppDatabase, + private val dataManager: ObservationDataManager +) { private var credentialRepository: CredentialRepository? = null val observations = mutableSetOf() @@ -44,10 +47,10 @@ abstract class ObservationFactory(private val dataManager: ObservationDataManage private var observationErrorWatcher: Job? = null init { - observations.add(SimpleQuestionObservation()) - observations.add(LimeSurveyObservation()) + observations.add(SimpleQuestionObservation(database)) + observations.add(LimeSurveyObservation(database)) Scope.launch(Dispatchers.IO) { - ObservationRepository().observationTypes().collect { + ObservationRepository(database).observationTypes().collect { Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } _studyObservationTypes.clear() _studyObservationTypes.appendAll(it) @@ -117,7 +120,7 @@ abstract class ObservationFactory(private val dataManager: ObservationDataManage values.toMap() } observationErrorWatcher?.cancel() - observationErrorWatcher = Scope.launch { + observationErrorWatcher = Scope.launch(Dispatchers.IO) { Napier.d(tag = "ObservationFactory::listenToObservationErrors") { "Listening for observation errors" } combinedFlow.cancellable().collect { _observationErrors.set(it) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt index 152dd99e5..27bef82b7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt @@ -11,14 +11,15 @@ package io.redlink.more.more_app_mutliplatform.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.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity 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.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull @@ -26,27 +27,28 @@ import kotlinx.datetime.Clock import kotlin.math.ceil class ObservationManager( + database: AppDatabase, private val observationFactory: ObservationFactory, private val dataRecorder: DataRecorder ) { - private val scheduleRepository = ScheduleRepository() - private val dataPointCountRepository = DataPointCountRepository() - private val observationRepository = ObservationRepository() + private val scheduleRepository = ScheduleRepository(database) + private val dataPointCountRepository = DataPointCountRepository(database) + private val observationRepository = ObservationRepository(database) private val runningObservations = mutableMapOf() - private val scheduleSchemaList = mutableSetOf() + private val scheduleSchemaList = mutableSetOf() private val currentlyRunning = mutableSetOf() - private var upToDateTimestamps: Map = emptyMap() + private var upToDateTimestamps: Map = emptyMap() fun activateScheduleUpdate() { Napier.i(tag = "ObservationManager::activateScheduleUpdate") { "ObservationManager: ScheduleUpdater activating..." } - StudyScope.launch { + StudyScope.launch(Dispatchers.IO) { scheduleRepository.allSchedulesWithStatus(true).cancellable().collect { list -> if (runningObservations.isNotEmpty()) { - list.filter { it.scheduleId.toHexString() in runningObservations.keys } - .map { it.scheduleId.toHexString() }.forEach { + list.filter { it.scheduleId in runningObservations.keys } + .map { it.scheduleId }.forEach { stop(it) dataPointCountRepository.delete(it) } @@ -56,11 +58,11 @@ class ObservationManager( val firstCall = ceil(Clock.System.now().toEpochMilliseconds() / 60_000.0).toLong() * 60_000 StudyScope.launch { delay(firstCall - Clock.System.now().toEpochMilliseconds()) - StudyScope.repeatedLaunch(30000L) { + StudyScope.repeatedLaunch(30000L, Dispatchers.IO) { updateTaskStates() } } - StudyScope.launch { + StudyScope.launch(Dispatchers.IO) { observationRepository.collectAllTimestamps().cancellable().collect { upToDateTimestamps = it } @@ -71,9 +73,9 @@ class ObservationManager( 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()) + list.filter { it.scheduleId !in runningObservations.keys }.forEach { + if (start(it.scheduleId)) { + startedObservations.add(it.scheduleId) } } } @@ -92,23 +94,23 @@ class ObservationManager( 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 @@ -123,13 +125,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,7 +145,7 @@ 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) setObservationState(it, ScheduleState.PAUSED) @@ -166,16 +168,16 @@ class ObservationManager( } fun startObservationType(type: String) { - StudyScope.launch { + StudyScope.launch(Dispatchers.IO) { Napier.d(tag = "ObservationManager::startObservationType") { "Restarting Observations with type: $type" } scheduleRepository.allSchedulesWithStatus(false) .firstOrNull() ?.filter { it.observationType == type && it.getState().active() } ?.forEach { - if (start(it.scheduleId.toHexString())) { + if (start(it.scheduleId)) { Napier.i(tag = "ObservationManager::startObservationType") { "Started Schedule: $it" } } else { - currentlyRunning.remove(it.scheduleId.toHexString()) + currentlyRunning.remove(it.scheduleId) Napier.i(tag = "ObservationManager::startObservationType") { "Failed to start schedule: $it" } } } @@ -184,7 +186,7 @@ class ObservationManager( 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.removeDataCount() @@ -222,16 +224,18 @@ class ObservationManager( val runningObs = runningObservations.toList() runningObs.forEach { (scheduleId, observation) -> observation.stopAndFinish(scheduleId) - scheduleRepository.setCompletionStateFor(scheduleId, true) + StudyScope.launch(Dispatchers.IO) { + scheduleRepository.setCompletionStateFor(scheduleId, true) + } dataPointCountRepository.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() if (!hasRunningTasks()) { onCompletion(true) @@ -239,7 +243,7 @@ class ObservationManager( var counter = 0 runningObservations.values.forEach { upToDateTimestamps[it.observationType.observationType]?.let { lastTimestamp -> - it.store(lastTimestamp.epochSeconds, Clock.System.now().epochSeconds) { + it.store(lastTimestamp, Clock.System.now().epochSeconds) { if (++counter == runningObservations.size) { onCompletion(true) } @@ -253,36 +257,38 @@ class ObservationManager( } } - private suspend fun findOrCreateObservation(scheduleId: String): ScheduleSchema? { + private suspend fun findOrCreateObservation(scheduleId: String): ScheduleEntity? { return scheduleRepository.scheduleWithId(scheduleId).firstOrNull()?.let { - val fixedScheduleSchema = it.copyFromRealm() + val fixedScheduleSchema = it Napier.d(tag = "ObservationManager::findOrCreateObservation") { "Found Schema $it" } if (findOrCreateObservation(fixedScheduleSchema)) fixedScheduleSchema 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) { + scheduleRepository.setRunningStateFor(scheduleId, state) + } else { + scheduleRepository.setCompletionStateFor(scheduleId, true) + dataPointCountRepository.delete(scheduleId) + } } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManagerModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManagerModel.kt deleted file mode 100644 index 151a23194..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManagerModel.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.observations - -import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema - -data class ObservationManagerModel( - val schedule: ScheduleSchema, - val config: Map, -) 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/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt index c2f709b94..2fefe78ac 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt @@ -14,12 +14,14 @@ 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.database.AppDatabase 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 kotlinx.coroutines.flow.MutableStateFlow -class LimeSurveyObservation : Observation(observationType = LimeSurveyType()) { +class LimeSurveyObservation(database: AppDatabase) : + Observation(database, observationType = LimeSurveyType()) { val limeURL = MutableStateFlow(null) override fun start(): Boolean { 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 index 8048165c6..8bbebee6c 100644 --- 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 @@ -10,10 +10,10 @@ */ package io.redlink.more.more_app_mutliplatform.observations.observationTypes -import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema +import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity open class ObservationType(val observationType: String, val sensorPermissions: Set) { - fun addObservationType(schema: ObservationDataSchema): ObservationDataSchema { + fun addObservationType(schema: ObservationDataEntity): ObservationDataEntity { val obsType = observationType schema.observationType = obsType return schema 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/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt index ff3e90799..1d450448a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt @@ -10,10 +10,12 @@ */ package io.redlink.more.more_app_mutliplatform.observations.simpleQuestionObservation +import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.observations.observationTypes.SimpleQuestionType -class SimpleQuestionObservation : Observation(observationType = SimpleQuestionType()) { +class SimpleQuestionObservation(database: AppDatabase) : + Observation(database, observationType = SimpleQuestionType()) { override fun start(): Boolean { return true } 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/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt index d241844fa..47ce0c0d5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt @@ -11,6 +11,7 @@ package io.redlink.more.more_app_mutliplatform.services.bluetooth import io.ktor.utils.io.core.Closeable +import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity interface BluetoothConnector : BluetoothConnectorObserver, Closeable { @@ -34,9 +35,9 @@ interface BluetoothConnector : BluetoothConnectorObserver, Closeable { 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/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt index 0cc844de1..69bf671a0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt @@ -10,19 +10,21 @@ */ package io.redlink.more.more_app_mutliplatform.services.bluetooth +import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity + interface BluetoothConnectorObserver { - fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) + fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) - fun didConnectToDevice(bluetoothDevice: BluetoothDevice) + fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) - fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) + fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) - fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) + fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) - fun didDiscoverDevice(device: BluetoothDevice) + fun didDiscoverDevice(device: BluetoothDeviceEntity) - fun removeDiscoveredDevice(device: BluetoothDevice) + fun removeDiscoveredDevice(device: BluetoothDeviceEntity) fun isScanning(boolean: Boolean) 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 index 13e34b893..55aeb668c 100644 --- 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 @@ -1,5 +1,6 @@ package io.redlink.more.more_app_mutliplatform.services.bluetooth +import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity 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 @@ -7,79 +8,79 @@ 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> = + private val _connectedDevices: MutableStateFlow> = MutableStateFlow(emptySet()) - val connectedDevices: StateFlow> = _connectedDevices - private val _discoveredDevices: MutableStateFlow> = + val connectedDevices: StateFlow> = _connectedDevices + private val _discoveredDevices: MutableStateFlow> = MutableStateFlow(emptySet()) - val discoveredDevices: StateFlow> = _discoveredDevices - private val _pairedDevices: MutableStateFlow> = + val discoveredDevices: StateFlow> = _discoveredDevices + private val _pairedDevices: MutableStateFlow> = MutableStateFlow(emptySet()) - val pairedDevices: StateFlow> = _pairedDevices - private val _devicesCurrentlyConnecting: MutableStateFlow> = + val pairedDevices: StateFlow> = _pairedDevices + private val _devicesCurrentlyConnecting: MutableStateFlow> = MutableStateFlow( emptySet() ) - val devicesCurrentlyConnecting: StateFlow> = _devicesCurrentlyConnecting + val devicesCurrentlyConnecting: StateFlow> = + _devicesCurrentlyConnecting - fun addConnectedDevices(devices: Set) { + fun addConnectedDevices(devices: Set) { _connectedDevices.appendAll(devices) addPairedDeviceIds(devices.filter { it !in pairedDevices.value }.toSet()) removeDiscoveredDevices(devices) removeConnectingDevices(devices) } - fun removeConnectedDevices(devices: Set) { + fun removeConnectedDevices(devices: Set) { _connectedDevices.removeAll(devices) _discoveredDevices.removeAll(devices) } - fun addDiscoveredDevices(devices: Set) { + fun addDiscoveredDevices(devices: Set) { _discoveredDevices.appendAll(devices.filter { !connectedDevices.value.contains(it) }) } - fun removeDiscoveredDevices(devices: Set) { + fun removeDiscoveredDevices(devices: Set) { _discoveredDevices.removeAll(devices) } - fun addPairedDeviceIds(deviceIds: Set) { + fun addPairedDeviceIds(deviceIds: Set) { _pairedDevices.appendAll(deviceIds) } - fun removePairedDeviceIds(deviceIds: Set) { + fun removePairedDeviceIds(deviceIds: Set) { _pairedDevices.removeAll(deviceIds) } - fun addConnectingDevices(devices: Set) { + fun addConnectingDevices(devices: Set) { _devicesCurrentlyConnecting.appendAll(devices.filter { !connectedDevices.value.contains(it) }) } - fun removeConnectingDevices(devices: Set) { + fun removeConnectingDevices(devices: Set) { _devicesCurrentlyConnecting.removeAll(devices) } - fun connectedDevicesAsClosure(state: (Set) -> Unit) = + fun connectedDevicesAsClosure(state: (Set) -> Unit) = this.connectedDevices.asClosure(state) - fun connectedDevicesAsValue(): Set = connectedDevices.value + fun connectedDevicesAsValue(): Set = connectedDevices.value - fun discoveredDevicesAsClosure(state: (Set) -> Unit) = + fun discoveredDevicesAsClosure(state: (Set) -> Unit) = this.discoveredDevices.asClosure(state) - fun pairedDeviceIdsAsClosure(state: (Set) -> Unit) = + fun pairedDeviceIdsAsClosure(state: (Set) -> Unit) = this.pairedDevices.asClosure(state) - fun devicesCurrentlyConnectingAsClosure(state: (Set) -> Unit) = + fun devicesCurrentlyConnectingAsClosure(state: (Set) -> Unit) = this.devicesCurrentlyConnecting.asClosure(state) - fun foreachConnectedDevice(handler: (BluetoothDevice) -> Unit) { + fun foreachConnectedDevice(handler: (BluetoothDeviceEntity) -> Unit) { this.connectedDevices.value.forEach(handler) } - fun foreachDiscoveredDevice(handler: (BluetoothDevice) -> Unit) { + fun foreachDiscoveredDevice(handler: (BluetoothDeviceEntity) -> Unit) { this.discoveredDevices.value.forEach(handler) } 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/more_app_mutliplatform/services/bluetooth/BluetoothStateListener.kt deleted file mode 100644 index 8451b46aa..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateListener.kt +++ /dev/null @@ -1,15 +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 - -interface BluetoothStateListener { - fun onBluetoothStateChange(bluetoothState: BluetoothState) -} \ 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 index d1e55628b..8c28dec95 100644 --- 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 @@ -12,29 +12,36 @@ package io.redlink.more.more_app_mutliplatform.services.network import io.github.aakira.napier.Napier import io.ktor.client.HttpClient +import io.ktor.client.call.body +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.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json 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" @@ -44,67 +51,35 @@ class NetworkService( 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 initHttpClientWithAuth(): HttpClient? { + initHttpClient() + val creds = credentialRepository.credentials() + return httpClient?.config { + install(Auth) { + creds?.let { authCredentials -> + basic { + credentials { + BasicAuthCredentials( + username = authCredentials.apiId, + password = authCredentials.apiKey + ) + } + sendWithoutRequest { true } + } } } - } - } - - 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 - } + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) } } } - private fun initLoggingApi() { - initHttpClient() - } - private fun initHttpClient() { if (httpClient == null) { httpClient = getHttpClient(Logger.DEFAULT) @@ -113,32 +88,25 @@ class NetworkService( suspend fun deleteParticipation(): Pair { try { - credentialRepository.credentials()?.let { + credentialRepository.credentials()?.let { credentials -> Napier.i(tag = "NetworkService::deleteParticipation") { "Deleting Participation..." } - val httpClient = getHttpClient() - val url = endpointRepository.endpoint() - val registrationApi = - RegistrationApi(baseUrl = url, httpClientEngine = httpClient.engine) + val client = initHttpClientWithAuth() ?: return Pair( + false, + NetworkServiceError(null, "Failed to init HTTP client") + ) + val baseUrl = endpointRepository.endpoint() - registrationApi.setUsername(it.apiId) - registrationApi.setPassword(it.apiKey) + val response = client.delete("$baseUrl/registration") - val registrationResponse = registrationApi.unregisterFromStudy() ?: return Pair( - false, NetworkServiceError(0, "Response null") - ) - Napier.i(registrationResponse.response.toString(), tag = TAG) + Napier.i(response.toString(), tag = TAG) close() - if (registrationResponse.success) { + if (response.status.isSuccess()) { 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 - ) + Napier.e(tag = "NetworkService::deleteParticipation") { "Error; Code: ${response.status.value}" } + val error = createErrorBody(response.status.value, response) + return Pair(false, error) } return Pair(false, NetworkServiceError(null, "No credentials")) } catch (err: Exception) { @@ -152,27 +120,27 @@ class NetworkService( ): 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 + initHttpClient() + val baseUrl = endpoint ?: endpointRepository.endpoint() + val client = httpClient ?: return Pair( + null, + NetworkServiceError(null, "HTTP client not initialized") ) + val response = client.get("$baseUrl/registration") { + header("More-Registration-Token", registrationToken) + contentType(ContentType.Application.Json) + } + + Napier.i(response.toString(), tag = TAG) + if (response.status.isSuccess()) { + val study: Study = response.body() + Napier.i(tag = "NetworkService::validateRegistrationToken") { "Registration token valid!" } + return Pair(study, null) + } + val error = createErrorBody(response.status.value, response) + return Pair(null, error) + } catch (err: Exception) { Napier.e(tag = "NetworkService::validateRegistrationToken") { err.stackTraceToString() } return Pair(null, getException(err)) @@ -184,25 +152,25 @@ class NetworkService( ): 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( + initHttpClient() + val baseUrl = endpoint ?: endpointRepository.endpoint() + val client = httpClient ?: return Pair( null, - createErrorBody(consentResponse.response.status.value, consentResponse.response) + NetworkServiceError(null, "HTTP client not initialized") ) + + val response = client.post("$baseUrl/registration") { + header("More-Registration-Token", registrationToken) + contentType(ContentType.Application.Json) + setBody(studyConsent) + } + + if (response.status.isSuccess()) { + val appConfig: AppConfiguration = response.body() + Napier.i(tag = "NetworkService::sendConsent") { "Credentials received!" } + return Pair(appConfig, null) + } + return Pair(null, createErrorBody(response.status.value, response)) } catch (e: Exception) { Napier.e(tag = "NetworkService::sendConsent") { e.stackTraceToString() } return Pair(null, getException(e)) @@ -210,22 +178,27 @@ class NetworkService( } 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 { + credentialRepository.credentials()?.let { credentials -> + val client = initHttpClientWithAuth() ?: return Pair( + null, + NetworkServiceError(null, "Failed to init HTTP client") + ) + val baseUrl = endpointRepository.endpoint() + + val response = client.get("$baseUrl/config/study") { + contentType(ContentType.Application.Json) + } + + if (response.status.isSuccess()) { + val study: Study = response.body() Napier.i(tag = "NetworkService::getStudyConfig") { "Loading study data success!" } - return Pair(it, null) + return Pair(study, null) } + return Pair(null, createErrorBody(response.status.value, response)) } - - return Pair( - null, createErrorBody(configResponse.response.status.value, configResponse.response) - ) + return Pair(null, NetworkServiceError(null, "No credentials set!")) } catch (e: Exception) { Napier.e(tag = "NetworkService::getStudyConfig") { e.stackTraceToString() } return Pair(null, getException(e)) @@ -233,106 +206,110 @@ class NetworkService( } 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) { + try { + Napier.i(tag = "NetworkService::sendNotificationToken") { "Sending notification token..." } + credentialRepository.credentials()?.let { credentials -> + val client = initHttpClientWithAuth() ?: return Pair( + false, + NetworkServiceError(null, "Failed to init HTTP client") + ) + val baseUrl = endpointRepository.endpoint() + val pushToken = PushNotificationToken(token = token) + + val response = client.put("$baseUrl/config/notifications/FCM") { + contentType(ContentType.Application.Json) + setBody(pushToken) + } + + if (response.status.isSuccess()) { 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, createErrorBody(response.status.value, response)) } + return Pair(false, NetworkServiceError(null, "No credentials found!")) + } 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 { + credentialRepository.credentials()?.let { credentials -> + val client = initHttpClientWithAuth() ?: return Pair( + emptySet(), + NetworkServiceError(null, "Failed to init HTTP client") + ) + val baseUrl = endpointRepository.endpoint() + + val response = client.post("$baseUrl/data/bulk") { + contentType(ContentType.Application.Json) + setBody(data) + } + + if (response.status.isSuccess()) { + val result: List = response.body() Napier.i(tag = "NetworkService::sendData") { "Sent data!" } - dataApiResponse.response.cancel() - return Pair(it.toSet(), null) + return Pair(result.toSet(), null) } + return Pair(emptySet(), createErrorBody(response.status.value, response)) } - dataApiResponse.response.cancel() - - return Pair( - emptySet(), createErrorBody( - dataApiResponse.response.status.value, dataApiResponse.response - ) - ) + return Pair(emptySet(), NetworkServiceError(null, "No credentials set!")) } 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" } + return try { + Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloading missed notifications from the Server..." } + credentialRepository.credentials()?.let { credentials -> + val client = initHttpClientWithAuth() ?: return@let emptyList() + val baseUrl = endpointRepository.endpoint() + + val response = client.get("$baseUrl/notifications") { + contentType(ContentType.Application.Json) + } + + if (response.status.isSuccess()) { + 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" } - return emptyList() + } ?: run { + Napier.d(tag = "NetworkService::downloadMissedNotifications") { "No credentials available" } + emptyList() } - } ?: emptyList() - Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloaded Messages list: $list" } - return list + } catch (e: Exception) { + Napier.e(tag = "NetworkService::downloadMissedNotifications") { "Notification List error: $e" } + emptyList() + } } 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!" } - } + try { + credentialRepository.credentials()?.let { credentials -> + val client = initHttpClientWithAuth() ?: return + val baseUrl = endpointRepository.endpoint() + + val response = client.delete("$baseUrl/notifications/$msgId") { + contentType(ContentType.Application.Json) + } + + if (response.status.isSuccess()) { + 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" } } + } catch (e: Exception) { + Napier.e(tag = "NetworkService::deletePushNotification") { "Notification deletion error: $e" } } } @@ -360,8 +337,6 @@ class NetworkService( override fun close() { Napier.d(tag = "NetworkService::close") { "Clearing the Http engine..." } engineUseCounter = 10 - configurationApi = null - dataApi = null httpClient?.close() httpClient = null } 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 index 115ad9a59..000294f61 100644 --- 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 @@ -13,8 +13,6 @@ 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 @@ -23,6 +21,8 @@ import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Stu import io.redlink.more.more_app_mutliplatform.services.store.EndpointRepository import io.redlink.more.more_app_mutliplatform.util.StudyScope import io.redlink.more.more_app_mutliplatform.util.validateAndNormalizeUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO class RegistrationService( private val shared: Shared @@ -44,12 +44,13 @@ class RegistrationService( ) { if (token.isNotEmpty()) { StudyScope.launch { + val normalizedEndpoint = manualEndpoint?.validateAndNormalizeUrl() val (result, networkError) = shared.networkService.validateRegistrationToken( token.uppercase(), - manualEndpoint?.validateAndNormalizeUrl() + normalizedEndpoint ) result?.let { - endpoint = manualEndpoint + endpoint = normalizedEndpoint study = it participationToken = token addObservationPermissions(it) @@ -72,7 +73,7 @@ class RegistrationService( ) { StudyScope.launch { shared.credentialRepository.remove() - DatabaseManager.deleteAll() + shared.removeStudyData() } study?.let { study -> participationToken?.let { token -> @@ -101,7 +102,7 @@ class RegistrationService( onError: ((NetworkServiceError?) -> Unit), onFinish: () -> Unit ) { - StudyScope.launch { + StudyScope.launch(Dispatchers.IO) { val (config, networkError) = shared.networkService.sendConsent( token, studyConsent, @@ -122,7 +123,7 @@ class RegistrationService( } else { study?.let { study -> shared.observationFactory.clearNeededObservationTypes() - StudyRepository().storeStudy(study) + shared.studyRepository.storeStudy(study) shared.resetFirstStartUp() onSuccess(shared.credentialRepository.hasCredentials()) } ?: run { 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/Authentication.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/Authentication.kt deleted file mode 100644 index 4b6bc4ffe..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/auth/Authentication.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.services.network.openapi.auth - -interface Authentication { - - /** - * Apply authentication settings to header and query params. - * - * @param query Query parameters. - * @param headers Header parameters. - */ - fun apply(query: MutableMap>, headers: MutableMap) - -} 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/notification/NotificationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt index 1ba757f2c..98594e2be 100644 --- 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 @@ -11,13 +11,12 @@ 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.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity import io.redlink.more.more_app_mutliplatform.database.repository.NotificationRepository 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.extensions.mapQueryParams import io.redlink.more.more_app_mutliplatform.models.NotificationModel import io.redlink.more.more_app_mutliplatform.models.NotificationStatusType @@ -27,6 +26,7 @@ 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 io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -39,7 +39,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext interface LocalNotificationListener { - fun displayNotification(notification: NotificationSchema) + fun displayNotification(notification: NotificationEntity) fun deleteNotificationFromSystem(notificationId: String) @@ -50,16 +50,17 @@ interface LocalNotificationListener { } class NotificationManager( + database: AppDatabase, private val localNotificationListener: LocalNotificationListener, private val networkService: NetworkService, private val deeplinkManager: DeeplinkManager, private val sharedStorageRepository: SharedStorageRepository ) { - val notificationRepository = NotificationRepository() + val notificationRepository = NotificationRepository(database) val _unreadUserCount = MutableStateFlow(0) val unreadUserCount: StateFlow = _unreadUserCount - private val scheduleRepository = ScheduleRepository() - private val observationRepository = ObservationRepository() + private val scheduleRepository = ScheduleRepository(database) + private val observationRepository = ObservationRepository(database) init { Scope.launch(Dispatchers.IO) { @@ -85,7 +86,7 @@ class NotificationManager( ) { storeAndHandleNotification( shared, - NotificationSchema.toSchema( + NotificationEntity.toEntity( notificationId = key, channelId = null, title = title, @@ -102,24 +103,26 @@ class NotificationManager( fun storeAndHandleNotification( shared: Shared, - notification: NotificationSchema, + notification: NotificationEntity, displayNotification: Boolean ) { storeAndDisplayNotification(notification, displayNotification) if (notification.notificationData.isNotEmpty()) { handleNotificationDataAsync( shared, - notification.notificationData + notification.getNotificationDataMap() ) } } fun storeAndDisplayNotification( - notification: NotificationSchema, + notification: NotificationEntity, displayNotification: Boolean ) { if (notification.title != null && notification.notificationBody != null) { - notificationRepository.storeNotification(notification) + StudyScope.launch(Dispatchers.IO) { + notificationRepository.storeNotification(notification) + } if (displayNotification) { Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } localNotificationListener.displayNotification(notification) @@ -127,14 +130,16 @@ class NotificationManager( } } - fun storeNotifications(notifications: List) { - notificationRepository.storeNotifications(notifications) + fun storeNotifications(notifications: List) { + StudyScope.launch(Dispatchers.IO) { + notificationRepository.storeNotifications(notifications) + } } fun downloadMissedNotifications() { - Scope.launch { + Scope.launch(Dispatchers.IO) { Napier.d { "Updating notifications" } - storeNotifications(NotificationSchema.toSchemaList(networkService.downloadMissedNotifications())) + storeNotifications(NotificationEntity.toEntityList(networkService.downloadMissedNotifications())) } } @@ -145,7 +150,7 @@ class NotificationManager( fun deleteNotificationFromServer(msgID: String) { Napier.i { "Deleting notification with msgID $msgID from server..." } - Scope.launch { + Scope.launch(Dispatchers.IO) { networkService.deletePushNotification(msgID) } } @@ -165,17 +170,17 @@ class NotificationManager( } fun handleNotificationDataAsync(shared: Shared, data: Map) { - Scope.launch { + Scope.launch(Dispatchers.IO) { handleNotificationData( shared, - data.toRealmDictionary() + data ) } } suspend fun handleNotificationData( shared: Shared, - data: RealmDictionary + data: Map ) { if (data.isNotEmpty()) { if (data[MAIN_DATA_KEY] == STUDY_CHANGED) { @@ -199,7 +204,6 @@ class NotificationManager( } } - // To-Do: here check completed or only ended fun handleNotificationInteraction( notification: NotificationModel, protocolReplacement: String? = null, @@ -209,15 +213,19 @@ class NotificationManager( notification.deepLink?.let { deepLink -> Scope.launch { - // first check status of notification val state = checkIfCompletedOrRead(deepLink).cancellable().firstOrNull() if (state != null) { - if (NotificationStatusType.READ == state) notificationRepository.setNotificationReadStatus(notification.notificationId, true) - if (NotificationStatusType.COMPLETED == state) notificationRepository.setNotificationCompletedStatus(notification.notificationId, true) + if (NotificationStatusType.READ == state) notificationRepository.setNotificationReadStatus( + notification.notificationId, + true + ) + if (NotificationStatusType.COMPLETED == state) notificationRepository.setNotificationCompletedStatus( + notification.notificationId, + true + ) } - // then modify deeplink deeplinkManager.modifyDeepLink(deepLink, protocolReplacement, hostReplacement) .firstOrNull()?.let { modifiedDeepLink -> if (modifiedDeepLink.contains(DeeplinkManager.TASK_DETAILS) || modifiedDeepLink.contains( @@ -257,22 +265,20 @@ class NotificationManager( emit(null) return@flow } - var schedule = + val schedule = scheduleRepository.firstScheduleAvailableForObservationId(observationId.first()) .cancellable().firstOrNull() - val allSchedules = scheduleRepository.queryAllSchedulesForObservationId(observationId.first()) - // 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 - if (schedule?.state == ScheduleState.DONE.toString() || schedule?.state == null) { - state = NotificationStatusType.COMPLETED + state = if (schedule?.state == ScheduleState.DONE.toString() || schedule?.state == null) { + NotificationStatusType.COMPLETED } else { - state = NotificationStatusType.READ + NotificationStatusType.READ } emit(state) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt index 1bdd46e0b..6d65de5bf 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt @@ -2,7 +2,7 @@ package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/util/Scope.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/Scope.kt index c5d2e5c0f..464ef428e 100644 --- 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 @@ -41,76 +41,102 @@ object Scope { ): 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() } - } + } + } + + 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 job = Job(rootJob) val uuid = createUUID() - job.invokeOnCompletion { + val job = Job(rootJob) + + scope.launch { + mutex.withLock { + jobs[uuid] = job + } + } + + job.invokeOnCompletion { cause -> scope.launch { mutex.withLock { try { - it?.let { - Napier.w(throwable = it) { "Coroutine with UUID: $uuid was completed or threw!" } - } jobs.remove(uuid) + cause?.let { + if (it !is CancellationException) { + Napier.w(throwable = it) { "Job with UUID: $uuid was completed with exception" } + } + } } catch (e: Exception) { - Napier.e { e.stackTraceToString() } + if (e !is CancellationException) { + Napier.e(tag = "Scope::create::cleanup") { 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 { + fun repeatedLaunch( + intervalMillis: Long, + coroutineContext: CoroutineContext = Dispatchers.Default, + block: suspend CoroutineScope.() -> Unit + ): Pair { val uuid = createUUID() - val job = scope.repeatEveryFewSeconds(intervalMillis, block) + val job = scope.repeatEveryFewSeconds(intervalMillis, coroutineContext, 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() } + } + } + + 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) } @@ -123,14 +149,18 @@ object Scope { } fun cancel(uuids: Collection) { - val set = uuids.toSet() + if (uuids.isEmpty()) return + scope.launch { mutex.withLock { - val jobsToCancel = jobs.filter { it.key in set }.toList() try { - jobsToCancel.forEach { it.second.cancel() } + uuids.forEach { uuid -> + jobs[uuid]?.cancel() + } } catch (exception: Exception) { - Napier.e { exception.stackTraceToString() } + if (exception !is CancellationException) { + Napier.e(tag = "Scope::cancel::batch") { exception.stackTraceToString() } + } } } } @@ -139,7 +169,13 @@ object Scope { fun cancel() { scope.launch { mutex.withLock { - rootJob.cancelChildren() + try { + rootJob.cancelChildren() + } catch (exception: Exception) { + if (exception !is CancellationException) { + Napier.e(tag = "Scope::cancel::all") { exception.stackTraceToString() } + } + } } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt index cd123e497..152174d38 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt @@ -1,44 +1,96 @@ package io.redlink.more.more_app_mutliplatform.util /** - * Validates and normalizes a URL string by adding https:// scheme if no scheme is present. + * 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, or null if the URL is invalid + * @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 } - val trimmedUrl = this.trim() + var trimmedUrl = this.trim() - return if (trimmedUrl.contains("://")) { - if (trimmedUrl.isValidUrlFormat()) { - trimmedUrl - } else { - null - } + if (!trimmedUrl.contains("://")) { + trimmedUrl = "https://$trimmedUrl" + } + + if (!trimmedUrl.isValidUrlFormat()) { + return null + } + + return if (trimmedUrl.endsWith("/") && trimmedUrl.count { it == '/' } > 2) { + trimmedUrl.dropLast(1) } else { - val normalizedUrl = "https://$trimmedUrl" - if (normalizedUrl.isValidUrlFormat()) { - normalizedUrl - } else { - null - } + trimmedUrl } } /** - * Basic URL format validation + * 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) - afterScheme.isNotEmpty() && !afterScheme.startsWith("/") + 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 + if (this.contains("..")) 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/StudyScope.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StudyScope.kt index 0b774bc15..fcb7031ba 100644 --- 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 @@ -10,7 +10,7 @@ import kotlin.coroutines.CoroutineContext object StudyScope { private val mutex = Mutex() - private val jobs = mutableSetOf() + private val studyJobs = mutableSetOf() fun launch( coroutineContext: CoroutineContext = Dispatchers.Default, @@ -18,39 +18,45 @@ object StudyScope { block: suspend CoroutineScope.() -> Unit ): Pair { val result = Scope.launch(coroutineContext, start, block) + Scope.launch { mutex.withLock { - jobs.add(result.first) - + studyJobs.add(result.first) } } + result.second.invokeOnCompletion { Scope.launch { mutex.withLock { - jobs.remove(result.first) + studyJobs.remove(result.first) } } } + return result } fun repeatedLaunch( intervalMillis: Long, + coroutineContext: CoroutineContext = Dispatchers.Default, block: suspend CoroutineScope.() -> Unit ): Pair { - val result = Scope.repeatedLaunch(intervalMillis, block) + val result = Scope.repeatedLaunch(intervalMillis, coroutineContext, block) + Scope.launch { mutex.withLock { - jobs.add(result.first) + studyJobs.add(result.first) } } + result.second.invokeOnCompletion { Scope.launch { mutex.withLock { - jobs.remove(result.first) + studyJobs.remove(result.first) } } } + return result } @@ -63,6 +69,28 @@ object StudyScope { } fun cancel() { - Scope.cancel(this.jobs) + 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/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt index e30323791..d809e587c 100644 --- 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 @@ -12,6 +12,8 @@ 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.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity 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 @@ -20,25 +22,27 @@ 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.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.firstOrNull class BluetoothController( + database: AppDatabase, private val bluetoothConnector: BluetoothConnector, - private val scanDuration: Long = 10000, - private val scanInterval: Long = 5000 + private val scanDuration: Long = 5000, + private val scanInterval: Long = 10000 ) : CoreViewModel(), BluetoothConnectorObserver, Closeable { private val deviceManager = BluetoothDeviceManager - private val bluetoothDeviceRepository = BluetoothDeviceRepository() + private val bluetoothDeviceRepository = BluetoothDeviceRepository(database) private val _isScanning = MutableStateFlow(false) val isScanning: StateFlow = _isScanning @@ -166,7 +170,7 @@ class BluetoothController( ) { if (scanJob == null) { Napier.i(tag = "BluetoothController::startPeriodicScan") { "Starting period scanner with Duration= $customScanDuration; Interval= $customScanInterval" } - scanJob = StudyScope.repeatedLaunch(customScanInterval) { + scanJob = StudyScope.repeatedLaunch(customScanInterval, Dispatchers.IO) { Napier.i(tag = "BluetoothController::startPeriodicScan") { "Scanning..." } scanForDevices() delay(customScanDuration) @@ -191,7 +195,7 @@ class BluetoothController( bluetoothConnector.stopScanning() } - fun connectToDevice(device: BluetoothDevice): Boolean { + fun connectToDevice(device: BluetoothDeviceEntity): Boolean { if (!deviceManager.connectedDevices.value.contains(device)) { Napier.i(tag = "BluetoothController::connectToDevice") { "Connecting to $device" } deviceManager.addConnectingDevices(setOf(device)) @@ -200,28 +204,28 @@ class BluetoothController( return true } - fun unpairFromDevice(device: BluetoothDevice) { + fun unpairFromDevice(device: BluetoothDeviceEntity) { Napier.i(tag = "BluetoothController::disconnectFromDevice") { "Disconnecting from $device" } bluetoothConnector.disconnect(device) bluetoothDeviceRepository.unpairDevice(device) deviceManager.removePairedDeviceIds(setOf(device)) } - override fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) { + override fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) { deviceManager.addConnectingDevices(setOf(bluetoothDevice)) } - override fun didConnectToDevice(bluetoothDevice: BluetoothDevice) { + override fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { deviceManager.addConnectedDevices(setOf(bluetoothDevice)) bluetoothDeviceRepository.storePairedDevice(bluetoothDevice) } - override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { + override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { Napier.i(tag = "BluetoothController::didDisconnectFromDevice") { "Disconnected from $bluetoothDevice" } deviceManager.removeConnectedDevices(setOf(bluetoothDevice)) } - override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { + override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { Napier.e(tag = "BluetoothController::didFailToConnectToDevice") { "Failed to connect to $bluetoothDevice" } deviceManager.removeConnectingDevices(setOf(bluetoothDevice)) } @@ -236,7 +240,7 @@ class BluetoothController( } } - override fun didDiscoverDevice(device: BluetoothDevice) { + override fun didDiscoverDevice(device: BluetoothDeviceEntity) { if (!deviceManager.connectedDevices.value.contains(device)) { Napier.i(tag = "BluetoothController::didDiscoverDevice") { "Discovered device: $device" } deviceManager.addDiscoveredDevices(setOf(device)) @@ -246,7 +250,7 @@ class BluetoothController( } } - override fun removeDiscoveredDevice(device: BluetoothDevice) { + override fun removeDiscoveredDevice(device: BluetoothDeviceEntity) { Napier.i(tag = "BluetoothController::removeDiscoveredDevice") { "Removed discovered device: $device" } deviceManager.removeDiscoveredDevices(setOf(device)) } @@ -256,7 +260,6 @@ class BluetoothController( this._isScanning.set(boolean) } - fun bluetoothStateAsClosure(providedState: (BluetoothState) -> Unit) = bluetoothPower.asClosure(providedState) @@ -288,7 +291,10 @@ class BluetoothController( } companion object { - private const val BACKGROUND_SCAN_DURATION = 2000L - private const val BACKGROUND_SCAN_INTERVAL = 10000L + // Energy-optimized scanning intervals + private const val BACKGROUND_SCAN_DURATION = 1000L // Reduced from 2s to 1s + private const val BACKGROUND_SCAN_INTERVAL = + 30000L // Increased from 10s to 30s for better battery life + private const val MAX_BACKGROUND_SCAN_INTERVAL = 300000L // Max 5 minutes between scans } -} \ 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/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt index bc31a2d82..4fe1bce14 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt @@ -10,6 +10,7 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.dashboard +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -25,14 +26,14 @@ import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.plus -class CoreDashboardFilterViewModel : CoreViewModel() { +class CoreDashboardFilterViewModel(database: AppDatabase) : CoreViewModel() { val currentTypeFilter = MutableStateFlow(emptyMap()) val currentDateFilter = MutableStateFlow( DateFilterModel.entries.associateWith { it == DateFilterModel.ENTIRE_TIME }) init { Scope.launch { - ObservationRepository().observationTypes().firstOrNull()?.let { + ObservationRepository(database).observationTypes().firstOrNull()?.let { currentTypeFilter.set(it.associateWith { false }) } } 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 index 2906b5ca5..45b8aa1ed 100644 --- 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 @@ -10,24 +10,27 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.dashboard +import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity 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.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.cancellable -class CoreDashboardViewModel: CoreViewModel() { - private val studyRepository: StudyRepository = StudyRepository() - val study: MutableStateFlow = MutableStateFlow(null) +class CoreDashboardViewModel(database: AppDatabase) : CoreViewModel() { + private val studyRepository: StudyRepository = StudyRepository(database) + val study: MutableStateFlow = MutableStateFlow(null) override fun viewDidAppear() { - launchScope { + launchScope(Dispatchers.IO) { studyRepository.getStudy().cancellable().collect { study.value = it } } } - fun onLoadStudy(provideNewState: ((StudySchema?) -> Unit)) = study.asClosure(provideNewState) + fun onLoadStudy(provideNewState: ((StudyEntity?) -> 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 index 0b954fdbc..094e59cb3 100644 --- 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 @@ -11,6 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.limeSurvey import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -26,15 +27,17 @@ 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 +class CoreLimeSurveyViewModel(database: AppDatabase, 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() + private val scheduleRepository = ScheduleRepository(database) + private val observationRepository = ObservationRepository(database) fun setScheduleId(scheduleId: String, notificationId: String?) { if (scheduleId != this.scheduleId) { @@ -44,18 +47,20 @@ class CoreLimeSurveyViewModel(observationFactory: ObservationFactory): CoreViewM 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) + 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) } - dataLoading.set(false) - } } } @@ -65,11 +70,13 @@ class CoreLimeSurveyViewModel(observationFactory: ObservationFactory): CoreViewM fun setObservationId(observationId: String, notificationId: String?) { launchScope { - scheduleRepository.firstScheduleIdAvailableForObservationId(observationId).cancellable().firstOrNull()?.let { setScheduleId(it, notificationId) } + scheduleRepository.firstScheduleIdAvailableForObservationId(observationId).cancellable() + .firstOrNull()?.let { setScheduleId(it, notificationId) } } } - fun onLimeSurveyLinkChange(providedState: (String?) -> Unit) = limeSurveyLink?.asNullableClosure(providedState) + fun onLimeSurveyLinkChange(providedState: (String?) -> Unit) = + limeSurveyLink?.asNullableClosure(providedState) fun onDataLoadingChange(providedState: (Boolean) -> Unit) = dataLoading.asClosure(providedState) 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 index 973f082b3..dcebf9559 100644 --- 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 @@ -17,20 +17,31 @@ import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Stu import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow -class CoreLoginViewModel(private val registrationService: RegistrationService): CoreViewModel() { +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) { + 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) { + registrationService.sendRegistrationToken( + token.uppercase(), + endpoint, + onSuccess, + onError + ) { loadingFlow.value = false } } } - fun onLoadingChange(provideNewState: ((Boolean) -> Unit)) = loadingFlow.asClosure(provideNewState) + fun onLoadingChange(provideNewState: ((Boolean) -> Unit)) = + loadingFlow.asClosure(provideNewState) override fun viewDidAppear() { 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/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt index 6709cc7c9..9f014148d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt @@ -11,6 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.observationDetails import io.ktor.utils.io.core.Closeable +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -21,10 +22,11 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine class CoreObservationDetailsViewModel( + database: AppDatabase, private val observationId: String ) : CoreViewModel() { - private val scheduleRepository: ScheduleRepository = ScheduleRepository() - private val observationRepository: ObservationRepository = ObservationRepository() + private val scheduleRepository: ScheduleRepository = ScheduleRepository(database) + private val observationRepository: ObservationRepository = ObservationRepository(database) val observationDetailsModel = MutableStateFlow(null) 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 index a46e8c39a..166bb1a82 100644 --- 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 @@ -10,8 +10,9 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.schedules +import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity 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 @@ -28,11 +29,12 @@ import kotlinx.coroutines.flow.update import kotlinx.datetime.Clock class CoreScheduleViewModel( + database: AppDatabase, private val dataRecorder: DataRecorder, private val scheduleListType: ScheduleListType, private val coreFilterModel: CoreDashboardFilterViewModel, ) : CoreViewModel() { - private val scheduleRepository = ScheduleRepository() + private val scheduleRepository = ScheduleRepository(database) private var originalScheduleList = emptySet() private val _scheduleListState = MutableStateFlow( @@ -130,20 +132,20 @@ class CoreScheduleViewModel( originalScheduleList = newList.toSet() } - private fun createModels(scheduleList: List): List { + private fun createModels(scheduleList: List): List { return scheduleList .mapNotNull { ScheduleModel.createModel(it) } } - private fun createCompletedModels(scheduleList: List): List { + private fun createCompletedModels(scheduleList: List): List { return createModels(scheduleList.filter { it.getState().completed() }) } - private fun createRunningModels(scheduleList: List): List { + private fun createRunningModels(scheduleList: List): List { return createModels(scheduleList.filter { it.getState().running() }) } - private fun createManualTasks(scheduleList: List): List { + private fun createManualTasks(scheduleList: List): List { return createModels(scheduleList.filter { !it.hidden }) } 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 index 7e5a4dad4..4f73dbf34 100644 --- 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 @@ -11,13 +11,10 @@ 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.entities.ObservationEntity +import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity 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 @@ -27,38 +24,38 @@ import kotlinx.coroutines.flow.combine class CoreSettingsViewModel( private val shared: Shared -): CoreViewModel() { +) : CoreViewModel() { val dataDeleted = MutableStateFlow(false) - private val studyRepository: StudyRepository = StudyRepository() - private val observationRepository = ObservationRepository() + private val observationRepository = ObservationRepository(shared.database) - val study = MutableStateFlow(null) - val observations = MutableStateFlow(emptyList()) + val study = MutableStateFlow(null) + val observations = MutableStateFlow(emptyList()) val permissionModel = MutableStateFlow(null) override fun viewDidAppear() { launchScope { - studyRepository.getStudy().cancellable() + shared.studyRepository.getStudy().cancellable() .combine(observationRepository.observations()) { study, observations -> - Pair(study?.copyFromRealm(), observations) + Pair(study, observations) }.cancellable().collect { - if (it.first?.isValid() == true) { + if (it.first?.active == true) { study.value = it.first it.first?.let { study -> - permissionModel.value = PermissionModel.createFromSchema(study, it.second) + permissionModel.value = + PermissionModel.createFromSchema(study, it.second) } } } } launchScope { observationRepository.observations().cancellable().collect { - observations.value = it.map { it.copyFromRealm() } + observations.value = it } } } - fun onLoadStudy(provideNewState: ((StudySchema?) -> Unit)): Closeable { + fun onLoadStudy(provideNewState: ((StudyEntity?) -> Unit)): Closeable { return study.asClosure(provideNewState) } 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 index e6fcf68fa..1f0cfecc5 100644 --- 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 @@ -11,6 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.simpleQuestion import io.ktor.utils.io.core.Closeable +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -24,14 +25,16 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull class SimpleQuestionCoreViewModel( + appDatabase: AppDatabase, observationFactory: ObservationFactory, -): CoreViewModel() { +) : CoreViewModel() { private var scheduleId: String? = null - private val scheduleRepository: ScheduleRepository = ScheduleRepository() - private val observationRepository: ObservationRepository = ObservationRepository() + private val scheduleRepository: ScheduleRepository = ScheduleRepository(appDatabase) + private val observationRepository: ObservationRepository = ObservationRepository(appDatabase) val simpleQuestionModel = MutableStateFlow(null) - private var observation: Observation? = observationFactory.observation(SimpleQuestionType().observationType) + private var observation: Observation? = + observationFactory.observation(SimpleQuestionType().observationType) private var notificationId: String? = null @@ -39,21 +42,29 @@ class SimpleQuestionCoreViewModel( 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)) + 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)} + scheduleRepository.firstScheduleIdAvailableForObservationId(observationId).cancellable() + .firstOrNull()?.let { setScheduleId(it, notificationId) } } } - fun finishQuestion(data: String, setObservationToDone: Boolean){ + fun finishQuestion(data: String, setObservationToDone: Boolean) { simpleQuestionModel.value?.let { observation?.let { observation -> observation.start(it.observationId, it.scheduleId, notificationId) 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/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt index bfdcb6181..a9d87d232 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt @@ -10,9 +10,9 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.startupConnection +import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity 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 @@ -31,11 +31,11 @@ class CoreBluetoothViewModel( coreBluetooth.viewDidDisappear() } - fun connectToDevice(device: BluetoothDevice): Boolean { + fun connectToDevice(device: BluetoothDeviceEntity): Boolean { return coreBluetooth.connectToDevice(device) } - fun disconnectFromDevice(device: BluetoothDevice) { + fun disconnectFromDevice(device: BluetoothDeviceEntity) { coreBluetooth.unpairFromDevice(device) } 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 index 9327f2a1b..a25228bc5 100644 --- 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 @@ -11,54 +11,51 @@ 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.AppDatabase 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.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull -class CoreStudyDetailsViewModel: CoreViewModel() { +class CoreStudyDetailsViewModel(appDatabase: AppDatabase) : CoreViewModel() { val studyModel = MutableStateFlow(null) + val studyRepository = StudyRepository(appDatabase) + val scheduleRepository = ScheduleRepository(appDatabase) + val observationRepository = ObservationRepository(appDatabase) 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() - ) - } - } + launchScope(Dispatchers.IO) { + val taskCount: Int = scheduleRepository.count().cancellable().firstOrNull() ?: 0 + + studyRepository.getStudy().cancellable() + .combine( + scheduleRepository.allSchedulesWithStatus(true).cancellable() + ) { study, doneTasks -> + Pair(study, doneTasks.size) + }.combine( + observationRepository.observations().cancellable() + ) { (study, doneTaskCount), observations -> + study?.let { studySchema -> + studyModel.value = StudyDetailsModel.createModelFrom( + studySchema, + observations.sortedBy { it.observationTitle }, + taskCount.toLong(), + doneTaskCount.toLong() + ) } - } - } + }.collect { } } } } \ 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 index 4e01fef7d..e0f1ab562 100644 --- 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 @@ -11,6 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.taskCompletionBar import io.ktor.utils.io.core.Closeable +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -19,17 +20,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine -class CoreTaskCompletionBarViewModel: CoreViewModel() { +class CoreTaskCompletionBarViewModel(database: AppDatabase) : CoreViewModel() { val taskCompletion: MutableStateFlow = MutableStateFlow(TaskCompletion()) - private val repository = ScheduleRepository() + private val repository = ScheduleRepository(database) init { launchScope { repository.count() - .combine(repository.allSchedulesWithStatus(true).cancellable()) { scheduleCount, doneSchedules -> + .combine( + repository.allSchedulesWithStatus(true).cancellable() + ) { scheduleCount, doneSchedules -> TaskCompletion( doneSchedules.size, - scheduleCount.toInt() + scheduleCount ) }.cancellable().collect { taskCompletion.emit(it) 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 index 8626da5f9..53ed72668 100644 --- 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 @@ -11,6 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.tasks import io.ktor.utils.io.core.Closeable +import io.redlink.more.more_app_mutliplatform.database.AppDatabase 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 @@ -23,13 +24,15 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull class CoreTaskDetailsViewModel( + database: AppDatabase, private val dataRecorder: DataRecorder -): CoreViewModel() { +) : CoreViewModel() { private var scheduleId: String? = null - private val dataPointCountRepository: DataPointCountRepository = DataPointCountRepository() - private val observationRepository: ObservationRepository = ObservationRepository() - private val scheduleRepository: ScheduleRepository = ScheduleRepository() + private val dataPointCountRepository: DataPointCountRepository = + DataPointCountRepository(database) + private val observationRepository: ObservationRepository = ObservationRepository(database) + private val scheduleRepository: ScheduleRepository = ScheduleRepository(database) val taskDetailsModel = MutableStateFlow(null) val dataCount = MutableStateFlow(0) @@ -44,7 +47,8 @@ class CoreTaskDetailsViewModel( launchScope { scheduleRepository.scheduleWithId(it).cancellable().collect { schedule -> schedule?.let { schedule -> - observationRepository.observationById(schedule.observationId).cancellable().firstOrNull()?.let { + observationRepository.observationById(schedule.observationId).cancellable() + .firstOrNull()?.let { taskDetailsModel.emit(TaskDetailsModel.createModelFrom(it, schedule)) } } diff --git a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.ios.kt b/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.ios.kt new file mode 100644 index 000000000..9d6c7bc64 --- /dev/null +++ b/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.ios.kt @@ -0,0 +1,25 @@ +package io.redlink.more.more_app_mutliplatform.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/services/network/HttpClientReceiver.kt b/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt index 48e00bf49..5b1c862a1 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt @@ -13,42 +13,41 @@ package io.redlink.more.more_app_mutliplatform.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 From 1db1ed04da0f98704776b0cc21d3269370fafbc2 Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:24:50 +0200 Subject: [PATCH 34/59] P2r 319 refactor registration workflow and services (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add `prehab2rehab_develop` branch triggers to workflows * P2r 301 drop ios 15 (#303) * PR-301: Remove unused files and extensions across the iOS project, update deprecated SwiftUI modifiers, and clean up redundant code. * PR-301: Refactor `ScheduleView` and `NotificationView` to use `ScrollViewReader` for better scroll management. * P2R 301: Remove unused `TextFieldViewModifier` from `Navigation.swift`. * P2r #311 app crashes for corrupted encrypted shared preferences store (#313) * P2R #311: Implemented a new private shared preference class, that only holds the data private for the app and does not encrypt it, as encryption suffers during edge cases, like app reinstallation, phone restoring or phone transferal. This caused the app to crash without any hope to regain access other than deleting and redownloading the app and removing all data manually. This commit includes following changes: - Added a new shared preferences in a private mode, restricting the data being accessible only by the app - Implemented a migration script, that transfers the data from the old encrypted repository to the new one - Added a fallback method, if the encrypted shared preferences fail to initialize, keeping the app from crashing, but leaving the user without credentials - Made sure that left over data are cleaned in events, like corrupted credentials * P2R #311: Add license header to EncryptedSharedPreferences file * Fixed a crash by removing redundant `stopObservations` call and minor formatting changes * #298, #300: Migrated the shared module to Kotlin V2 and Room Database and adapted Android, with iOS coming in next commit * Android fix: added `BLUETOOTH_ADVERTISE` permission, switched play store track to `internal`. * #298, #300: Completed migration from Realm to Room database, updated dependencies, implemented BluetoothDeviceEntity, and improved iOS/Android integration. * #298: Update README to reflect Gradle version change (8.6 → 8.13) * #316: Improve `ObservationRecordingService` stability: (#317) - Ensure foreground service starts correctly to avoid ANR crashes. - Add fallback mechanisms for notification creation. - Handle AlarmManager null scenarios gracefully during service restart. * #316: Refactor data-clearing logic * #316: Switch Google Play deployment track from `beta` to `internal`. * #319, #321, #322: Remove unused resources and clean up deprecated view models and services from iOS and Android apps. * Prehab2rehab 294 ios upgrade (#297) * Upgraded to iOS 26 * Upgrade Pipeline to iOS 26 builds * Update workflows to use Xcode 26.0 instead of 26_beta * Update workflows and Fastfile to use Xcode 26.0-beta * Add step to download iOS 26.0 platform in workflows * Update workflows to use `universal` architecture variant for iOS 26.0 download steps * Update workflows to use iOS 26.0-beta-4 for platform download steps * Fix iOS 26.0 platform download step to use correct build version * Update iOS platform download step to log available SDKs and switch to `arm64` architecture * Simplify iOS platform download step in build workflow * Remove build version and architecture variant from iOS platform download step * Add `FASTLANE_XCODEBUILD_SETTINGS_RETRIES` to deploy and build workflows * Add comment about removing iOS 26.0 platform download step after GitHub resolves Xcode 26 beta issue * #294: Final build for iOS 26 Public Release * #294: Final build for iOS 26 Public Release * #294: Final build for iOS 26 Public Release * #2914: Update iOS workflows: use `macos-latest`, switch to Xcode 26.0 stable, and remove iOS 26 platform download step. * #294: Add temporary iOS 26.0 platform download step to workflows; to be removed after GitHub resolves Xcode 26 beta issue. * #294: Update iOS workflows to use `macos-15` runners. * #319, #321, #322: Reworked the registration workflow - Completely reworked the registration workflow - Added new Error messages and alert views - Migrated iOS String files to String Catalogue for improved DX - Added KMP Native Coroutines for better interoperability between Kotlin StateFlows and iOS Combine - Fixed many bugs regarding state issues - Cleaned up code that was no longer necessary * #319: Fixed some state bugs: - Removed unused navigation dependencies in `SettingsView` across platforms. - Improved conditional logic and state updates in shared code for study observation logic and Bluetooth handling. - Updated Android `ObservationRecordingService` to check foreground status before invoking services. - Added defensive checks for study states across shared study operation workflows. - Fixed layout adjustments in `NotificationView` on iOS. - Updated Gradle version in `README` (8.13 → 8.14.3). * #298: PR fixes: Refactor `ObservationDao` hidden parameters, remove obsolete OpenAPI Gradle configurations, and use `AppDatabase` in `BluetoothDeviceRepository`. * #298: PR fixes: Refactor `ObservationDao` to make `hidden` parameter non-nullable and clean up obsolete methods and constants in `ObservationDataRepository`. * P2r 298 #300 multiplatform mobile database migration from realmio to room (#315) * #298, #300: Migrated the shared module to Kotlin V2 and Room Database and adapted Android, with iOS coming in next commit * #298, #300: Completed migration from Realm to Room database, updated dependencies, implemented BluetoothDeviceEntity, and improved iOS/Android integration. * #298: Update README to reflect Gradle version change (8.6 → 8.13) * #298: PR fixes: Refactor `ObservationDao` hidden parameters, remove obsolete OpenAPI Gradle configurations, and use `AppDatabase` in `BluetoothDeviceRepository`. * #298: PR fixes: Refactor `ObservationDao` to make `hidden` parameter non-nullable and clean up obsolete methods and constants in `ObservationDataRepository`. * #319: PR fix: Cleaned up obsolete code and updated KMP-NativeCoroutines dependency revision. --- README.md | 2 +- androidApp/build.gradle.kts | 35 +- .../more/app/android/MoreApplication.kt | 8 +- .../app/android/activities/ContentActivity.kt | 42 +- .../android/activities/ContentViewModel.kt | 69 +- .../activities/bluetooth/BluetoothActivity.kt | 7 +- .../CompletedSchedulesView.kt | 21 +- .../android/activities/consent/ConsentView.kt | 94 +- .../activities/consent/ConsentViewModel.kt | 137 +- .../consent/composables/ConsentButtons.kt | 10 +- .../activities/dashboard/DashboardView.kt | 51 +- .../dashboard/DashboardViewModel.kt | 55 - .../dashboard/composables/FilterView.kt | 10 +- .../dashboard/filter/DashboardFilterView.kt | 22 +- .../filter/DashboardFilterViewModel.kt | 6 +- .../dashboard/modules/ModuleView.kt | 36 - .../dashboard/schedule/ScheduleViewModel.kt | 79 +- .../schedule/list/ScheduleListItem.kt | 45 +- .../schedule/list/ScheduleListView.kt | 25 +- .../app/android/activities/info/InfoItem.kt | 20 +- .../app/android/activities/info/InfoView.kt | 21 +- .../android/activities/info/InfoViewModel.kt | 27 +- .../leaveStudy/LeaveStudyConfirmView.kt | 13 +- .../activities/leaveStudy/LeaveStudyView.kt | 13 +- .../app/android/activities/login/LoginView.kt | 5 +- .../activities/login/LoginViewModel.kt | 82 +- .../login/composables/EndpointView.kt | 6 +- .../login/composables/ErrorMessage.kt | 4 +- .../login/composables/ParticipantKeyInput.kt | 33 +- .../login/composables/QRCodeButton.kt | 5 +- .../login/composables/ValidationButton.kt | 9 +- .../android/activities/main/MainActivity.kt | 120 +- .../android/activities/main/MainTabView.kt | 10 +- .../android/activities/main/MainViewModel.kt | 67 +- .../activities/main/QRScannerActivity.kt | 20 +- .../notification/NotificationView.kt | 34 +- .../notification/NotificationViewModel.kt | 34 +- .../NotificationFilterViewButton.kt | 9 +- .../composables/NotificationItem.kt | 14 +- .../filter/NotificationFilterView.kt | 13 +- .../filter/NotificationFilterViewModel.kt | 8 - .../ObservationErrorListView.kt | 5 +- .../limeSurvey/LimeSurveyActivity.kt | 12 +- .../limeSurvey/LimeSurveyViewModel.kt | 2 +- .../questionnaire/QuestionnaireButtons.kt | 1 - .../questionnaire/QuestionnaireHeader.kt | 9 +- .../QuestionnaireRadioButtons.kt | 5 +- .../questionnaire/QuestionnaireViewModel.kt | 9 +- .../runningSchedules/RunningSchedulesView.kt | 21 +- .../activities/setting/SettingsView.kt | 37 +- .../activities/setting/SettingsViewModel.kt | 8 - .../studyDetails/StudyDetailsView.kt | 21 +- .../studyDetails/StudyDetailsViewModel.kt | 29 +- .../composables/AccordionWithList.kt | 6 +- .../composables/ObservationList.kt | 7 +- .../ObservationDetailsViewModel.kt | 2 +- .../activities/studyStates/StudyClosedView.kt | 17 +- .../studyStates/StudyLoadingView.kt | 62 + .../TaskCompletionBarViewModel.kt | 3 +- .../activities/tasks/TaskDetailsView.kt | 255 +- .../activities/tasks/TaskDetailsViewModel.kt | 83 +- .../NotificationBroadcastReceiver.kt | 10 +- .../extensions/ComposableExtensions.kt | 6 +- .../android/extensions/DateTimeConversion.kt | 24 +- .../more/app/android/firebase/FCMService.kt | 2 +- .../AndroidObservationDataManager.kt | 6 +- .../observations/AndroidObservationFactory.kt | 14 +- .../observations/GPS/GPSObservation.kt | 6 +- .../HR/PolarHeartRateObservation.kt | 8 +- .../ObservationManagerExtension.kt | 2 +- .../accelerometer/AccelerometerObservation.kt | 8 +- .../services/ObservationRecordingService.kt | 68 +- .../services/bluetooth/PolarConnector.kt | 6 +- .../sensorsListener/BluetoothStateListener.kt | 2 +- .../sensorsListener/GPSStateListener.kt | 2 +- .../android/shared_composables/Accordion.kt | 47 +- .../shared_composables/AccordionReadMore.kt | 30 +- .../ActivityProgressView.kt | 19 +- .../android/shared_composables/AppVersion.kt | 3 +- .../DatapointCollectionView.kt | 9 +- .../shared_composables/EmptyListView.kt | 6 +- .../shared_composables/HeaderDescription.kt | 3 +- .../android/shared_composables/HeaderTitle.kt | 2 +- .../android/shared_composables/IconInline.kt | 7 +- .../android/shared_composables/MediumTitle.kt | 6 +- .../shared_composables/MessageAlertDialog.kt | 18 +- .../MoreBackgroundComposable.kt | 19 +- .../android/shared_composables/MoreDivider.kt | 6 +- .../shared_composables/ScheduleList.kt | 102 +- .../shared_composables/ScheduleListHeader.kt | 6 +- .../shared_composables/TimeFrameDays.kt | 8 +- .../shared_composables/TimeframeHours.kt | 6 +- .../app/android/shared_composables/Title.kt | 11 +- .../more/app/android/ui/theme/Color.kt | 10 +- .../more/app/android/ui/theme/Theme.kt | 1 - .../redlink/more/app/android/ui/theme/Type.kt | 1 - .../app/android/workers/DataUploadWorker.kt | 22 +- .../android/workers/ScheduleUpdateWorker.kt | 8 +- .../res/values-de/alert-dialog-strings.xml | 2 + .../src/main/res/values-de/login-strings.xml | 2 + .../res/values-de/schedule_view_strings.xml | 1 + .../res/values-de/study_states_strings.xml | 1 + .../main/res/values/alert-dialog-strings.xml | 2 + .../src/main/res/values/login-strings.xml | 2 + .../main/res/values/schedule_view_strings.xml | 1 + .../main/res/values/study_states_strings.xml | 1 + build.gradle.kts | 2 + gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.properties | 4 +- iosApp/iosApp.xcodeproj/project.pbxproj | 372 +-- .../xcshareddata/swiftpm/Package.resolved | 11 +- iosApp/iosApp/AppDelegate.swift | 9 +- iosApp/iosApp/ContentView.swift | 83 +- iosApp/iosApp/ContentViewModel.swift | 166 +- .../Extensions/DictionaryExtension.swift | 16 + .../iosApp/Extensions/NavigationScreen.swift | 4 +- .../iosApp/Extensions/StringExtension.swift | 12 - .../AccelerometerBackgroundObservation.swift | 6 +- .../AccelerometerObservation.swift | 4 +- .../iosApp/Observations/GPSObservation.swift | 4 +- .../Observations/IOSObservationFactory.swift | 10 +- .../PolarVerityHeartRateObservation.swift | 35 +- .../Resources/Strings/Localizable.xcstrings | 2605 +++++++++++++++++ .../Strings/de.lproj/AlertDialog.strings | 14 - .../de.lproj/BluetoothConnection.strings | 22 - .../Strings/de.lproj/ConsentView.strings | 11 - .../Strings/de.lproj/DashboardFilter.strings | 31 - .../Strings/de.lproj/DashboardView.strings | 12 - .../Strings/de.lproj/Default.strings | 8 - .../Resources/Strings/de.lproj/Errors.strings | 22 - .../Strings/de.lproj/ExpandableText.strings | 10 - .../Resources/Strings/de.lproj/Info.strings | 18 - .../Strings/de.lproj/LimeSurvey.strings | 11 - .../Strings/de.lproj/LoginView.strings | 31 - .../Strings/de.lproj/Navigation.strings | 24 - .../Strings/de.lproj/NotificationView.strings | 5 - .../de.lproj/ObservationDetails.strings | 10 - .../Strings/de.lproj/ScheduleListView.strings | 22 - .../Strings/de.lproj/SettingsView.strings | 28 - .../SimpleQuestionObservation.strings | 19 - .../Strings/de.lproj/StudyDetailsView.strings | 18 - .../Strings/de.lproj/StudyStates.strings | 16 - .../Strings/de.lproj/TaskDetail.strings | 18 - .../Strings/en.lproj/AlertDialog.strings | 14 - .../en.lproj/BluetoothConnection.strings | 22 - .../Strings/en.lproj/ConsentView.strings | 11 - .../Strings/en.lproj/DashboardFilter.strings | 31 - .../Strings/en.lproj/DashboardView.strings | 16 - .../Strings/en.lproj/Default.strings | 8 - .../Resources/Strings/en.lproj/Errors.strings | 22 - .../Strings/en.lproj/ExpandableText.strings | 10 - .../Resources/Strings/en.lproj/Info.strings | 18 - .../Strings/en.lproj/LimeSurvey.strings | 11 - .../Strings/en.lproj/LoginView.strings | 31 - .../Strings/en.lproj/Navigation.strings | 25 - .../Strings/en.lproj/NotificationView.strings | 13 - .../en.lproj/ObservationDetails.strings | 10 - .../Strings/en.lproj/ScheduleListView.strings | 22 - .../Strings/en.lproj/SettingsView.strings | 28 - .../SimpleQuestionObservation.strings | 19 - .../Strings/en.lproj/StudyDetailsView.strings | 18 - .../Strings/en.lproj/StudyStates.strings | 17 - .../Strings/en.lproj/TaskDetail.strings | 16 - .../Bluetooth/BluetoothConnectionView.swift | 25 +- .../BluetoothConnectionViewModel.swift | 111 +- .../CompletedSchedules.swift | 2 +- .../iosApp/Views/Components/AppVersion.swift | 2 +- .../iosApp/Views/Components/BasicText.swift | 2 +- .../Components/DatapointsCollection.swift | 3 +- .../Views/Components/DetailsTitle.swift | 2 +- .../iosApp/Views/Components/ErrorLogin.swift | 42 - .../iosApp/Views/Components/ErrorText.swift | 2 +- .../Views/Components/ExpandableText.swift | 2 +- .../Components/Filters/MoreFilterOption.swift | 2 +- .../Components/Filters/MoreFilterText.swift | 2 +- .../Views/Components/InactiveText.swift | 2 +- .../Views/Components/InlineAbortButton.swift | 3 +- .../Views/Components/MoreAlertDialog.swift | 23 +- .../Views/Components/NavigationText.swift | 2 +- .../Components/ObservatinoDetailsData.swift | 4 +- .../Views/Components/ScheduleListHeader.swift | 6 +- .../Views/Components/SectionHeading.swift | 4 +- iosApp/iosApp/Views/Components/Title.swift | 2 +- iosApp/iosApp/Views/Components/Title2.swift | 2 +- .../Views/Components/UIToggleButtonView.swift | 28 - .../Views/Components/WebView/WebView.swift | 1 + iosApp/iosApp/Views/Consent/ConsentView.swift | 119 +- .../Views/Consent/ConsentViewModel.swift | 108 +- .../DashboardFilter/DashboardFilterView.swift | 8 +- .../DashboardFilterViewModel.swift | 5 +- .../Views/Dashboard/DashboardPicker.swift | 38 - .../Views/Dashboard/DashboardView.swift | 16 +- .../Views/Dashboard/DashboardViewModel.swift | 44 - .../Dashboard/StudyTitleForwardButton.swift | 39 - iosApp/iosApp/Views/Info/InfoList.swift | 13 +- iosApp/iosApp/Views/Info/InfoView.swift | 14 +- iosApp/iosApp/Views/Info/InfoViewModel.swift | 38 +- .../LeaveStudyConfirmationView.swift | 22 +- .../Views/LeaveStudy/LeaveStudyView.swift | 22 +- .../Views/LimeSurvey/LimeSurveyView.swift | 2 +- .../LimeSurvey/LimeSurveyViewModel.swift | 2 +- iosApp/iosApp/Views/Login/LoginButton.swift | 14 +- iosApp/iosApp/Views/Login/LoginView.swift | 84 +- .../iosApp/Views/Login/LoginViewModel.swift | 57 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 15 +- .../Views/Login/ScanQrCodeViewModel.swift | 23 +- iosApp/iosApp/Views/MainTabView.swift | 16 +- iosApp/iosApp/Views/Navigation.swift | 16 +- .../iosApp/Views/NavigationModalState.swift | 80 +- .../Filter/NotificationFilterView.swift | 22 +- .../Filter/NotificationFilterViewModel.swift | 25 +- .../Views/Notification/NotificationView.swift | 22 +- .../Notification/NotificationViewModel.swift | 52 +- .../ObservationDetailsView.swift | 13 +- .../ObservationDetailsViewModel.swift | 2 +- .../Views/ObservationErrorListView.swift | 6 +- .../ObservationErrorsView.swift | 2 +- .../SimpleQuestionObservationViewModel.swift | 2 +- .../SimpleQuestionThankYouView.swift | 10 +- .../SimpleQuetionObservationView.swift | 6 +- .../Registration/RegistrationObservable.swift | 66 + .../RunningSchedules/RunningSchedules.swift | 2 +- .../Schedule/List/ObservationButton.swift | 17 +- .../List/ObservationTimeDetails.swift | 2 +- .../iosApp/Views/Schedule/ScheduleView.swift | 16 +- .../Views/Schedule/ScheduleViewModel.swift | 116 +- .../iosApp/Views/Settings/SettingsView.swift | 16 +- .../Views/Settings/SettingsViewModel.swift | 40 +- .../Views/StudyDetails/StudyDetailsView.swift | 11 +- .../StudyDetails/StudyDetailsViewModel.swift | 26 +- .../Views/StudyStates/StudyClosedView.swift | 27 +- .../Views/StudyStates/StudyLoadingView.swift | 27 + .../Views/StudyStates/StudyPausedView.swift | 5 +- .../Views/StudyStates/StudyUpdateView.swift | 7 +- .../TaskCompletionBarView.swift | 3 - .../TaskCompletionBarViewModel.swift | 21 +- .../Views/TaskDetails/TaskDetailsView.swift | 33 +- .../TaskDetails/TaskDetailsViewModel.swift | 7 +- shared/build.gradle.kts | 20 +- .../services/extensions/StringExtension.kt | 21 - .../extensions/AsStringTest.kt | 82 - .../more_app_mutliplatform/AlertController.kt | 10 +- .../more/more_app_mutliplatform/Shared.kt | 142 +- .../database/dao/BaseDao.kt | 3 + .../database/dao/StudyDao.kt | 3 + .../repository/BluetoothDeviceRepository.kt | 4 +- .../repository/DataPointCountRepository.kt | 2 +- .../database/repository/MainRepository.kt | 20 + .../repository/NotificationRepository.kt | 2 +- .../repository/ObservationDataRepository.kt | 4 +- .../database/repository/ScheduleRepository.kt | 2 +- .../database/repository/StudyRepository.kt | 55 +- .../extensions/FlowExtension.kt | 29 +- .../models/AlertDialogModel.kt | 4 +- .../models/LoginModel.kt | 13 + .../models/ScheduleModel.kt | 1 - .../navigation/DeeplinkManager.kt | 15 +- .../observations/Observation.kt | 36 +- .../observations/ObservationDataManager.kt | 24 +- .../observations/ObservationFactory.kt | 15 +- .../observations/ObservationManager.kt | 51 +- .../limesurvey/LimeSurveyObservation.kt | 6 +- .../SimpleQuestionObservation.kt | 6 +- .../registration/RegistrationService.kt | 164 ++ .../{util => scopes}/Scope.kt | 3 +- .../{util => scopes}/StudyScope.kt | 2 +- .../bluetooth/BluetoothDeviceManager.kt | 23 +- .../services/network/NetworkService.kt | 63 +- .../services/network/RegistrationService.kt | 154 - .../notification/NotificationManager.kt | 36 +- .../services/store/CredentialRepository.kt | 33 +- .../services/store/StudyStateRepository.kt | 58 - .../viewModels/CoreViewModel.kt | 41 +- .../viewModels/ViewManager.kt | 25 +- .../BluetoothController.kt | 26 +- .../dashboard/CoreDashboardFilterViewModel.kt | 15 +- .../dashboard/CoreDashboardViewModel.kt | 36 - .../limeSurvey/CoreLimeSurveyViewModel.kt | 19 +- .../viewModels/login/CoreLoginViewModel.kt | 33 +- .../CoreNotificationFilterViewModel.kt | 35 +- .../CoreNotificationViewModel.kt | 27 +- .../CoreObservationDetailsViewModel.kt | 13 +- .../permission/CoreConsentViewModel.kt | 29 + .../permission/CorePermissionViewModel.kt | 68 - .../permission/PermissionViewModelModel.kt | 23 - .../schedules/CoreScheduleViewModel.kt | 149 +- .../settings/CoreSettingsViewModel.kt | 59 +- .../SimpleQuestionCoreViewModel.kt | 27 +- .../CoreBluetoothViewModel.kt | 10 +- .../studydetails/CoreStudyDetailsViewModel.kt | 59 +- .../CoreTaskCompletionBarViewModel.kt | 30 +- .../tasks/CoreTaskDetailsViewModel.kt | 92 +- .../more_app_mutliplatform/GreetingTests.kt | 14 - .../store/CredentialRepositoryTest.kt | 38 - 294 files changed, 5490 insertions(+), 4658 deletions(-) delete mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardViewModel.kt delete mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/modules/ModuleView.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingView.kt create mode 100644 iosApp/iosApp/Resources/Strings/Localizable.xcstrings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/AlertDialog.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/BluetoothConnection.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/ConsentView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/DashboardFilter.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/DashboardView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/Default.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/ExpandableText.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/Info.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/LimeSurvey.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/NotificationView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/ObservationDetails.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/ScheduleListView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/SettingsView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/SimpleQuestionObservation.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/StudyDetailsView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/StudyStates.strings delete mode 100644 iosApp/iosApp/Resources/Strings/de.lproj/TaskDetail.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/AlertDialog.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/BluetoothConnection.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/ConsentView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/DashboardFilter.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/DashboardView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/Default.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/ExpandableText.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/Info.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/LimeSurvey.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/NotificationView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/ObservationDetails.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/ScheduleListView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/SettingsView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/SimpleQuestionObservation.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/StudyDetailsView.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/StudyStates.strings delete mode 100644 iosApp/iosApp/Resources/Strings/en.lproj/TaskDetail.strings delete mode 100644 iosApp/iosApp/Views/Components/ErrorLogin.swift delete mode 100644 iosApp/iosApp/Views/Components/UIToggleButtonView.swift delete mode 100644 iosApp/iosApp/Views/Dashboard/DashboardPicker.swift delete mode 100644 iosApp/iosApp/Views/Dashboard/DashboardViewModel.swift delete mode 100644 iosApp/iosApp/Views/Dashboard/StudyTitleForwardButton.swift create mode 100644 iosApp/iosApp/Views/Registration/RegistrationObservable.swift create mode 100644 iosApp/iosApp/Views/StudyStates/StudyLoadingView.swift delete mode 100644 shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/extensions/StringExtension.kt delete mode 100644 shared/src/androidUnitTest/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AsStringTest.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/MainRepository.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/LoginModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt rename shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/{util => scopes}/Scope.kt (98%) rename shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/{util => scopes}/StudyScope.kt (97%) delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/RegistrationService.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/StudyStateRepository.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardViewModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CoreConsentViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CorePermissionViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/PermissionViewModelModel.kt delete mode 100644 shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/GreetingTests.kt delete mode 100644 shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepositoryTest.kt diff --git a/README.md b/README.md index 76a9529a5..4d8087e1f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ performance. In order to build the iOS application the version of **iOS** should * [XCode](https://apps.apple.com/us/app/xcode) (Must be of version 16.0 or higher) * [Command Line Tools](https://developer.apple.com/downloads/) * [JDK 21](https://www.oracle.com/java/technologies/downloads/) -* [Gradle 8.13](https://gradle.org) +* [Gradle 8.14.3](https://gradle.org) 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. diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 8cce83e6f..ad28ce399 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -49,8 +49,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 30 - versionName = "4.1.2" + versionCode = 29 + versionName = "4.0.29" } buildFeatures { compose = true @@ -62,6 +62,12 @@ android { } } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + signingConfigs { create("release") { val keystorePath = getEnvOrProperty("ANDROID_KEYSTORE_PATH", envProps) ?: "" @@ -143,12 +149,14 @@ android { // ... rest of your dependencies ... -val composeVersion = "1.6.8" -val workVersion = "2.9.0" -val navVersion = "2.7.7" +val composeVersion = "1.6.0" +val workVersion = "2.10.3" +val navVersion = "2.9.3" val polarSDKVersion = "6.6.0" val ktorVersion = "3.2.3" val roomVersion = "2.7.2" +val koinVersion = "4.1.1" +val cameraVersion = "1.4.2" dependencies { implementation(project(":shared")) @@ -169,23 +177,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.2.0") - implementation("androidx.camera:camera-camera2:1.3.0") - implementation("androidx.camera:camera-lifecycle:1.3.0") - implementation("androidx.camera:camera-view:1.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/src/main/java/io/redlink/more/app/android/MoreApplication.kt b/androidApp/src/main/java/io/redlink/more/app/android/MoreApplication.kt index 349d1b4ae..c3211ba93 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 @@ -28,6 +28,7 @@ import io.redlink.more.more_app_mutliplatform.Shared import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.getDatabaseBuilder import io.redlink.more.more_app_mutliplatform.database.getRoomDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.napierDebugBuild import io.redlink.more.more_app_mutliplatform.services.store.SharedPreferencesRepository @@ -83,14 +84,15 @@ class MoreApplication : Application(), DefaultLifecycleObserver { polarConnector = PolarConnector(context) val androidBluetoothConnector = polarConnector!! val database: AppDatabase = getRoomDatabase(getDatabaseBuilder(context)) - val dataManager = AndroidObservationDataManager(context, database) + val repositories = MainRepository(database) + val dataManager = AndroidObservationDataManager(context, repositories) shared = Shared( LocalPushNotificationService(context), - database, + repositories, SharedPreferencesRepository(context), dataManager, androidBluetoothConnector, - AndroidObservationFactory(context, dataManager, database), + AndroidObservationFactory(context, dataManager, repositories), AndroidDataRecorder() ) } 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 91089cb5b..dc748cc59 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,17 +14,22 @@ 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.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.StudyUpdateView +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 kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch class ContentActivity : ComponentActivity() { private val viewModel = ContentViewModel() @@ -43,6 +48,21 @@ class ContentActivity : ComponentActivity() { } 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) } @@ -55,17 +75,19 @@ class ContentActivity : ComponentActivity() { @Composable fun ContentView(viewModel: ContentViewModel) { + val validLogin by viewModel.registrationService.validLoginModel.collectAsStateWithLifecycle() + val hasCredentials by MoreApplication.shared!!.credentialRepository.hasCredentials.collectAsStateWithLifecycle() + val credentialsLoaded by MoreApplication.shared!!.credentialRepository.credentialsLoaded.collectAsStateWithLifecycle() MoreBackground(showBackButton = false, alertDialogModel = viewModel.alertDialogOpen.value) { - if (viewModel.hasCredentials.value) { - viewModel.openMainActivity(LocalContext.current) - StudyUpdateView() - } else { - if (viewModel.loginViewScreenNr.intValue == 0) { - LoginView(model = viewModel.loginViewModel) - AppVersion() + if (credentialsLoaded && !hasCredentials) { + if (validLogin != null) { + ConsentView(viewModel.registrationService) } else { - ConsentView(model = viewModel.consentViewModel) + LoginView(viewModel.registrationService) + AppVersion() } + } 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 2d120217e..171f03f25 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,9 +12,8 @@ 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 @@ -23,10 +22,6 @@ 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 @@ -34,8 +29,7 @@ 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.registration.RegistrationService import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager import kotlinx.coroutines.Dispatchers @@ -45,25 +39,21 @@ 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!! - ) - } - - val loginViewModel: LoginViewModel by lazy { LoginViewModel(registrationService, this) } - val consentViewModel: ConsentViewModel by lazy { ConsentViewModel(registrationService, this) } +class ContentViewModel : ViewModel() { + val registrationService: RegistrationService = RegistrationService(MoreApplication.shared!!) - 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 @@ -97,7 +87,9 @@ class ContentViewModel : ViewModel(), LoginViewModelListener, ConsentViewModelLi viewModelScope.launch(Dispatchers.IO) { ViewManager.checkingForNewStudyData.first { !it } val modifiedDeepLink = rawDeepLink?.let { link -> - MoreApplication.shared!!.deeplinkManager + val sharedInstance = MoreApplication.shared + ?: throw IllegalStateException("MoreApplication.shared is not initialized") + sharedInstance.deeplinkManager .modifyDeepLink(link, stringResource(R.string.app_scheme), applicationId) .firstOrNull() } @@ -105,15 +97,17 @@ class ContentViewModel : ViewModel(), LoginViewModelListener, ConsentViewModelLi val finalUri = when { modifiedDeepLink != null -> { notificationId?.let { - MoreApplication.shared!!.notificationManager.handleNotificationInteraction( + val sharedInstance = MoreApplication.shared + ?: throw IllegalStateException("MoreApplication.shared is not initialized") + sharedInstance.notificationManager.handleNotificationInteraction( it, modifiedDeepLink ) } - Uri.parse(modifiedDeepLink) + modifiedDeepLink.toUri() } notificationId != null -> { - Uri.parse(ContentActivity.DEEPLINK + NavigationScreen.NOTIFICATIONS.routeWithParameters()) + (ContentActivity.DEEPLINK + NavigationScreen.NOTIFICATIONS.routeWithParameters()).toUri() } else -> null @@ -136,31 +130,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/bluetooth/BluetoothActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt index 36cd4e67a..ff4de9338 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 @@ -213,7 +215,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/completedSchedules/CompletedSchedulesView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/completedSchedules/CompletedSchedulesView.kt index 04019b2f3..bd2e133e2 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,31 +17,21 @@ 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.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() - } - } +fun CompletedSchedulesView( + viewModel: ScheduleViewModel, + navController: NavController, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { Column( verticalArrangement = Arrangement.SpaceEvenly, horizontalAlignment = Alignment.CenterHorizontally, @@ -58,7 +48,6 @@ fun CompletedSchedulesView(viewModel: ScheduleViewModel, navController: NavContr Column { ScheduleListView( navController = navController, - routeString = NavigationScreen.COMPLETED_SCHEDULES.routeWithParameters(), scheduleViewModel = viewModel, showButton = false ) 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..6b469baad 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,73 @@ 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.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.more_app_mutliplatform.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, - 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)) - } +fun ConsentView(registrationService: RegistrationService) { + val model = remember { ConsentViewModel(registrationService) } + val permissions by model.coreModel.permissions.collectAsStateWithLifecycle() + permissions?.let { permissionModel -> + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.9f) + ) { + 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) + 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..a98f355b4 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,65 @@ 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 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.more_app_mutliplatform.registration.RegistrationService +import io.redlink.more.more_app_mutliplatform.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() - } - )) + 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 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 = 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() + } + )) } 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 da7b42bff..8d408aa72 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 @@ -28,10 +28,12 @@ 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.lifecycle.compose.collectAsStateWithLifecycle import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.consent.ConsentViewModel @@ -41,6 +43,7 @@ import io.redlink.more.app.android.ui.theme.MoreColors @Composable fun ConsentButtons(model: ConsentViewModel) { + val isLoading by model.registrationService.isLoading.collectAsStateWithLifecycle() val context = LocalContext.current val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -70,7 +73,7 @@ fun ConsentButtons(model: ConsentViewModel) { } - if (!model.loading.value) { + if (!isLoading) { Column( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally, @@ -86,7 +89,6 @@ fun ConsentButtons(model: ConsentViewModel) { backgroundColor = MoreColors.Primary, contentColor = MoreColors.White ), - enabled = !model.loading.value, modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) @@ -104,7 +106,6 @@ fun ConsentButtons(model: ConsentViewModel) { backgroundColor = MoreColors.Important, contentColor = MoreColors.White ), - enabled = !model.loading.value, modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) @@ -132,7 +133,8 @@ fun checkAndRequestPermissions( model: ConsentViewModel, extraPermissions: Set = emptySet() ) { - val permissions = model.permissions.toMutableSet() + val permissions = + MoreApplication.shared!!.observationFactory.studySensorPermissions().toMutableSet() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissions.add(Manifest.permission.POST_NOTIFICATIONS) 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..42776cebf 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,71 +11,46 @@ 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.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() - } - } +fun DashboardView( + navController: NavController, + scheduleViewModel: ScheduleViewModel, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { Column( verticalArrangement = Arrangement.SpaceEvenly, horizontalAlignment = CenterHorizontally, modifier = Modifier .fillMaxWidth() .fillMaxHeight() - ){ + ) { ScheduleListHeader( - viewModel = viewModel.scheduleViewModel, + 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) - } + 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 b15090208..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/DashboardViewModel.kt +++ /dev/null @@ -1,55 +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.MoreApplication -import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity -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(MoreApplication.shared!!.database) - - var study: MutableState = mutableStateOf(StudyEntity()) - 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/composables/FilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/composables/FilterView.kt index 1223deb0a..de35d7d1c 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 @@ -44,9 +46,11 @@ 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("scheduleListType" 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..c89163506 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,6 +24,7 @@ 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 @@ -34,10 +36,11 @@ 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.more_app_mutliplatform.viewModels.schedules.CoreScheduleViewModel @Composable -fun DashboardFilterView(viewModel: DashboardFilterViewModel) { +fun DashboardFilterView(coreScheduleViewModel: CoreScheduleViewModel) { + val viewModel = remember { DashboardFilterViewModel(coreScheduleViewModel.coreFilterModel) } LazyColumn { item { HeaderTitle( @@ -61,7 +64,10 @@ fun DashboardFilterView(viewModel: DashboardFilterViewModel) { modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.toggleDateFilter(item.key) }) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleDateFilter(item.key) }) .padding(4.dp) ) { HeaderDescription( @@ -96,7 +102,10 @@ fun DashboardFilterView(viewModel: DashboardFilterViewModel) { modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.clearTypeFilter() }) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.clearTypeFilter() }) .padding(4.dp) ) { HeaderDescription( @@ -124,7 +133,10 @@ fun DashboardFilterView(viewModel: DashboardFilterViewModel) { modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.toggleTypeFilter(item.key) }) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleTypeFilter(item.key) }) .padding(4.dp) ) { HeaderDescription( 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 ee666ac91..d2d0a5ec5 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,17 +13,19 @@ 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.scopes.Scope.launch import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class DashboardFilterViewModel(private val coreViewModel: CoreDashboardFilterViewModel) { +class DashboardFilterViewModel(private val coreViewModel: CoreDashboardFilterViewModel) : + ViewModel() { val currentTypeFilter = mutableStateMapOf() val currentDateFilter = mutableStateMapOf() 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 fb90efd79..5d56dcfde 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,97 +11,42 @@ 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 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(MoreApplication.shared!!.database) + CoreDashboardFilterViewModel(MoreApplication.shared!!.repositories) - private val coreViewModel = CoreScheduleViewModel( - MoreApplication.shared!!.database, + val coreViewModel = CoreScheduleViewModel( + MoreApplication.shared!!.repositories, MoreApplication.shared!!.dataRecorder, + scheduleListType = scheduleListType, coreFilterModel = coreDashboardFilterViewModel, - scheduleListType = scheduleListType + observationFactory = MoreApplication.shared!!.observationFactory ) 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 { withContext(Dispatchers.Main) { polarHrReady.value = it } } - }) - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() - jobs.forEach { it.cancel() } - jobs.clear() + } } fun startObservation(scheduleId: String) { @@ -116,15 +61,5 @@ class ScheduleViewModel( 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 - } + fun numberOfObservationErrors(): Int = coreViewModel.numberOfObservationErrors() } \ 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..cfec056aa 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,9 +23,11 @@ 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 @@ -40,14 +42,14 @@ 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 - @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 +61,8 @@ 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 +77,12 @@ fun ScheduleListItem( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - BasicText(text = scheduleModel.observationType, color = MoreColors.Secondary) + BasicText(text = scheduleModel().observationType, 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,19 +99,19 @@ 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) { + if (showButton && !scheduleModel().hidden) { + when (scheduleModel().observationType) { "question-observation" -> { SmallTextButton( text = getStringResource(id = R.string.more_questionnaire_start), - enabled = scheduleModel.scheduleState.active() + enabled = scheduleModel().scheduleState.active() ) { navController.navigate( - NavigationScreen.SIMPLE_QUESTION.navigationRoute("scheduleId" to scheduleModel.scheduleId) + NavigationScreen.SIMPLE_QUESTION.navigationRoute("scheduleId" to scheduleModel().scheduleId) ) } } @@ -117,25 +119,26 @@ fun ScheduleListItem( "lime-survey-observation" -> { SmallTextButton( text = getStringResource(id = R.string.more_limesurvey_start), - enabled = scheduleModel.scheduleState.active() + enabled = scheduleModel().scheduleState.active() ) { - navController.navigate(NavigationScreen.LIMESURVEY.navigationRoute("scheduleId" to scheduleModel.scheduleId)) + navController.navigate(NavigationScreen.LIMESURVEY.navigationRoute("scheduleId" to scheduleModel().scheduleId)) } } else -> { SmallTextButton( - text = if (scheduleModel.scheduleState == ScheduleState.RUNNING) getStringResource( + 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) + enabled = scheduleModel().scheduleState.active() && if (scheduleModel().observationType == "polar-verity-observation") + viewModel.polarHrReady.value else true ) { - if (scheduleModel.scheduleState == ScheduleState.RUNNING) { - viewModel.pauseObservation(scheduleModel.scheduleId) + if (scheduleModel().scheduleState == ScheduleState.RUNNING) { + viewModel.pauseObservation(scheduleModel().scheduleId) } else { - viewModel.startObservation(scheduleModel.scheduleId) + 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..25347df76 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,6 +23,7 @@ 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 @@ -30,16 +32,26 @@ import io.redlink.more.app.android.shared_composables.NavigationText import io.redlink.more.app.android.ui.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..6f2450797 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,6 +37,7 @@ 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 @@ -50,18 +50,10 @@ import io.redlink.more.app.android.shared_composables.SmallTitle import io.redlink.more.app.android.ui.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() @@ -118,7 +110,7 @@ fun InfoView(navController: NavController, viewModel: InfoViewModel) { } item { - viewModel.model.value?.let { + studyInfo?.let { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -238,7 +230,6 @@ fun InfoView(navController: NavController, viewModel: InfoViewModel) { } } - } 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 004f0ca88..2b7af9f89 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,35 +10,10 @@ */ 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.app.android.MoreApplication -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 class InfoViewModel : ViewModel() { - private val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!.database) - 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() - } + val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!) } \ 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..3dc3b5d0d 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 @@ -44,11 +44,12 @@ import io.redlink.more.app.android.ui.theme.moreApproved import io.redlink.more.app.android.ui.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()) + val route = + backStackEntry?.arguments?.getString(NavigationScreen.LEAVE_STUDY_CONFIRM.routeWithParameters()) LaunchedEffect(route) { viewModel.viewDidAppear() } @@ -69,7 +70,11 @@ fun LeaveStudyConfirmView(navController: NavController, viewModel: LeaveStudyVie verticalArrangement = Arrangement.Center ) { viewModel.permissionModel.value?.let { - Title(text = it.studyTitle, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + Title( + text = it.studyTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) } Spacer(Modifier.height(80.dp)) 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..81520b44b 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 @@ -43,11 +43,12 @@ import io.redlink.more.app.android.ui.theme.moreApproved import io.redlink.more.app.android.ui.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()) + val route = + backStackEntry?.arguments?.getString(NavigationScreen.LEAVE_STUDY.routeWithParameters()) LaunchedEffect(route) { viewModel.viewDidAppear() } @@ -68,7 +69,11 @@ fun LeaveStudyView(navController: NavController, viewModel: LeaveStudyViewModel) verticalArrangement = Arrangement.Center ) { viewModel.permissionModel.value?.let { - Title(text = it.studyTitle, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + Title( + text = it.studyTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) } Spacer(Modifier.height(80.dp)) 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..a82700f35 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.more_app_mutliplatform.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 475c819e2..01aca96bc 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,66 +12,58 @@ 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 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.models.LoginModel +import io.redlink.more.more_app_mutliplatform.registration.RegistrationService +import io.redlink.more.more_app_mutliplatform.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 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( + stringResource(R.string.more_token_error), + stringResource(R.string.more_404), + positiveTitle = "Ok" + ) + ) + } + } else { + val dialogModel = AlertDialogModel( + title = stringResource(R.string.no_internet_connection_title), + message = stringResource(R.string.no_internet_connection_body), + positiveTitle = "Ok" + ) + AlertController.openAlertDialog(dialogModel) + } } fun extractValuesFromQRCode(qrCodeUrl: String) { dataEndpoint.value = qrCodeUrl.substringBefore("signup?") - participantKey.value = qrCodeUrl.substringAfter("token=", "").takeIf { it.isNotEmpty() } ?: "" + 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..68a0287df 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 @@ -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..7aa814653 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 @@ -25,7 +25,9 @@ import io.redlink.more.app.android.ui.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) } 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 c0b1ba744..eb14f757a 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,6 +39,7 @@ 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 @@ -50,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), @@ -70,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) } }, @@ -85,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, @@ -114,12 +116,17 @@ 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) 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 ed8cffc82..5bd316a21 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 @@ -37,7 +37,6 @@ 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.moreSecondary - @Composable fun QRCodeButton(model: LoginViewModel) { @@ -46,8 +45,8 @@ fun QRCodeButton(model: LoginViewModel) { contract = ActivityResultContracts.StartActivityForResult() ) { result -> val data = result.data - val scanned = result.data?.getStringExtra("qrResult") ?: - return@rememberLauncherForActivityResult + val scanned = + result.data?.getStringExtra("qrResult") ?: return@rememberLauncherForActivityResult model.extractValuesFromQRCode(scanned) } 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..c2e309e93 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,9 +18,11 @@ 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 @@ -29,15 +31,16 @@ import io.redlink.more.app.android.ui.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 81b0059a4..95d6e40e6 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,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -37,7 +38,6 @@ import io.redlink.more.app.android.activities.NavigationScreen.Companion.Navigat 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 @@ -53,17 +53,16 @@ import io.redlink.more.app.android.activities.studyDetails.observationDetails.Ob import io.redlink.more.app.android.activities.studyStates.StudyClosedView 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.app.android.util.ActivityProvider 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.more_app_mutliplatform.viewModels.ViewManager class MainActivity : ComponentActivity() { - private var loadedNavController = false - private lateinit var navHostController: NavHostController override fun onResume() { @@ -96,25 +95,33 @@ 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) + 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) { + navHostController.navigate( + NavigationScreen.DASHBOARD.navigationRoute() + ) + } 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, @@ -122,7 +129,6 @@ class MainActivity : ComponentActivity() { navHostController, activityLauncher ) - loadedNavController = true } } } @@ -136,6 +142,7 @@ fun MainView( activityResultLauncher: ActivityResultLauncher ) { val currentContext = rememberUpdatedState(LocalContext.current) + val taskCompletionBarViewModel = remember { TaskCompletionBarViewModel() } MoreBackground( navigationTitle = navigationTitle, showBackButton = viewModel.showBackButton.value, @@ -166,10 +173,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 ) } } @@ -182,8 +189,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) } } @@ -196,8 +202,7 @@ fun MainView( ) { viewModel.tabIndex.intValue = 2 viewModel.showBackButton.value = false - viewModel.navigationBarTitle.value = screen.stringRes() - InfoView(navController, viewModel = viewModel.infoVM) + InfoView(navController) } } @@ -208,9 +213,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 -> @@ -224,14 +228,11 @@ fun MainView( val scheduleId by remember { mutableStateOf(requireNotNull(arguments.getString("scheduleId"))) } - val taskVM by remember { mutableStateOf(viewModel.getTaskDetailsVM(scheduleId)) } - viewModel.navigationBarTitle.value = screen.stringRes() viewModel.showBackButton.value = true TaskDetailsView( navController = navController, - viewModel = taskVM, scheduleId = scheduleId ) } @@ -244,11 +245,7 @@ fun MainView( ) { val arguments = requireNotNull(it.arguments) - viewModel.navigationBarTitle.value = - NavigationScreen.OBSERVATION_DETAILS.stringRes() val observationId = arguments.getString("observationId") - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true val obsDetailsVM by remember { @@ -267,12 +264,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 ) } } @@ -284,29 +280,20 @@ 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( + val coreViewModel = remember { + viewModel.schedulesViewModel( + ScheduleListType.valueOf( + requireNotNull(it.arguments).getString( "scheduleListType", "ALL" ) - )) { - ScheduleListType.MANUALS -> viewModel.manualTasks.filterModel - ScheduleListType.RUNNING -> viewModel.runningSchedulesViewModel.filterModel - ScheduleListType.COMPLETED -> viewModel.completedSchedulesViewModel.filterModel - ScheduleListType.ALL -> DashboardFilterViewModel( - CoreDashboardFilterViewModel(MoreApplication.shared!!.database) - ) - } + ) ) - } - DashboardFilterView(viewModel = vm) + }.coreViewModel + + DashboardFilterView(coreViewModel) } } @@ -326,10 +313,8 @@ fun MainView( mutableStateOf(it.arguments?.getString(NavigationNotificationIDKey)) } - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true - val vm by remember { + val viewModel by remember { mutableStateOf( viewModel.creteNewSimpleQuestionViewModel( scheduleId, @@ -339,8 +324,8 @@ fun MainView( ) } QuestionnaireView( - navController = navController, - viewModel = vm + navController, + viewModel ) } } @@ -382,8 +367,6 @@ fun MainView( screen.routeWithParameters(), screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = false QuestionnaireResponseView(navController) @@ -396,11 +379,9 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true - NotificationFilterView(viewModel = viewModel.notificationFilterViewModel) + NotificationFilterView(coreViewModel = viewModel.coreNotificationFilterViewModel) } } @@ -410,14 +391,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 ) } } @@ -428,13 +407,11 @@ 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 ) } } @@ -445,9 +422,8 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = screen.stringRes() viewModel.showBackButton.value = true - LeaveStudyView(navController, viewModel = viewModel.leaveStudyViewModel) + LeaveStudyView(navController) } } @@ -457,10 +433,8 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true - LeaveStudyConfirmView(navController, viewModel = viewModel.leaveStudyViewModel) + LeaveStudyConfirmView(navController) } } @@ -470,8 +444,6 @@ fun MainView( screen.createListOfNavArguments(), screen.createDeepLinkRoute() ) { - viewModel.navigationBarTitle.value = - screen.stringRes() viewModel.showBackButton.value = true ObservationErrorView() } 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 2b6cbea0c..205e882ce 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 @@ -20,23 +20,13 @@ 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.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 @@ -48,21 +38,13 @@ class MainViewModel(context: Context) : ViewModel() { val showBackButton = mutableStateOf(false) val navigationBarTitle = mutableStateOf("") - val studyIsUpdating = mutableStateOf(false) - val studyState = mutableStateOf(StudyState.NONE) - val finishText = mutableStateOf(null) - val unreadNotificationCount = mutableIntStateOf(0) - private var initFinished = false + val coreNotificationFilterViewModel = CoreNotificationFilterViewModel() - 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,24 +57,10 @@ 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 @@ -104,17 +72,6 @@ class MainViewModel(context: Context) : ViewModel() { } } } - viewModelScope.launch(Dispatchers.IO) { - ViewManager.studyIsUpdating.collect { - studyIsUpdating.value = it - } - } - viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.currentStudyState.collect { - finishText.value = MoreApplication.shared!!.finishText - studyState.value = it - } - } viewModelScope.launch(Dispatchers.IO) { MoreApplication.shared!!.unreadNotificationCount.collect { @@ -124,12 +81,6 @@ class MainViewModel(context: Context) : ViewModel() { } } - val coreNotificationFilterViewModel = CoreNotificationFilterViewModel() - notificationViewModel = NotificationViewModel(coreNotificationFilterViewModel) - notificationFilterViewModel = NotificationFilterViewModel(coreNotificationFilterViewModel) - - initFinished = true - viewModelScope.launch { ViewManager.showBluetoothView.collect { if (it && !lastBleViewState) { @@ -140,9 +91,6 @@ class MainViewModel(context: Context) : ViewModel() { } } - fun getTaskDetailsVM(scheduleId: String) = - taskDetailsViewModel.apply { setSchedule(scheduleId) } - fun openLimesurvey( context: Context, activityResultLauncher: ActivityResultLauncher, @@ -186,6 +134,13 @@ class MainViewModel(context: Context) : ViewModel() { 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 index fb95170c7..edebcb283 100644 --- 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 @@ -67,19 +67,18 @@ import io.redlink.more.app.android.shared_composables.IconInline import io.redlink.more.app.android.ui.theme.MoreColors import io.redlink.more.app.android.ui.theme.moreSecondary - // Infos to Barcodes mit ML Kit: https://developers.google.com/ml-kit/vision/barcode-scanning/android?hl=de -class QRScannerActivity: ComponentActivity() { +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() + registerForActivityResult( + ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> permissionGiven.value = isGranted if (isGranted) { @@ -106,18 +105,25 @@ class QRScannerActivity: ComponentActivity() { private fun requestCameraPermissionIfNeeded() { when { - ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED -> { startCamera() } - ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA) -> { + + 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) { 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 d1113655f..a1c19824e 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,6 +11,7 @@ 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 @@ -24,33 +25,27 @@ 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.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.more_app_mutliplatform.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() - } - } +fun NotificationView( + navController: NavController, + coreFilterViewModel: CoreNotificationFilterViewModel +) { + val viewModel = remember { NotificationViewModel(coreFilterViewModel) } + val notificationList by viewModel.coreViewModel.notificationList.collectAsStateWithLifecycle() LazyColumn( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, @@ -71,16 +66,19 @@ fun NotificationView(navController: NavController, viewModel: NotificationViewMo } item { - if (viewModel.notificationList.isEmpty()) { + if (notificationList.isEmpty()) { Text(text = getStringResource(id = R.string.no_notifications_yet)) } } - items(viewModel.notificationList.sortedByDescending { it.timestamp }) { notification -> + items(notificationList.sortedByDescending { it.timestamp }) { notification -> Box( modifier = Modifier .fillMaxWidth() - .clickable { + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { if (!notification.read) { viewModel.handleNotificationAction(notification, navController) } 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..7414e1218 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,10 +10,8 @@ */ 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 @@ -23,45 +21,21 @@ 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 - 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 ) - 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)) + NotificationActionHandler.DEEPLINK -> navController.navigate(data.toUri()) } } } @@ -70,6 +44,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(", ") } } 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..34cb82847 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 @@ -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 6f35114f0..cb293d0f7 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 @@ -42,7 +42,7 @@ 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 @@ -134,7 +134,7 @@ fun NotificationItem( ) Text( - text = notificationModel.timestamp.jvmLocalDateTimeFromMilliseconds() + text = notificationModel.timestamp.jvmLocalDateTimeFromEpochSeconds() .formattedString("dd.MM.yyyy HH:mm:ss"), fontWeight = FontWeight.Normal, fontSize = 14.sp, @@ -145,11 +145,11 @@ fun NotificationItem( if (notificationModel.deepLink != null) { 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 - ) + 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..844abe5e2 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,6 +23,7 @@ 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 @@ -33,9 +35,11 @@ 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.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel @Composable -fun NotificationFilterView(viewModel: NotificationFilterViewModel) { +fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { + val viewModel = remember { NotificationFilterViewModel(coreViewModel) } LazyColumn { item { HeaderTitle( @@ -49,7 +53,7 @@ fun NotificationFilterView(viewModel: NotificationFilterViewModel) { Row( verticalAlignment = Alignment.CenterVertically, ) { - if(entry.value) + if (entry.value) IconInline( icon = Icons.Rounded.Done, color = MoreColors.Approved, @@ -59,7 +63,10 @@ fun NotificationFilterView(viewModel: NotificationFilterViewModel) { modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) - .clickable(onClick = { viewModel.toggleFilter(entry.key) }) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { viewModel.toggleFilter(entry.key) }) .padding(4.dp) ) { HeaderDescription( 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..b47f2e172 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 @@ -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..d454eea33 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 @@ -32,8 +31,8 @@ import io.redlink.more.more_app_mutliplatform.observations.Observation @Composable fun ObservationErrorListView( - errors: SnapshotStateList, - errorActions: SnapshotStateList + errors: List, + errorActions: List ) { val context = LocalContext.current if (errors.isNotEmpty()) { 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..0d31f7c66 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 @@ -17,6 +17,7 @@ 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 @@ -61,6 +62,11 @@ class LimeSurveyActivity : ComponentActivity() { intent.getStringExtra(LIME_SURVEY_ACTIVITY_NOTIFICATION_ID) ) + onBackPressedDispatcher.addCallback(this) { + viewModel.onFinish() + finish() + } + webView = WebView(this) webView?.let { webView -> webClientListener = LimeSurveyWebClient() @@ -107,12 +113,6 @@ class LimeSurveyActivity : ComponentActivity() { 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" 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 7f257abab..4a0b1d005 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 @@ -27,7 +27,7 @@ import java.net.URL class LimeSurveyViewModel : ViewModel(), WebClientListener { private val coreViewModel = CoreLimeSurveyViewModel( - MoreApplication.shared!!.database, + MoreApplication.shared!!.repositories, MoreApplication.shared!!.observationFactory ) val limeSurveyLink = mutableStateOf(null) 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..e51dc1e61 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 @@ -32,7 +32,6 @@ 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 - @Composable fun QuestionnaireButtons(navController: NavController, model: QuestionnaireViewModel) { val context = LocalContext.current 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..657386874 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 @@ -32,12 +32,13 @@ 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 - @Composable fun QuestionnaireHeader(model: QuestionnaireViewModel) { - Column(modifier = Modifier - .fillMaxWidth() - .padding(2.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + ) { HeaderTitle(title = model.observationTitle.value) Spacer(Modifier.height(12.dp)) 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..cd957af41 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,6 +10,7 @@ */ 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 @@ -51,7 +52,9 @@ fun QuestionnaireRadioButtons(model: QuestionnaireViewModel) { .selectable( selected = isSelectedItem(item), onClick = { onChangeState(item) }, - role = Role.RadioButton + role = Role.RadioButton, + indication = null, + interactionSource = remember { MutableInteractionSource() } ) .padding(vertical = 8.dp) ) { 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 index 8efa3928d..043ed790c 100644 --- 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 @@ -13,17 +13,16 @@ package io.redlink.more.app.android.activities.observations.questionnaire 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.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!!.database, + MoreApplication.shared!!.repositories, MoreApplication.shared!!.observationFactory ) @@ -34,10 +33,8 @@ class QuestionnaireViewModel : ViewModel() { val answerSet = mutableStateOf("") val observationParticipantInfo = mutableStateOf("") - private val scope = CoroutineScope(Dispatchers.Default + Job()) - init { - scope.launch { + viewModelScope.launch(Dispatchers.Main.immediate) { coreViewModel.simpleQuestionModel.collect { model -> withContext(Dispatchers.Main) { model?.let { 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..319b9108f 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,31 +17,21 @@ 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.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() - } - } +fun RunningSchedulesView( + viewModel: ScheduleViewModel, + navController: NavController, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { Column( verticalArrangement = Arrangement.SpaceEvenly, horizontalAlignment = Alignment.CenterHorizontally, @@ -58,7 +48,6 @@ fun RunningSchedulesView(viewModel: ScheduleViewModel, navController: NavControl Column { ScheduleListView( navController = navController, - routeString = NavigationScreen.RUNNING_SCHEDULES.routeWithParameters(), scheduleViewModel = viewModel, showButton = true ) 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..44385867f 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 @@ -19,35 +19,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember 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.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 - @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() - } - } +fun SettingsView() { + val model = remember { SettingsViewModel() } Column( modifier = Modifier .fillMaxHeight() @@ -67,22 +50,6 @@ fun SettingsView( ) Spacer(Modifier.height(24.dp)) - - /* - model.permissionModel.value?.let { - Spacer(Modifier.height(24.dp)) - - Accordion( - title = getStringResource(id = R.string.more_study_consent), - description = it.studyConsentInfo, - hasCheck = true, - hasSmallTitle = true, - hasPreview = false - ) - } - */ - - } items(model.permissionModel.value?.consentInfo ?: emptyList()) { consentInfo -> 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 b239bfa08..09578b079 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 @@ -42,12 +42,4 @@ class SettingsViewModel : ViewModel() { } } } - - 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 fe02a7af3..98493f8ad 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,14 @@ 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.studyDetails.composables.AccordionWithList import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel @@ -42,22 +41,12 @@ import io.redlink.more.app.android.ui.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() - } - } + val viewModel = remember { StudyDetailsViewModel() } + val studyInfo by viewModel.coreViewModel.studyModel.collectAsStateWithLifecycle() - viewModel.model.value?.let { + studyInfo?.let { Column( verticalArrangement = Arrangement.Top, modifier = Modifier 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 63dc38fd5..7be289820 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,35 +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.app.android.MoreApplication -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 -class StudyDetailsViewModel: ViewModel() { - private val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!.database) - 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 9ca4641e8..796a8b356 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 @@ -67,7 +68,10 @@ fun AccordionWithList( modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp) - .clickable { + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { open.value = !open.value } ) { 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 45d866bd3..6d5b5019d 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 @@ -21,6 +22,7 @@ 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.unit.dp @@ -42,7 +44,10 @@ fun ObservationList(navController: NavController, observations: List = mutableStateOf(TaskCompletion()) init { 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 eb18e464d..6c3816156 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,13 +22,14 @@ 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.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.observationErrors.ObservationErrorListView @@ -53,20 +54,20 @@ import io.redlink.more.more_app_mutliplatform.observations.observationTypes.Simp @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()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() + val viewModel = + remember { + TaskDetailsViewModel( + MoreApplication.shared!!.dataRecorder, + MoreApplication.shared!!.observationFactory, + scheduleId + ) } - } + 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() Column( verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.CenterHorizontally, @@ -74,132 +75,132 @@ fun TaskDetailsView( .fillMaxWidth() .padding(4.dp) ) { - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - HeaderTitle( - title = viewModel.taskDetailsModel.value.observationTitle, + taskDetails?.let { taskDetails -> + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(0.65f) - .padding(vertical = 11.dp) + .fillMaxWidth() + ) { + 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 = taskDetails.observationType, + color = MoreColors.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.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() - ) { - 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) - ) + 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) + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Accordion( - title = getStringResource(id = R.string.participant_information), - description = viewModel.taskDetailsModel.value.participantInformation, - hasCheck = false, - hasPreview = false - ) - 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.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + scheduleId?.let { + if (!taskDetails.state.completed()) { + DatapointCollectionView( + dataPoints, + taskDetails.state + ) + } } } - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally + ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.CenterHorizontally - ) { - - ObservationErrorListView( - errors = viewModel.taskObservationErrors, - errorActions = viewModel.taskObservationErrorActions - ) + ObservationErrorListView( + errors = taskErrors, + errorActions = taskErrorActions + ) - 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 - ) { - if (viewModel.taskDetailsModel.value.observationType == SimpleQuestionType().observationType) { - navController.navigate( - NavigationScreen.SIMPLE_QUESTION.navigationRoute( - "scheduleId" to scheduleId - ) + if (!taskDetails.hidden) { + SmallTextButton( + text = if (taskDetails.state == ScheduleState.RUNNING) getStringResource( + id = R.string.more_observation_pause ) - } else if (viewModel.taskDetailsModel.value.observationType == LimeSurveyType().observationType) { - navController.navigate( - NavigationScreen.LIMESURVEY.navigationRoute( - "scheduleId" to scheduleId - ) + else if (taskDetails.observationType == SimpleQuestionType().observationType) getStringResource( + id = R.string.more_questionnaire_start ) - } else if (viewModel.taskDetailsModel.value.state == ScheduleState.RUNNING) { - viewModel.pauseObservation() - } else { - viewModel.startObservation() + else if (taskDetails.observationType == LimeSurveyType().observationType) getStringResource( + id = R.string.more_limesurvey_start + ) + else getStringResource( + id = R.string.more_observation_start + ), + enabled = taskDetails.state.active() && if (taskDetails.observationType == PolarVerityHeartRateType( + emptySet() + ).observationType + ) viewModel.polarHrReady.value else true + ) { + if (taskDetails.observationType == SimpleQuestionType().observationType) { + navController.navigate( + NavigationScreen.SIMPLE_QUESTION.navigationRoute( + "scheduleId" to scheduleId + ) + ) + } else if (taskDetails.observationType == LimeSurveyType().observationType) { + navController.navigate( + NavigationScreen.LIMESURVEY.navigationRoute( + "scheduleId" to scheduleId + ) + ) + } else if (taskDetails.state == ScheduleState.RUNNING) { + viewModel.pauseObservation() + } else { + viewModel.startObservation() + } } } } 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 b120beb1b..e954043ac 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,38 +10,32 @@ */ 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.observations.ObservationFactory import io.redlink.more.more_app_mutliplatform.viewModels.tasks.CoreTaskDetailsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class TaskDetailsViewModel( - dataRecorder: DataRecorder + dataRecorder: DataRecorder, + observationFactory: ObservationFactory, + scheduleId: String ) : ViewModel() { - private val coreViewModel: CoreTaskDetailsViewModel = - CoreTaskDetailsViewModel(MoreApplication.shared!!.database, 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, + observationFactory, + scheduleId ) - ) - val taskObservationErrors = mutableStateListOf() - val taskObservationErrorActions = mutableStateListOf() - private var observationErrors: Map> = emptyMap() + val polarHrReady = mutableStateOf(false) init { viewModelScope.launch(Dispatchers.IO) { @@ -51,61 +45,6 @@ class TaskDetailsViewModel( } } } - 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/broadcasts/NotificationBroadcastReceiver.kt b/androidApp/src/main/java/io/redlink/more/app/android/broadcasts/NotificationBroadcastReceiver.kt index 1153a6cab..c87d912e3 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 @@ -21,8 +21,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch class NotificationBroadcastReceiver : BroadcastReceiver() { - private var notificationRepository = MoreApplication.shared!!.notificationManager.notificationRepository - private val scope = CoroutineScope(Job() + Dispatchers.IO) override fun onReceive(context: Context, intent: Intent?) { @@ -30,7 +28,10 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { if (intent.action == NOTIFICATION_SET_ON_READ_ACTION) { intent.getStringExtra(NotificationManager.MSG_ID)?.let { key -> scope.launch { - notificationRepository.setNotificationReadStatus(key, true) + MoreApplication.shared!!.repositories.notification.setNotificationReadStatus( + key, + true + ) } } } @@ -38,6 +39,7 @@ 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" } } \ 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..805696e29 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,18 +26,19 @@ 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 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/firebase/FCMService.kt b/androidApp/src/main/java/io/redlink/more/app/android/firebase/FCMService.kt index 65a769679..6f370f383 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 @@ -53,6 +53,6 @@ fun RemoteMessage.daoFromRemoteMessage(): NotificationEntity { 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/AndroidObservationDataManager.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt index 4dfd607ee..c9a40e16b 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 @@ -17,11 +17,11 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import io.redlink.more.app.android.workers.DataUploadWorker -import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManager -class AndroidObservationDataManager(context: Context, database: AppDatabase) : - ObservationDataManager(database) { +class AndroidObservationDataManager(context: Context, repository: MainRepository) : + ObservationDataManager(repository) { private val workManager = WorkManager.getInstance(context) private val workerConstraints = Constraints.Builder() 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 a8279f72d..52c4ef668 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,25 +18,25 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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.more_app_mutliplatform.scopes.Scope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class AndroidObservationFactory( context: Context, observationDataManager: ObservationDataManager, - database: AppDatabase + repository: MainRepository ) : - ObservationFactory(database, observationDataManager) { + ObservationFactory(repository, observationDataManager) { init { observations.addAll( setOf( - AccelerometerObservation(context, database), - GPSObservation(context, database, gpsService = GPSService(context)), - PolarHeartRateObservation(database) + AccelerometerObservation(context, repository), + GPSObservation(context, repository, gpsService = GPSService(context)), + PolarHeartRateObservation(repository) ) ) 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 a28d75c29..b9501fe75 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 @@ -22,7 +22,7 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.observations.observationTypes.GPSType import kotlinx.coroutines.CoroutineScope @@ -46,9 +46,9 @@ private val permissions = if (Build.VERSION.SDK_INT >= 34) { class GPSObservation( context: Context, - database: AppDatabase, + repos: MainRepository, private val gpsService: GPSService -) : Observation(database, observationType = GPSType(permissions)), +) : Observation(repos, observationType = GPSType(permissions)), GPSListener { private val locationManager = context.getSystemService(LocationManager::class.java) private val scope = CoroutineScope(Job() + Dispatchers.IO) 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 0986707b7..f27f28faf 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 @@ -23,13 +23,13 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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.scopes.Scope import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.util.Scope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -58,9 +58,9 @@ private val permissions = ) } -class PolarHeartRateObservation(database: AppDatabase) : +class PolarHeartRateObservation(repos: MainRepository) : Observation( - database, + repos, observationType = PolarVerityHeartRateType(permissions) ) { private val deviceManager = BluetoothDeviceManager 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 index f8ffd1011..b2a354d0b 100644 --- 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 @@ -4,7 +4,7 @@ import android.app.Activity import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.observations.ObservationManager -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.more_app_mutliplatform.scopes.Scope import kotlinx.coroutines.Dispatchers /** 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 3facc3bf9..448f57ccb 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 @@ -18,18 +18,18 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Build import android.util.Log -import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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.more_app_mutliplatform.scopes.Scope private const val TAG = "AccelerometerObservation" class AccelerometerObservation( context: Context, - database: AppDatabase + repos: MainRepository ) : Observation( - database = database, + repos, observationType = AccelerometerType( if (Build.VERSION.SDK_INT >= 34) { setOf( 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 e78b2fa7a..1a01916ff 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,6 +10,7 @@ */ package io.redlink.more.app.android.services +import android.app.Activity import android.app.AlarmManager import android.app.Notification import android.app.NotificationChannel @@ -26,28 +27,22 @@ 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.observations.AndroidDataRecorder 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.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.more_app_mutliplatform.util.StudyScope +import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class ObservationRecordingService : Service() { private var observationManager: ObservationManager? = null - private val scheduleRepository = ScheduleRepository(MoreApplication.shared!!.database) - private val studyRepository = StudyRepository(MoreApplication.shared!!.database) 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 @@ -87,12 +82,7 @@ class ObservationRecordingService : Service() { } observationFactory?.let { if (observationManager == null) { - observationManager = - MoreApplication.shared?.observationManager ?: ObservationManager( - MoreApplication.shared!!.database, - it, - AndroidDataRecorder() - ) + observationManager = MoreApplication.shared!!.observationManager } } return intent?.action?.let { action -> @@ -274,7 +264,7 @@ class ObservationRecordingService : Service() { startForegroundService() scope.launch { try { - if (studyRepository.getStudy().firstOrNull()?.active == true) { + if (MoreApplication.shared!!.repositories.study.study.value?.active == true) { scheduleId.forEach { id -> try { if (observationManager?.start(id) == true) { @@ -324,7 +314,7 @@ class ObservationRecordingService : Service() { observationManager?.stop(scheduleId) runningSchedules.remove(scheduleId) StudyScope.launch(Dispatchers.IO) { - scheduleRepository.setCompletionStateFor(scheduleId, true) + MoreApplication.shared!!.repositories.schedule.setCompletionStateFor(scheduleId, true) } if (observationManager?.hasRunningTasks() == false) { stopService() @@ -548,7 +538,7 @@ class ObservationRecordingService : Service() { */ private fun checkPermissionsAndStart( scheduleIds: Set, - activity: android.app.Activity + activity: Activity ) { val observations = MoreApplication.shared?.observationFactory?.observations ?: emptySet() @@ -616,26 +606,38 @@ class ObservationRecordingService : Service() { } 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 (MoreApplication.shared?.appIsInForeGround == true) { + 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 (MoreApplication.shared?.appIsInForeGround == true) { + 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 (MoreApplication.shared?.appIsInForeGround == true) { + 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() { 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 ce0382e98..ac362dd8a 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 @@ -23,10 +23,10 @@ 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.database.entities.BluetoothDeviceEntity +import io.redlink.more.more_app_mutliplatform.scopes.Scope 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.BluetoothState -import io.redlink.more.more_app_mutliplatform.util.Scope import kotlinx.coroutines.delay class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListener { @@ -185,10 +185,10 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe override fun onBluetoothStateChange(bluetoothState: BluetoothState) { this.bluetoothState = bluetoothState updateObserver { it.onBluetoothStateChange(bluetoothState) } - if (MoreApplication.shared?.credentialRepository?.hasCredentials() == true && bluetoothState == BluetoothState.ON) { + if (MoreApplication.shared?.credentialRepository?.hasCredentials?.value == true && bluetoothState == BluetoothState.ON) { Scope.launch { scan() - delay(10000) + delay(5000) stopScanning() } } else { 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..c1fc01341 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 @@ -7,7 +7,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.more_app_mutliplatform.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..d24c8bf61 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 @@ -5,7 +5,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.more_app_mutliplatform.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..470abed48 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 @@ -43,7 +44,6 @@ import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.ui.theme.MoreColors - @Composable fun Accordion( title: String, @@ -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..75e938522 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 @@ -42,7 +43,7 @@ import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.ui.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..f588e0dd6 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 @@ -28,15 +28,22 @@ import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.ui.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..740f55e5e 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 @@ -32,7 +32,8 @@ 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/DatapointCollectionView.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/DatapointCollectionView.kt index 0717399b5..fd9dcfb14 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 @@ -30,9 +30,8 @@ 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 - @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..c79fb60fa 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 @@ -17,9 +17,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import io.redlink.more.app.android.ui.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..b48400d86 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 @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.sp import io.redlink.more.app.android.ui.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/IconInline.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/IconInline.kt index 38d022c56..18f2b3be5 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 @@ -20,7 +20,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import io.redlink.more.app.android.ui.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..18ed5aeb7 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 @@ -19,7 +19,11 @@ import androidx.compose.ui.unit.sp import io.redlink.more.app.android.ui.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..66c138716 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 @@ -30,7 +30,8 @@ fun MessageAlertDialog(model: AlertDialogModel) { positiveButtonTitle = model.positiveTitle, negativeButtonTitle = model.negativeTitle, onPositive = model.onPositive, - onNegative = model.onNegative) + onNegative = model.onNegative + ) } @Composable @@ -41,10 +42,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 +62,8 @@ fun MessageAlertDialog( Text(text = message) }, confirmButton = { - TextButton(onClick = { onPositive() }, + TextButton( + onClick = { onPositive?.let { it() } }, colors = positiveButtonColors ?: defaultButtonColors ) { Text(text = positiveButtonTitle) @@ -66,7 +71,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..1ecb1a502 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 @@ -67,15 +67,16 @@ fun MoreBackground( MoreApplication.openSettings.value = false } MorePlatformTheme { - Scaffold(topBar = { - MoreTopAppBar( - navigationTitle, - showBackButton, - onBackButtonClick, - leftCornerContent, - rightCornerContent - ) - }, + Scaffold( + topBar = { + MoreTopAppBar( + navigationTitle, + showBackButton, + onBackButtonClick, + leftCornerContent, + rightCornerContent + ) + }, bottomBar = { if (showTabRow) { MoreBottomAppBar( 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..8e6f17b39 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 @@ -19,6 +19,10 @@ import androidx.compose.ui.unit.dp import io.redlink.more.app.android.ui.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/ScheduleList.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleList.kt index c54484b8f..dfb65c2e5 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,90 @@ 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 @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( + "scheduleId" to scheduleModel.scheduleId, + "scheduleListType" 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..3163e3839 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,6 +19,7 @@ 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -26,6 +27,7 @@ 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 @@ -39,6 +41,8 @@ fun ScheduleListHeader( navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { + val filterViewModel = + remember { DashboardFilterViewModel(viewModel.coreViewModel.coreFilterModel) } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.height(IntrinsicSize.Min) @@ -60,7 +64,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/TimeFrameDays.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/TimeFrameDays.kt index 64d2a84d1..a8c28fb9d 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 @@ -26,7 +26,7 @@ import io.redlink.more.app.android.ui.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..7d07bcfe3 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 @@ -26,7 +26,11 @@ import io.redlink.more.app.android.ui.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..715b1a919 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 @@ -21,11 +21,12 @@ import androidx.compose.ui.unit.sp import io.redlink.more.app.android.ui.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/ui/theme/Color.kt index ec7c69f46..a061ad7dd 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/ui/theme/Color.kt @@ -10,7 +10,6 @@ */ package io.redlink.more.app.android.ui.theme - import androidx.compose.foundation.BorderStroke import androidx.compose.material.ButtonDefaults import androidx.compose.runtime.Composable @@ -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/ui/theme/Theme.kt index 431206098..4820daab7 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/ui/theme/Theme.kt @@ -10,7 +10,6 @@ */ package io.redlink.more.app.android.ui.theme - import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme 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/ui/theme/Type.kt index 4396052c1..7f04434a4 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/ui/theme/Type.kt @@ -10,7 +10,6 @@ */ package io.redlink.more.app.android.ui.theme - import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily 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 cebc20cdd..e94443d27 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,7 +16,6 @@ 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 @@ -42,26 +41,25 @@ class DataUploadWorker( EndpointRepository(sharedPreferences), credentialRepository ) private var stopped = false - private val observationDataRepository = - ObservationDataRepository(MoreApplication.shared!!.database) 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) - } - Napier.i { "No data points found, DataUploadWorker success" } - Result.success() - } ?: Result.failure() + 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() } catch (err: Exception) { Napier.e(throwable = err, message = "Exception in DataUploadWorker") if (isStopped) { @@ -85,7 +83,7 @@ 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/ScheduleUpdateWorker.kt b/androidApp/src/main/java/io/redlink/more/app/android/workers/ScheduleUpdateWorker.kt index 20608235c..f08e2f9e2 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 @@ -16,7 +16,6 @@ import androidx.work.WorkerParameters import io.github.aakira.napier.Napier 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 @@ -24,19 +23,20 @@ class ScheduleUpdateWorker(context: Context, workerParameters: WorkerParameters) CoroutineWorker(context, workerParameters) { private val shared: Shared - private val scheduleRepository: ScheduleRepository init { if (MoreApplication.shared == null) { MoreApplication.initShared(applicationContext) } shared = MoreApplication.shared!! - scheduleRepository = ScheduleRepository(shared.database) } override suspend fun doWork() = withContext(Dispatchers.IO) { Napier.i { "Running $WORKER_TAG! Updating Schedule..." } - scheduleRepository.updateTaskStatesSync(shared.observationFactory, shared.dataRecorder) + shared.repositories.schedule.updateTaskStatesSync( + shared.observationFactory, + shared.dataRecorder + ) 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..4a5bbcc4c 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,6 @@ 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 + Keine Internetverbindung + Bitte verbinden Sie das Gerät mit dem Internet und versuchen es erneut! \ 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 b70c0ca83..370e3d46b 100644 --- a/androidApp/src/main/res/values-de/login-strings.xml +++ b/androidApp/src/main/res/values-de/login-strings.xml @@ -8,6 +8,8 @@ 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 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/study_states_strings.xml b/androidApp/src/main/res/values-de/study_states_strings.xml index 1b286941e..737fe7ff2 100644 --- a/androidApp/src/main/res/values-de/study_states_strings.xml +++ b/androidApp/src/main/res/values-de/study_states_strings.xml @@ -4,6 +4,7 @@ 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 diff --git a/androidApp/src/main/res/values/alert-dialog-strings.xml b/androidApp/src/main/res/values/alert-dialog-strings.xml index 1909482df..900ba4da1 100644 --- a/androidApp/src/main/res/values/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values/alert-dialog-strings.xml @@ -6,4 +6,6 @@ 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 + No internet connection + Please connect to the internet and try again \ 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 c58200f5f..a35350d51 100644 --- a/androidApp/src/main/res/values/login-strings.xml +++ b/androidApp/src/main/res/values/login-strings.xml @@ -8,6 +8,8 @@ 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 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/study_states_strings.xml b/androidApp/src/main/res/values/study_states_strings.xml index c323e61f0..766178ab7 100644 --- a/androidApp/src/main/res/values/study_states_strings.xml +++ b/androidApp/src/main/res/values/study_states_strings.xml @@ -4,6 +4,7 @@ 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 diff --git a/build.gradle.kts b/build.gradle.kts index 4b5e3d4ec..41fe70ec9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,8 @@ plugins { id("org.jetbrains.kotlin.plugin.compose").version("2.2.10").apply(false) id("androidx.room").version("2.7.2").apply(false) id("com.google.devtools.ksp").version("2.2.10-2.0.2").apply(false) + + id("com.rickclephas.kmp.nativecoroutines").version("1.0.0-ALPHA-47").apply(false) } diff --git a/gradle.properties b/gradle.properties index dc2c082de..d5fa0bda3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,13 +1,10 @@ #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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 629b5b4e1..6f1202ba7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Sep 04 11:02:55 CEST 2025 +#Wed Sep 17 11:04:57 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index eedffcc5d..c75e19cd5 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -33,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 */; }; @@ -62,7 +69,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 */; }; - 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 */; }; @@ -88,7 +94,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 */; }; @@ -99,23 +104,6 @@ 1F8EA2D52A0CE5EE00F32602 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8EA2D42A0CE5EE00F32602 /* ModalView.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 */; }; @@ -126,7 +114,6 @@ 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 */; }; 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 */; }; @@ -165,19 +152,15 @@ 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 */; }; 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 */; }; ED6A7F3F29DC405B00E266EC /* FCMService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED6A7F3E29DC405B00E266EC /* FCMService.swift */; }; ED74FF4629DEC73600BAA1EF /* KotlinLongExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED74FF4529DEC73600BAA1EF /* KotlinLongExtension.swift */; }; @@ -194,7 +177,6 @@ EDCCC31229DAC3D6006D252F /* ObservationTimeDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDCCC31129DAC3D6006D252F /* ObservationTimeDetails.swift */; }; EDD1DEFF29F7B595009BC8FB /* ScheduleListHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD1DEFE29F7B595009BC8FB /* ScheduleListHeader.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 */ @@ -260,10 +242,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 = ""; }; @@ -289,8 +274,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 = ""; }; - 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 = ""; }; @@ -316,8 +299,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 = ""; }; @@ -329,40 +310,6 @@ 1F9283C02BC512E500D459A7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; 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 = ""; }; @@ -373,8 +320,6 @@ 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 = ""; }; 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 = ""; }; @@ -414,20 +359,15 @@ 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 = ""; }; 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 = ""; }; 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 = ""; }; ED474B4B29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQuestionThankYouView.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 = ""; }; @@ -444,7 +384,6 @@ EDCCC31129DAC3D6006D252F /* ObservationTimeDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationTimeDetails.swift; sourceTree = ""; }; EDD1DEFE29F7B595009BC8FB /* ScheduleListHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleListHeader.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 */ @@ -461,6 +400,10 @@ 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 */, EDACF62729D2F31D0032327B /* PolarBleSdk in Frameworks */, @@ -496,6 +439,14 @@ path = Bluetooth; sourceTree = ""; }; + 1F1E45CF2E7842E300C82016 /* Registration */ = { + isa = PBXGroup; + children = ( + 1F1E45D02E7842F400C82016 /* RegistrationObservable.swift */, + ); + path = Registration; + sourceTree = ""; + }; 1F27536229CC39CA00324417 /* Services */ = { isa = PBXGroup; children = ( @@ -525,6 +476,7 @@ 1F2C4BDC2A6F976F00C29888 /* StudyUpdateView.swift */, 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */, 1F750CAE2A6FA8AE006E455E /* StudyClosedView.swift */, + 1F29C68B2E7A78CA003693C5 /* StudyLoadingView.swift */, ); path = StudyStates; sourceTree = ""; @@ -596,7 +548,6 @@ 1F13BA422993951200938C1E /* ConsentList.swift */, 1F13BA44299396B200938C1E /* ConsentListHeader.swift */, 1F13BA462993972E00938C1E /* ConsentListItem.swift */, - B79DBA3629D312F200A1F547 /* ErrorLogin.swift */, 1F60C58529951A5F00858581 /* ErrorText.swift */, B79DBA3829D3130600A1F547 /* ExpandableInput.swift */, B79DBA3A29D3135E00A1F547 /* ForwardButton.swift */, @@ -606,7 +557,6 @@ B72C645E29DC812900F5A7C6 /* MoreTextFieldHL.swift */, B72C645F29DC812900F5A7C6 /* MoreTextFieldSmBottom.swift */, B79DBA4529D3141400A1F547 /* NavigationLinkButton.swift */, - B79DBA3229D2E7F000A1F547 /* NavigationLinkButton.swift */, 1FDC264929C1CFE80011D8A4 /* NavigationText.swift */, 07BC54B129CB47C400459267 /* DetailsTitle.swift */, B784168F29C8B1DA0035D830 /* InlineAbortButton.swift */, @@ -619,7 +569,6 @@ 1F8847CD299155F50023EF10 /* SectionHeading.swift */, 1F8847B929914C8D0023EF10 /* Title.swift */, B784169129C9E3F50035D830 /* Title2.swift */, - B79DBA4829D3144400A1F547 /* UIToggleButtonView.swift */, 1F8847D1299158880023EF10 /* UIToggleFoldViewButton.swift */, B74DDB6929E6AEDE006FEA74 /* NotificationItem.swift */, 07FD293B29D57F0300853108 /* ModuleListItem.swift */, @@ -656,26 +605,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 = ""; @@ -732,6 +662,7 @@ 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( + 1F1E45CF2E7842E300C82016 /* Registration */, 1F8937892BFF1EB20083D20E /* ObservationErrors */, 1F2C4BDB2A6F974900C29888 /* StudyStates */, 1F8EA2CB2A0BDF8D00F32602 /* LimeSurvey */, @@ -835,10 +766,10 @@ 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 */, @@ -981,10 +912,7 @@ isa = PBXGroup; children = ( B79DBA4F29D342FE00A1F547 /* DashboardFilter */, - ED16C5E829B7309F00DA8AFE /* StudyTitleForwardButton.swift */, EDEBA13429B0D30200533F79 /* DashboardView.swift */, - EDEBA13229B0D2EE00533F79 /* DashboardViewModel.swift */, - ED16C5EA29B7380300DA8AFE /* DashboardPicker.swift */, ); path = Dashboard; sourceTree = ""; @@ -1039,6 +967,10 @@ EDACF62629D2F31D0032327B /* PolarBleSdk */, 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */, 1FBD513B2BBC3E8A0029D185 /* FirebaseCrashlytics */, + 1F1E45D32E7848D800C82016 /* KMPNativeCoroutinesAsync */, + 1F1E45D52E7848D800C82016 /* KMPNativeCoroutinesCombine */, + 1F1E45D72E7848D800C82016 /* KMPNativeCoroutinesCore */, + 1F1E45D92E7848D800C82016 /* KMPNativeCoroutinesRxSwift */, ); productName = iosApp; productReference = 7555FF7B242A565900829871 /* More.app */; @@ -1075,6 +1007,7 @@ packageReferences = ( EDACF62529D2F31D0032327B /* XCRemoteSwiftPackageReference "polar-ble-sdk" */, 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -1098,33 +1031,14 @@ 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 */, 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets 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; }; @@ -1188,6 +1102,7 @@ 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 */, @@ -1226,7 +1141,6 @@ 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 */, @@ -1237,13 +1151,13 @@ B784169229C9E3F50035D830 /* Title2.swift in Sources */, 1F8847BA29914C8D0023EF10 /* Title.swift in Sources */, 1F8937882BFF0DAB0083D20E /* ObservationErrorListView.swift in Sources */, + 1F1E45D12E7842F400C82016 /* RegistrationObservable.swift in Sources */, 1F9C3E8E298AAC1A00B9AC82 /* LoginView.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 */, B72C646129DC812900F5A7C6 /* MoreTextFieldSmBottom.swift in Sources */, @@ -1262,7 +1176,6 @@ B784169829CA08850035D830 /* CircleActivityIndicator.swift in Sources */, B748DF3C29CC6B380026C348 /* ObservatinoDetailsData.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 */, @@ -1275,7 +1188,6 @@ 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 */, 07F67D8A29EECF1A006DCFB5 /* BasicNavLinkButton.swift in Sources */, B79DBA3B29D3135E00A1F547 /* ForwardButton.swift in Sources */, @@ -1295,7 +1207,6 @@ 1F8847E0299160400023EF10 /* MoreTextFieldStyle.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 */, @@ -1356,189 +1267,6 @@ }; /* 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; @@ -1675,12 +1403,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; 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; @@ -1734,10 +1464,12 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; 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; }; @@ -1864,6 +1596,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/rickclephas/KMP-NativeCoroutines.git"; + requirement = { + branch = master; + kind = branch; + }; + }; 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; @@ -1883,6 +1623,26 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 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 = 1F1E45D22E7848D800C82016 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */; + productName = KMPNativeCoroutinesRxSwift; + }; 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; package = 1FBD51382BBC3D410029D185 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index dd8ea5984..2806a965d 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "327c4396f40ddf59fbe2bb60e912e4fe804220437390a45dc9155b80b2ed8e73", + "originHash" : "430e2595b142619f759799345883dfdc8c641d1198a191acc715dc9ff38e23db", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -91,6 +91,15 @@ "version" : "101.0.0" } }, + { + "identity" : "kmp-nativecoroutines", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rickclephas/KMP-NativeCoroutines.git", + "state" : { + "branch" : "master", + "revision" : "e4b66be72f3e904b8fa096ac255486c81bacce5f" + } + }, { "identity" : "leveldb", "kind" : "remoteSourceControl", diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index f405f81d3..93a98e47b 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -25,19 +25,20 @@ class AppDelegate: NSObject, UIApplicationDelegate { static let appGroup = "group.ac.at.lbg.dhp.more.group" static let appGroupUserDefaults = UserDefaults(suiteName: appGroup) static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) - static let navigationScreenHandler = NavigationModalState() + static let repositories = MainRepository(appDatabase: database) + static let navigationScreenHandler = NavigationModalState(repos: repositories) static let polarConnector = PolarConnector() static let dataUploadManager = DataUploadManager() static let shared: Shared = { - let dataManager = iOSObservationDataManager(database: database) + let dataManager = iOSObservationDataManager(repository: repositories) return Shared( localNotificationListener: LocalPushNotifications(), - database: database, + repositories: repositories, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: dataManager, mainBluetoothConnector: polarConnector, - observationFactory: IOSObservationFactory(database: database, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager), dataRecorder: IOSDataRecorder() ) }() diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 1bd4e4626..266db94f9 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() { 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,52 @@ struct ContentView: View { } } +struct CredentialsView: View { + @ObservedObject var navigationModalState: NavigationModalState + @ObservedObject var viewModel: ContentViewModel + var body: some View { + VStack { + 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() + .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 3af3807ce..bb971b169 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -16,109 +16,86 @@ import Foundation import shared import BackgroundTasks +import KMPNativeCoroutinesCombine +import Combine 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 } + .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 + } + .store(in: &cancellables) + + + + createPublisher(for: ViewManager.shared.studyIsUpdating) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in}) { updating in + AppDelegate.navigationScreenHandler.studyIsUpdating(updating.boolValue) + } + .store(in: &cancellables) + + createPublisher(for: ViewManager.shared.showBluetoothView) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] show in + self?.showBleView = show.boolValue + } + .store(in: &cancellables) + + createPublisher(for: AlertController.shared.alertDialogModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in }) { [weak self] alertDialogModel in + self?.alertDialogModel = alertDialogModel + } + .store(in: &cancellables) + + AppDelegate.shared.unreadNotificationCountAsClosure { [weak self] kInt in self?.unreadNotificationCount = kInt.intValue } } - func showLoginView() { - DispatchQueue.main.async { - self.registrationService.reset() - self.loginViewScreenNr = 0 - self.hasCredentials = false - } - } - - func showConsentView() { - DispatchQueue.main.async { - self.loginViewScreenNr = 1 - self.consentViewModel.onAppear() - } - } - - func getTaskDetailsVM(navigationState: NavigationState) -> TaskDetailsViewModel { - if let scheduleId = navigationState.scheduleId { - taskDetailsVM.setSchedule(scheduleId: scheduleId) - } - return taskDetailsVM - } - func getSimpleQuestionObservationVM(navigationState: NavigationState) -> SimpleQuestionObservationViewModel { simpleQuestionVM.setScheduleId(navigationState: navigationState) return simpleQuestionVM @@ -131,51 +108,12 @@ class ContentViewModel: ObservableObject { 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() - } - - func credentialsDeleted() { - DispatchQueue.main.async { [weak self] in - if let self { - self.hasCredentials = false - } - } - showLoginView() - } -} diff --git a/iosApp/iosApp/Extensions/DictionaryExtension.swift b/iosApp/iosApp/Extensions/DictionaryExtension.swift index 58c7802aa..7b86847a7 100644 --- a/iosApp/iosApp/Extensions/DictionaryExtension.swift +++ b/iosApp/iosApp/Extensions/DictionaryExtension.swift @@ -25,6 +25,21 @@ 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 self.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 self.reduce(into: [Key: NewValue]()) { result, element in + let newValue = try transform(element.value) + result[element.key] = newValue + } + } + } extension Dictionary where Value == Set { @@ -36,3 +51,4 @@ extension Dictionary where Value == Set { return resultSet } } + diff --git a/iosApp/iosApp/Extensions/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index 20824c170..7024c5d31 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -99,8 +99,8 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { } extension NavigationScreen { - func localize(useTable table: String, withComment comment: String) -> String { - return values.screenName.localize(withComment: comment, useTable: table) + func localize() -> String { + return values.screenName } func generateURL(withParameters params: [NavigationParameter: String]) -> URL? { diff --git a/iosApp/iosApp/Extensions/StringExtension.swift b/iosApp/iosApp/Extensions/StringExtension.swift index 4ab7f702c..fa734dce5 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() diff --git a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift index 4f771b7cb..6da0be2f1 100644 --- a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift @@ -27,9 +27,9 @@ class AccelerometerBackgroundObservation: Observation_ { private let semaphore = Semaphore() private let observationRepository: ObservationRepository - init(database: AppDatabase, sensorPermissions: Set) { - observationRepository = ObservationRepository(appDatabase: database) - super.init(database: database, observationType: AccelerometerType(sensorPermissions: sensorPermissions)) + init(repos: MainRepository, sensorPermissions: Set) { + self.observationRepository = repos.observation + super.init(repos: repos, observationType: AccelerometerType(sensorPermissions: sensorPermissions)) } override func start() -> Bool { diff --git a/iosApp/iosApp/Observations/AccelerometerObservation.swift b/iosApp/iosApp/Observations/AccelerometerObservation.swift index ce74b7a72..d4c75836e 100644 --- a/iosApp/iosApp/Observations/AccelerometerObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerObservation.swift @@ -23,8 +23,8 @@ class AccelerometerObservation: Observation_ { private var timer: Timer? = nil - init(database: AppDatabase, sensorPermission: Set) { - super.init(database: database, observationType: AccelerometerType(sensorPermissions: sensorPermission)) + init(repos: MainRepository, sensorPermission: Set) { + super.init(repos: repos, observationType: AccelerometerType(sensorPermissions: sensorPermission)) } override func start() -> Bool { diff --git a/iosApp/iosApp/Observations/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index 1a4324944..d15db0bae 100644 --- a/iosApp/iosApp/Observations/GPSObservation.swift +++ b/iosApp/iosApp/Observations/GPSObservation.swift @@ -23,8 +23,8 @@ class GPSObservation: Observation_ { public var currentLocation = CLLocation() private var running = false - init(database: AppDatabase, sensorPermissions: Set) { - super.init(database: database, observationType: GPSType(sensorPermissions: sensorPermissions)) + init(repos: MainRepository, sensorPermissions: Set) { + super.init(repos: repos, observationType: GPSType(sensorPermissions: sensorPermissions)) manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyBest } diff --git a/iosApp/iosApp/Observations/IOSObservationFactory.swift b/iosApp/iosApp/Observations/IOSObservationFactory.swift index b7a6daffb..3f89f301d 100644 --- a/iosApp/iosApp/Observations/IOSObservationFactory.swift +++ b/iosApp/iosApp/Observations/IOSObservationFactory.swift @@ -17,10 +17,10 @@ import Foundation import shared class IOSObservationFactory: ObservationFactory { - override init(database: AppDatabase ,dataManager: ObservationDataManager) { - super.init(database: database, dataManager: dataManager) - observations.add(GPSObservation(database: database, sensorPermissions: ["gpsAlways"])) - observations.add(AccelerometerBackgroundObservation(database: database, sensorPermissions: ["cmsensorrecorder"])) - observations.add(PolarVerityHeartRateObservation(database: database, sensorPermissions: ["bluetoothAlways"])) + override init(repository: MainRepository,dataManager: ObservationDataManager) { + super.init(repository: repository, dataManager: dataManager) + observations.add(GPSObservation(repos: repository, sensorPermissions: ["gpsAlways"])) + observations.add(AccelerometerBackgroundObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"])) + observations.add(PolarVerityHeartRateObservation(repos: repository, sensorPermissions: ["bluetoothAlways"])) } } diff --git a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift index 21600b5bd..2e9732761 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -19,6 +19,8 @@ import PolarBleSdk import RxSwift import shared import UIKit +import Combine +import KMPNativeCoroutinesCombine class PolarVerityHeartRateObservation: Observation_ { static var hrReady = false @@ -42,17 +44,17 @@ class PolarVerityHeartRateObservation: Observation_ { private let deviceManager = BluetoothDeviceManager.shared - private var deviceListener: Ktor_ioCloseable? + private var deviceListener: AnyCancellable? private let errorStringTable = "Errors" - init(database: AppDatabase, sensorPermissions: Set) { - super.init(database: database, observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) + init(repos: MainRepository, sensorPermissions: Set) { + super.init(repos: repos, observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) } override func start() -> Bool { if observerAccessible() { - let acceptableDevices = deviceManager.connectedDevicesAsValue().deviceWithNameIn(nameSet: deviceIdentificer) + let acceptableDevices = deviceManager.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,31 +65,31 @@ 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)) + 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!", fallbackTitle: "Observation Error") 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)) + showObservationErrorNotification(notificationBody: "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!", fallbackTitle: "Observation Error") 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 + let state = AppDelegate.shared.bluetoothController.bluetoothPowerValue if CBManager.authorization != .allowedAlways { errors.insert("Access to Bluetooth not granted") PermissionManager.openSensorPermissionDialog() } - if state == nil || state == BluetoothState.off { + if state == BluetoothState.off { errors.insert("Bluetooth is not enabled") } if !AppDelegate.shared.bluetoothController.observerDeviceAccessible(bleDevices: deviceIdentificer) { @@ -112,11 +114,14 @@ class PolarVerityHeartRateObservation: Observation_ { } 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() - } - } + deviceListener = createPublisher(for: deviceManager.connectedDevices) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in}, receiveValue: { [weak self] devices in + if let self, !self.deviceIdentificer.anyNameIn(items: devices) { + PolarVerityHeartRateObservation.setHRFeature(state: false) + self.deviceListener?.cancel() + } + }) } } diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings new file mode 100644 index 000000000..b4680e715 --- /dev/null +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -0,0 +1,2605 @@ +{ + "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" + } + } + } + }, + "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" + } + } + } + }, + "Button to start a limesurvey" : { + "comment" : "A button label that can be translated.", + "isCommentAutoGenerated" : true + }, + "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" + } + } + } + }, + "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_url" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL der Studie eigeben" + } + }, + "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" : "Nur wichtige" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Only 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_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" + } + } + } + }, + "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 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" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "second_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!" + } + } + } + }, + "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_update_title" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "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" : "translated", + "value" : "Nicht gelesene" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not read" + } + } + } + }, + "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/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 4c4e10d94..000000000 --- a/iosApp/iosApp/Resources/Strings/de.lproj/LoginView.strings +++ /dev/null @@ -1,31 +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" = "QR Code Scannen"; -"scan_start_automatic" = "QR Code wird automatisch gescannt."; -"camera_to_scan_qr_code" = "Öffnen Sie die Kamera um den QR code zu scannen"; - -"permission_needed" = "Kamerazugriff erforderlich"; -"open_settings" = "Einstellungen öffnen"; -"cancel" = "Abbrechen"; -"provide_camera_access" = "Um den QR Code zu scannen benötigen wir Zugriff zu deiner Kamera."; 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 800342607..000000000 --- a/iosApp/iosApp/Resources/Strings/en.lproj/LoginView.strings +++ /dev/null @@ -1,31 +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"; -"scan_start_automatic" = "QR Code will be scanned automatically."; -"camera_to_scan_qr_code" = "Open camera to scan a QR Code"; - -"permission_needed" = "Permission needed"; -"open_settings" = "Open settings"; -"cancel" = "Cancel"; -"provide_camera_access" = "Camera permissions needed to scan 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/Views/Bluetooth/BluetoothConnectionView.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift index 8255bf6bf..81bb855f3 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift @@ -17,11 +17,10 @@ import SwiftUI import shared 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,8 +40,8 @@ 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 @@ -51,15 +50,15 @@ struct BluetoothConnectionView: View { .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))) { + 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 { @@ -92,7 +91,7 @@ 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 +101,7 @@ struct BluetoothConnectionView: View { if let address = device.address, viewModel.connectingDevices.contains(address) { Spacer() ProgressView() + .tint(.more.primary) } } Divider() @@ -117,13 +117,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 +140,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 7fe0a1205..cde8fc225 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift @@ -15,6 +15,9 @@ import Foundation import shared +import Combine +import KMPNativeCoroutinesCombine +import Dispatch class BluetoothConnectionViewModel: ObservableObject { private let coreViewModel: CoreBluetoothViewModel = CoreBluetoothViewModel(observationFactory: AppDelegate.shared.observationFactory, coreBluetooth: AppDelegate.shared.bluetoothController) @@ -29,63 +32,69 @@ class BluetoothConnectionViewModel: ObservableObject { @Published var neededDevices: [String] = [] @Published var bluetoothPower: BluetoothState = .off + + 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: deviceManager.connectedDevices) + .map { devices in + return Array(devices) + .compactMap { device -> BluetoothDeviceEntity? in + guard let name = device.deviceName, !name.isEmpty else { return nil } + return device + } + .sorted { ($0.deviceName ?? "") < ($1.deviceName ?? "") } } - } - - deviceManager.devicesCurrentlyConnectingAsClosure { [weak self] devices in - if let self { - DispatchQueue.main.async { - self.connectingDevices = devices.compactMap { $0.address } - } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] devices in + self?.connectedDevices = devices } - } - - 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 - } - }) + .store(in: &cancellables) + + createPublisher(for: deviceManager.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) + + createPublisher(for: deviceManager.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 } } - } - - coreViewModel.coreBluetooth.isScanningAsClosure { [weak self] kBool in - self?.bluetoothIsScanning = kBool.boolValue - } - - coreViewModel.coreBluetooth.bluetoothStateAsClosure { [weak self] bluetoothState in - self?.bluetoothPower = bluetoothState - } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] devices in + self?.discoveredDevices = devices + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.coreBluetooth.isScanning) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in}) { [weak self] scanning in + self?.bluetoothIsScanning = scanning.boolValue + } + .store(in: &cancellables) + + createPublisher(for: coreViewModel.coreBluetooth.bluetoothPower) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in}) { [weak self] power in + self?.bluetoothPower = power + } + .store(in: &cancellables) } func viewDidAppear() { diff --git a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift index 4e0de3511..97bf4a80f 100644 --- a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift +++ b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift @@ -26,6 +26,6 @@ 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) } } diff --git a/iosApp/iosApp/Views/Components/AppVersion.swift b/iosApp/iosApp/Views/Components/AppVersion.swift index af6580c2b..83bd063e4 100644 --- a/iosApp/iosApp/Views/Components/AppVersion.swift +++ b/iosApp/iosApp/Views/Components/AppVersion.swift @@ -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/BasicText.swift b/iosApp/iosApp/Views/Components/BasicText.swift index 3e2aa2fc6..76da8a4f6 100644 --- a/iosApp/iosApp/Views/Components/BasicText.swift +++ b/iosApp/iosApp/Views/Components/BasicText.swift @@ -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/DatapointsCollection.swift b/iosApp/iosApp/Views/Components/DatapointsCollection.swift index 5490ade8c..9dbc91111 100644 --- a/iosApp/iosApp/Views/Components/DatapointsCollection.swift +++ b/iosApp/iosApp/Views/Components/DatapointsCollection.swift @@ -18,13 +18,12 @@ 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) diff --git a/iosApp/iosApp/Views/Components/DetailsTitle.swift b/iosApp/iosApp/Views/Components/DetailsTitle.swift index e54794f35..c52ed148f 100644 --- a/iosApp/iosApp/Views/Components/DetailsTitle.swift +++ b/iosApp/iosApp/Views/Components/DetailsTitle.swift @@ -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/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..9ee65c845 100644 --- a/iosApp/iosApp/Views/Components/ErrorText.swift +++ b/iosApp/iosApp/Views/Components/ErrorText.swift @@ -18,7 +18,7 @@ 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/ExpandableText.swift b/iosApp/iosApp/Views/Components/ExpandableText.swift index a03251d21..f70923c4b 100644 --- a/iosApp/iosApp/Views/Components/ExpandableText.swift +++ b/iosApp/iosApp/Views/Components/ExpandableText.swift @@ -86,7 +86,7 @@ struct ExpandableText: View { 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)) } diff --git a/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift b/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift index b5bc0a111..aced08ff1 100644 --- a/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift +++ b/iosApp/iosApp/Views/Components/Filters/MoreFilterOption.swift @@ -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..c555e77aa 100644 --- a/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift +++ b/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift @@ -12,7 +12,7 @@ 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/InactiveText.swift b/iosApp/iosApp/Views/Components/InactiveText.swift index d798369e1..959fba901 100644 --- a/iosApp/iosApp/Views/Components/InactiveText.swift +++ b/iosApp/iosApp/Views/Components/InactiveText.swift @@ -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 5f1ffef2b..420d4e55f 100644 --- a/iosApp/iosApp/Views/Components/InlineAbortButton.swift +++ b/iosApp/iosApp/Views/Components/InlineAbortButton.swift @@ -25,9 +25,8 @@ struct InlineAbortButton: View { 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) diff --git a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift index a09d9cf5c..1552bab4d 100644 --- a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift +++ b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift @@ -10,15 +10,14 @@ import shared import SwiftUI struct MoreAlertDialog: View { - var alertDialogModel: AlertDialogModel - - private let stringTable = "AlertDialog" + let alertDialogModel: AlertDialogModel + 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(LocalizedStringKey(alertDialogModel.title)) .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(LocalizedStringKey(alertDialogModel.message)) .foregroundColor(.more.primary) .font(.subheadline) .multilineTextAlignment(.leading) @@ -40,20 +39,24 @@ struct MoreAlertDialog: View { VStack { MoreActionButton(disabled: .constant(false)) { - alertDialogModel.onPositive() + if let onPositive = alertDialogModel.onPositive { + onPositive() + } } label: { - Text(String.localize(forKey: alertDialogModel.positiveTitle, withComment: "positive button", inTable: stringTable)) + Text(LocalizedStringKey(alertDialogModel.positiveTitle)) } if let negativeTitle = alertDialogModel.negativeTitle { MoreActionButton(backgroundColor: .more.secondaryLight, disabled: .constant(false)) { - alertDialogModel.onNegative() + if let onNegative = alertDialogModel.onNegative { + onNegative() + } } label: { if #available(iOS 17.0, *) { - Text(String.localize(forKey: negativeTitle, withComment: "negative button", inTable: stringTable)) + Text(LocalizedStringKey(negativeTitle)) .foregroundStyle(Color.more.primary) } else { - Text(String.localize(forKey: negativeTitle, withComment: "negative button", inTable: stringTable)) + Text(LocalizedStringKey(negativeTitle)) .foregroundColor(.more.primary) } } diff --git a/iosApp/iosApp/Views/Components/NavigationText.swift b/iosApp/iosApp/Views/Components/NavigationText.swift index 9c4c033fa..3d173198d 100644 --- a/iosApp/iosApp/Views/Components/NavigationText.swift +++ b/iosApp/iosApp/Views/Components/NavigationText.swift @@ -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/ObservatinoDetailsData.swift b/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift index 19601ec10..6b009a52b 100644 --- a/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift +++ b/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift @@ -20,8 +20,6 @@ struct ObservationDetailsData: View { var dateRange: String var timeframe: String - private let stringTable = "TaskDetail" - var body: some View { VStack { @@ -34,7 +32,7 @@ struct ObservationDetailsData: View { HStack { Image(systemName: "clock.fill") .padding(0.7) - Text(String.localize(forKey: "Timeframe", withComment: "Timeframe of observation", inTable: stringTable)) + Text("Timeframe") .foregroundColor(.more.primary) BasicText(text: timeframe, color: .more.secondary) diff --git a/iosApp/iosApp/Views/Components/ScheduleListHeader.swift b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift index 14eb446e6..8b3e63339 100644 --- a/iosApp/iosApp/Views/Components/ScheduleListHeader.swift +++ b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift @@ -25,7 +25,7 @@ struct ScheduleListHeader: View { 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 { MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { @@ -34,7 +34,8 @@ struct ScheduleListHeader: View { HStack { Image(systemName: "exclamationmark.triangle") .padding(.trailing, 2) - Text("\(scheduleViewModel.numberOfObservationErrors()) \("errors".localize(withComment: "Errors", useTable: "Errors"))") + Text(verbatim: String(scheduleViewModel.numberOfObservationErrors())) + Text("Error") } } .padding(.bottom) @@ -44,3 +45,4 @@ struct ScheduleListHeader: View { } } } + diff --git a/iosApp/iosApp/Views/Components/SectionHeading.swift b/iosApp/iosApp/Views/Components/SectionHeading.swift index 7fcb84651..6e6369832 100644 --- a/iosApp/iosApp/Views/Components/SectionHeading.swift +++ b/iosApp/iosApp/Views/Components/SectionHeading.swift @@ -22,11 +22,11 @@ struct SectionHeading: View { 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..004454350 100644 --- a/iosApp/iosApp/Views/Components/Title.swift +++ b/iosApp/iosApp/Views/Components/Title.swift @@ -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..6bc94db6f 100644 --- a/iosApp/iosApp/Views/Components/Title2.swift +++ b/iosApp/iosApp/Views/Components/Title2.swift @@ -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/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/WebView/WebView.swift b/iosApp/iosApp/Views/Components/WebView/WebView.swift index 391c8bf91..7d92e351e 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebView.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebView.swift @@ -24,6 +24,7 @@ struct WebView: View { VStack { if viewModel.progress < 1 { ProgressView(value: viewModel.progress, total: 1) + .tint(.more.primary) } SwiftUIWebView(viewModel: viewModel, url: url) .refreshable { diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 7e933cf6c..3563606fa 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -17,83 +17,72 @@ import shared import SwiftUI struct ConsentView: View { - @StateObject var viewModel: ConsentViewModel - - private let stringsTable = "ConsentView" - private let taskStringTable = "TaskDetail" + @StateObject private var viewModel: ConsentViewModel + @ObservedObject private var registration: RegistrationObservable + + init(registration: RegistrationObservable) { + _registration = ObservedObject(wrappedValue: registration) + _viewModel = StateObject(wrappedValue: ConsentViewModel(registrationService: registration.service)) + } + var body: some View { - VStack { - Title2(titleText: viewModel.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) + if let permissionModel = viewModel.permissionModel { + VStack { + Title2(titleText: permissionModel.studyTitle) + .padding(.bottom, 30) - 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) - } - - }), - secondaryButton: .cancel({ viewModel.decline() }) - ) + ScrollView { + ExpandableText(permissionModel.studyParticipantInfo, "Participant Information", lineLimit: 4) + .padding(.bottom, 35) + + 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) { + 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)) { + 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 let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + static let repos = MainRepository(appDatabase: database) static var previews: some View { - ConsentView(viewModel: ConsentViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(database: database, dataManager: ObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) + ConsentView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(repository: repos, dataManager: ObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder())))) } } diff --git a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index 349515123..73a7a867e 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -16,57 +16,41 @@ import shared import UIKit import AVFoundation +import KMPNativeCoroutinesCombine +import Combine -protocol ConsentViewModelListener { - func credentialsStored() - func decline() - func credentialsDeleted() -} - -class ConsentViewModel: NSObject, ObservableObject { - private let coreModel: CorePermissionViewModel +class ConsentViewModel: ObservableObject { + private let coreModel: CoreConsentViewModel + private let registration: RegistrationService var consentInfo: String? = nil - var delegate: ConsentViewModelListener? = nil 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 + self.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 } - } - coreModel.onLoadingChange { loading in - if let loading = loading as? Bool { - DispatchQueue.main.async { - self.isLoading = loading - } - } - } + .store(in: &cancellables) } func onAppear() { - coreModel.viewDidAppear() permissionManager.observer = self } func onDisappear() { - coreModel.viewDidDisappear() permissionManager.observer = nil } @@ -79,61 +63,37 @@ class ConsentViewModel: NSObject, ObservableObject { self.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 { + self.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(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: { + self.acceptConsent() + self.requestedPermissions = false + AlertController.shared.closeAlertDialog() + })) + } 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..5f5492fcc 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift @@ -19,7 +19,7 @@ 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() @@ -41,7 +41,7 @@ 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() @@ -49,7 +49,7 @@ struct DashboardFilterView: View { 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() } } @@ -76,6 +76,6 @@ struct DashboardFilterView: View { } 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 633bd9b68..cf81b381e 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift @@ -14,8 +14,7 @@ protocol DashboardFilterObserver { } class DashboardFilterViewModel: ObservableObject { - let coreViewModel: CoreDashboardFilterViewModel = CoreDashboardFilterViewModel(database: AppDelegate.database) - private let stringTable = "DashboardFilter" + let coreViewModel: CoreDashboardFilterViewModel = CoreDashboardFilterViewModel(repository: AppDelegate.shared.repositories) var delegate: DashboardFilterObserver? = nil @@ -63,7 +62,7 @@ class DashboardFilterViewModel: ObservableObject { 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..de91ddb84 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardView.swift @@ -18,7 +18,7 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject private var navigationModalState: NavigationModalState - @StateObject var viewModel: DashboardViewModel + @StateObject var viewModel: ScheduleViewModel private let stringTable = "DashboardView" @State var totalTasks: Double = 0 @State var selection: Int = 0 @@ -26,27 +26,21 @@ struct DashboardView: View { 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) - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear { - viewModel.viewDidDisappear() - } + .customNavigationTitle(with: NavigationScreen.dashboard.localize(), displayMode: .inline) } } 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 6858c759f..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(database: AppDelegate.database) - let scheduleViewModel: ScheduleViewModel - - @Published var studyTitle: String = "" - @Published var study: StudyEntity? - @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/Info/InfoList.swift b/iosApp/iosApp/Views/Info/InfoList.swift index 4f522a2a4..a7bcc8492 100644 --- a/iosApp/iosApp/Views/Info/InfoList.swift +++ b/iosApp/iosApp/Views/Info/InfoList.swift @@ -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/InfoView.swift b/iosApp/iosApp/Views/Info/InfoView.swift index 1c0ec7eac..39973112d 100644 --- a/iosApp/iosApp/Views/Info/InfoView.swift +++ b/iosApp/iosApp/Views/Info/InfoView.swift @@ -39,14 +39,14 @@ struct InfoView: View { 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, @@ -57,13 +57,7 @@ struct InfoView: View { AppVersion() } .padding(.horizontal, 10) - .customNavigationTitle(with: NavigationScreen.info.localize(useTable: navigationStrings, withComment: "Information Title")) - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear { - viewModel.viewDidDisappear() - } + .customNavigationTitle(with: NavigationScreen.info.localize()) } } diff --git a/iosApp/iosApp/Views/Info/InfoViewModel.swift b/iosApp/iosApp/Views/Info/InfoViewModel.swift index 0c275a8ab..dcce4a800 100644 --- a/iosApp/iosApp/Views/Info/InfoViewModel.swift +++ b/iosApp/iosApp/Views/Info/InfoViewModel.swift @@ -14,10 +14,12 @@ // import shared +import Combine +import KMPNativeCoroutinesCombine class InfoViewModel: ObservableObject { - private let studyCoreModel = CoreStudyDetailsViewModel(appDatabase: AppDelegate.database) + private let studyCoreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) @Published var studyTitle: String? @Published var contactInstitute: String? @Published var contactPerson: String? @@ -26,26 +28,22 @@ class InfoViewModel: ObservableObject { @Published var participantId: Int? @Published var participantAlias: String? + private var cancellables = Set() + init() { - studyCoreModel.onLoadStudyDetails() { - studyDetails in - if let studyDetails { - self.studyTitle = studyDetails.study.studyTitle - self.contactInstitute = studyDetails.study.contactInstitute - self.contactPerson = studyDetails.study.contactPerson - self.contactEmail = studyDetails.study.contactEmail - self.contactPhoneNumber = studyDetails.study.contactPhoneNumber - self.participantId = studyDetails.study.participantId?.intValue - self.participantAlias = studyDetails.study.participantAlias + 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 + self.contactEmail = studyDetails.study.contactEmail + self.contactPhoneNumber = studyDetails.study.contactPhoneNumber + self.participantId = studyDetails.study.participantId?.intValue + 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..594764944 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift @@ -16,12 +16,7 @@ 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 @@ -46,12 +41,12 @@ struct LeaveStudyConfirmationView: View { .foregroundColor(Color.more.important) .padding() - Text(String.localize(forKey: "second_message", withComment: "second exit message", inTable: stringTable)) + Text("second_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) @@ -67,7 +62,7 @@ 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) @@ -75,20 +70,13 @@ struct LeaveStudyConfirmationView: View { 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..128929d7b 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift @@ -16,12 +16,10 @@ import SwiftUI struct LeaveStudyView: View { - @StateObject var viewModel: SettingsViewModel + @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() @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 @@ -40,14 +38,14 @@ struct LeaveStudyView: View { .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) @@ -57,7 +55,7 @@ struct LeaveStudyView: View { ) { 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) @@ -67,7 +65,7 @@ struct LeaveStudyView: View { ) { 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) } @@ -76,14 +74,8 @@ struct LeaveStudyView: View { .padding(.horizontal, 40) } .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .withdrawStudyConfirm)) { - LeaveStudyConfirmationView(viewModel: contentViewModel.settingsViewModel) - } - .customNavigationTitle(with: NavigationScreen.withdrawStudy.localize(useTable: navigationStrings, withComment: "Withdraw from Study")) - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear{ - viewModel.viewDidDisappear() + LeaveStudyConfirmationView(viewModel: viewModel) } + .customNavigationTitle(with: NavigationScreen.withdrawStudy.localize()) } } diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift index 9c27d7a80..0c8944085 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift @@ -32,7 +32,7 @@ struct LimeSurveyView: View { } } } - .customNavigationTitle(with: NavigationScreen.limeSurvey.localize(useTable: stringsTable, withComment: "LimeSurvey View"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.limeSurvey.localize(), displayMode: .inline) .toolbar { if viewModel.wasAnswered { Button { diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift index 1aa1616c0..b3b2a473d 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift @@ -18,7 +18,7 @@ import shared import WebKit class LimeSurveyViewModel: ObservableObject { - private let coreViewModel = CoreLimeSurveyViewModel(database: AppDelegate.database, observationFactory: AppDelegate.shared.observationFactory) + private let coreViewModel = CoreLimeSurveyViewModel(repositories: AppDelegate.shared.repositories, observationFactory: AppDelegate.shared.observationFactory) let webViewModel = WebViewViewModel() diff --git a/iosApp/iosApp/Views/Login/LoginButton.swift b/iosApp/iosApp/Views/Login/LoginButton.swift index 1a4ed0d27..5895e867f 100644 --- a/iosApp/iosApp/Views/Login/LoginButton.swift +++ b/iosApp/iosApp/Views/Login/LoginButton.swift @@ -17,15 +17,14 @@ import SwiftUI import shared 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") } } } @@ -33,7 +32,8 @@ struct LoginButton: View { 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(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(database: database, dataManager: ObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) + LoginButton(disabled: .constant(false)) { + print("Hello World") + } } } diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index f44dd3f19..91f86a3d3 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.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,19 @@ import shared import SwiftUI 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 { @@ -33,27 +38,27 @@ 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)), + headerText: "participation_key_entry", + inputPlaceholder: .constant("participation_key_entry"), input: $model.token, capitalization: .uppercase, autoCorrectDisabled: true, textType: .oneTimeCode, - hlAlignment: TextAlignment.center + hlAlignment: .center ) .padding(.bottom, 12) - - MoreActionButton(backgroundColor: Color.more.secondary, disabled: $disabledQRCodeButton) { + + MoreActionButton(backgroundColor: .more.secondary, disabled: $disabledQRCodeButton) { model.showQRCodeView = true } label: { HStack { - Text(verbatim:.localize(forKey: "scan_qr_code", withComment: "Login with QR Code.", inTable: stringTable)) + Text("scan_qr_code") Spacer() Image(systemName: "qrcode") .foregroundColor(.more.primaryLight200) @@ -63,29 +68,59 @@ struct LoginView: View { 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(title: "no_internet_title", message: "no_internet_message", positiveTitle: "Ok", negativeTitle: nil) { AlertController.shared.closeAlertDialog() + }) + } + } + } + } + } + .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"), input: $model.endpoint, - capitalization: .lowercase, + capitalization: .lowercase, textType: .URL ) - + if !showEndpoint { BasicText(text: "\(model.currentStudyEndpoint())", font: .footnote, lineLimit: 1, textAlign: .center) } @@ -100,7 +135,8 @@ struct LoginView: View { struct LoginView_Previews: PreviewProvider { static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + static let repos = MainRepository(appDatabase: database) static var previews: some View { - LoginView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(database: database, dataManager: iOSObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) + LoginView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder())))) } } diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index de82e1474..88058b9e0 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -15,36 +15,32 @@ import shared - -protocol LoginViewModelListener { - func tokenValid(study: Study) -} +import KMPNativeCoroutinesCombine +import Combine 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 = "" @Published var showQRCodeView: Bool = false - init(registrationService: RegistrationService) { - print("LoginViewModel allocated!") - coreModel = CoreLoginViewModel(registrationService: registrationService) - defaultEndpoint = registrationService.getEndpointRepository().endpoint() + private var cancellables: Set = [] + + + init(registration: RegistrationService) { + self.registrationService = registration + defaultEndpoint = registration.getEndpointRepository().endpoint() - coreModel.onLoadingChange { loading in - if let loading = loading as? Bool { - self.isLoading = loading + Publishers.CombineLatest($endpoint, $token) + .map { !$0.isEmpty || !$1.isEmpty } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] hasInput in + self?.registrationService.clearError() } - } + .store(in: &cancellables) } func extractValuesFromQRCode(qrCodeUrl: String) { @@ -57,16 +53,13 @@ class LoginViewModel: ObservableObject { } 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(title: "Token or Endpoint invalid", message: "login_model_invalid_body", positiveTitle: "Ok", negativeTitle: nil, onPositive: { + AlertController.shared.closeAlertDialog() + }, onNegative: nil)) } } @@ -77,9 +70,5 @@ class LoginViewModel: ObservableObject { func currentStudyEndpoint() -> String { endpoint.isEmpty ? defaultEndpoint : endpoint } - - deinit { - print("LoginViewModel deallocated") - } } diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 938d1616d..b79e98b51 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -14,8 +14,6 @@ import AVFoundation struct ScanQRCodeView: View { @StateObject private var viewModel = ScanQRCodeViewModel() @ObservedObject var model: LoginViewModel - - private let stringTable = "LoginView" // Error Properties @State private var errorMessage: String = "" @@ -40,7 +38,7 @@ struct ScanQRCodeView: View { .font(.largeTitle) .foregroundColor(.more.textDefault) - Text(verbatim:.localize(forKey: "scan_qr_code", withComment: "Login with QR Code.", inTable: stringTable)) + Text("scan_qr_code") .foregroundColor(.more.primary) } @@ -71,7 +69,7 @@ struct ScanQRCodeView: View { if(viewModel.showError) { HStack(spacing: 8) { - Text(verbatim:.localize(forKey: "provide_camera_access", withComment: "Access to camera wasn't granted to scan QR Code.", inTable: stringTable)) + Text("provide_camera_access") .foregroundColor(.more.important) .padding(.horizontal, 20) .frame(maxWidth: .infinity, alignment: .center) @@ -87,16 +85,16 @@ struct ScanQRCodeView: View { .onAppear(perform: viewModel.checkCameraPermission) .alert(isPresented: $viewModel.showError) { Alert( - title: Text(verbatim:.localize(forKey: "permission_needed", withComment: "Camera permission is needed, but not granted.", inTable: stringTable)), + title: Text("permission_needed"), message: Text(viewModel.errorMessage), - primaryButton: .default(Text(verbatim:.localize(forKey: "open_settings", withComment: "Open device settings to grant permissions.", inTable: stringTable)), action: { + 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(verbatim:.localize(forKey: "cancel", withComment: "Cancel QR Code scan", inTable: stringTable))) + secondaryButton: .cancel(Text("Cancel")) ) } .onChange(of: viewModel.scannedCode) { code in @@ -110,8 +108,9 @@ struct ScanQRCodeView: View { struct ScanQRCodeView_Previews: PreviewProvider { static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + static let repos = MainRepository(appDatabase: database) static var previews: some View { - ScanQRCodeView(model: LoginViewModel(registrationService: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), database: database, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(database: database), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(database: database, dataManager: iOSObservationDataManager(database: database)), dataRecorder: IOSDataRecorder())))) + ScanQRCodeView(model: LoginViewModel(registration: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder())))) } } diff --git a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift index 6a8cb52c9..956195507 100644 --- a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift +++ b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift @@ -37,7 +37,10 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { qrDelegate.onCodeScanned = { [weak self] code in DispatchQueue.main.async { self?.scannedCode = code - self?.cameraSession.stopRunning() + Task.detached { [weak self] in + guard let session = self?.cameraSession else { return } + session.stopRunning() + } } } } @@ -48,14 +51,18 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { case .authorized: await MainActor.run { self.cameraPermission = .approved - self.setupCamera() + Task.detached { [weak self] in + self?.setupCamera() + } } case .notDetermined: let granted = await AVCaptureDevice.requestAccess(for: .video) await MainActor.run { if granted { self.cameraPermission = .approved - self.setupCamera() + Task.detached { [weak self] in + self?.setupCamera() + } self.showError = false } else { self.showError = true @@ -71,7 +78,9 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { func setupCamera() { guard let device = AVCaptureDevice.default(for: .video) else { - self.showError = true + Task { @MainActor in + self.showError = true + } return } @@ -88,7 +97,11 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) } - cameraSession.startRunning() + Task.detached { [weak self] in + guard let session = self?.cameraSession else { return } + session.startRunning() + } + } catch { presentError(errorDescription: error.localizedDescription) } diff --git a/iosApp/iosApp/Views/MainTabView.swift b/iosApp/iosApp/Views/MainTabView.swift index ca8c88f4a..673ba5fe5 100644 --- a/iosApp/iosApp/Views/MainTabView.swift +++ b/iosApp/iosApp/Views/MainTabView.swift @@ -23,31 +23,31 @@ struct MainTabView: 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) + NotificationView(coreFilterVM: contentViewModel.coreNotificationFilterViewModel) .padding(.horizontal, navigationModalState.horizontalContentPadding) } .tabItem { - Label(NavigationScreen.notifications.localize(useTable: strings, withComment: "Notifications Tab"), systemImage: "bell") + Label(NavigationScreen.notifications.localize(), systemImage: "bell") } .tag(1) .badge(contentViewModel.unreadNotificationCount) } else { NavigationWithDestinations { - NotificationView(notificationViewModel: contentViewModel.notificationViewModel, filterVM: contentViewModel.notificationFilterViewModel) + NotificationView(coreFilterVM: contentViewModel.coreNotificationFilterViewModel) .padding(.horizontal, navigationModalState.horizontalContentPadding) } .tabItem { - Label(NavigationScreen.notifications.localize(useTable: strings, withComment: "Notifications Tab"), systemImage: "bell") + Label(NavigationScreen.notifications.localize(), systemImage: "bell") } .tag(1) } @@ -56,7 +56,7 @@ struct MainTabView: View { .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) } @@ -97,7 +97,7 @@ struct MainTabView: View { } } .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .withdrawStudy)) { - LeaveStudyView(viewModel: contentViewModel.settingsViewModel) + LeaveStudyView() } } } diff --git a/iosApp/iosApp/Views/Navigation.swift b/iosApp/iosApp/Views/Navigation.swift index 713f664c2..f5160e3f7 100644 --- a/iosApp/iosApp/Views/Navigation.swift +++ b/iosApp/iosApp/Views/Navigation.swift @@ -90,28 +90,30 @@ struct NavigationWithDestinations: View { 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() diff --git a/iosApp/iosApp/Views/NavigationModalState.swift b/iosApp/iosApp/Views/NavigationModalState.swift index df203b951..8b8595cfe 100644 --- a/iosApp/iosApp/Views/NavigationModalState.swift +++ b/iosApp/iosApp/Views/NavigationModalState.swift @@ -15,6 +15,8 @@ import shared import SwiftUI +import Combine +import KMPNativeCoroutinesCombine struct NavigationState: Hashable { var scheduleId: String? = nil @@ -51,7 +53,20 @@ class NavigationModalState: ObservableObject { } } - private var studyUpdatingClosable: Ktor_ioCloseable? + 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) + } func screenBinding(for screen: NavigationScreen) -> Binding { Binding( @@ -81,13 +96,6 @@ class NavigationModalState: ObservableObject { } } - func setStudyState(_ state: StudyState) { - currentStudyState = state - if state == StudyState.closed || state == StudyState.paused { - clearViews() - } - } - func currentNavigationAction() -> NavigationActions? { navigationActions.last } @@ -105,7 +113,9 @@ class NavigationModalState: ObservableObject { navigationStateStack.append(NavigationState(scheduleId: scheduleId, observationId: observationId, notificationId: notificationId)) navigationStack.append(screen) if let onViewOpen = currentNavigationAction()?.onViewOpen { - onViewOpen(screen) + Task { @MainActor in + onViewOpen(screen) + } } } else { fullscreenNavigationStateStack.append(NavigationState(scheduleId: scheduleId, observationId: observationId, notificationId: notificationId)) @@ -186,37 +196,33 @@ class NavigationModalState: ObservableObject { } func openWithDeepLink(url: URL, notificationId: String? = nil) { - self.studyUpdatingClosable = ViewManager.shared.checkingForNewStudyDataAsClosure { isUpdating in - guard !isUpdating.boolValue else { return } - 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, 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 } - - let observationId = parameters[.observationId] - let notificationId = parameters[.notificaitonId] ?? notificationId - let scheduleId = parameters[.scheduleId] - - if let notificationId { - AppDelegate.shared.notificationManager.handleNotificationInteraction(notificationId: notificationId, deeplink: modifiedDeepLink) - } - - self.openView(screen: matchingScreen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) } - } else if modifiedDeepLink == nil, let notificationId { - AppDelegate.shared.notificationManager.markNotificationAsRead(notificationId: notificationId) + + let observationId = parameters[.observationId] + let notificationId = parameters[.notificaitonId] ?? notificationId + let scheduleId = parameters[.scheduleId] + + if let notificationId { + AppDelegate.shared.notificationManager.handleNotificationInteraction(notificationId: notificationId, deeplink: modifiedDeepLink) + } + + self.openView(screen: matchingScreen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) } - self.studyUpdatingClosable?.close() - self.studyUpdatingClosable = nil + } else if modifiedDeepLink == nil, let notificationId { + AppDelegate.shared.notificationManager.markNotificationAsRead(notificationId: notificationId) } } diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift index d9e922bbb..4052247e4 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift @@ -17,24 +17,26 @@ import SwiftUI import shared struct NotificationFilterView: View { - @StateObject var viewModel: NotificationFilterViewModel + @StateObject private var viewModel: NotificationFilterViewModel @State var filtersChanged = false - private let stringTable = "NotificationView" - private let navigationStrings = "Navigation" + + init(coreVM: CoreNotificationFilterViewModel) { + _viewModel = StateObject(wrappedValue: 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 { + 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) @@ -46,13 +48,7 @@ struct NotificationFilterView: View { }.padding(.vertical, 20) Spacer() } - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear { - viewModel.viewDidDisappear() - } - .customNavigationTitle(with: NavigationScreen.notificationFilter.localize(useTable: navigationStrings, withComment: "Select Notification Filter")) + .customNavigationTitle(with: NavigationScreen.notificationFilter.localize()) .navigationBarTitleDisplayMode(.inline) } } diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift index 5deb0d58a..aaea6a717 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift @@ -14,29 +14,28 @@ // import shared +import Combine +import KMPNativeCoroutinesCombine 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 - } - } - - func viewDidAppear() { - coreViewModel.viewDidAppear() + 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 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 7fddbbf67..ecaf74962 100644 --- a/iosApp/iosApp/Views/Notification/NotificationView.swift +++ b/iosApp/iosApp/Views/Notification/NotificationView.swift @@ -17,12 +17,13 @@ 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 { @@ -30,7 +31,8 @@ struct NotificationView: View { .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 { ScrollViewReader { _ in ScrollView { @@ -53,16 +55,8 @@ struct NotificationView: View { } } } - Spacer() } .frame(maxWidth: .infinity) - .onAppear { - notificationViewModel.getFilterText(stringTable: stringTable) - notificationViewModel.viewDidAppear() - } - .onDisappear { - notificationViewModel.viewDidDisappear() - } - .customNavigationTitle(with: NavigationScreen.notifications.localize(useTable: navigationStrings, withComment: "Navigation title")) + .customNavigationTitle(with: NavigationScreen.notifications.localize()) } } diff --git a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift index 55dee3304..0e3548a32 100644 --- a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift +++ b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift @@ -15,27 +15,45 @@ import shared +import Combine +import KMPNativeCoroutinesCombine class NotificationViewModel: ObservableObject { - let recorder = IOSDataRecorder() private let filterViewModel: CoreNotificationFilterViewModel private 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 = [] + + createPublisher(for: coreModel.notificationList) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in}) { [weak self] notifications in self?.notificationList = notifications } - } + .store(in: &cancellables) + + createPublisher(for: filterViewModel.activeTypes) + .map { (types: Set) -> String in + guard !types.isEmpty else { return "" } + return types.sorted().joined(separator: ", ") + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] text in + self?.filterText = text + } + .store(in: &cancellables) } - + func handleNotificationAction(notification: NotificationModel, navigationModalState: NavigationModalState) { coreModel.handleNotificationAction(notification: notification) { (actionHandler, data) in switch(actionHandler) { @@ -48,19 +66,5 @@ class NotificationViewModel: ObservableObject { } } } - - func viewDidAppear() { - coreModel.viewDidAppear() - } - - func viewDidDisappear() { - coreModel.viewDidDisappear() - } - - func getFilterText(stringTable: String) { - self.filterText = filterViewModel.getActiveTypes().map { - $0.localize(withComment: $0, useTable: stringTable) - } - .joined(separator: ", ") - } } + diff --git a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift index 09c728956..fe4b07bdb 100644 --- a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift +++ b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift @@ -17,9 +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( @@ -48,13 +51,13 @@ struct ObservationDetailsView: View { 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 217668458..b61a3196c 100644 --- a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift +++ b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift @@ -22,7 +22,7 @@ class ObservationDetailsViewModel: ObservableObject { init(observationId: String) { - self.coreModel = CoreObservationDetailsViewModel(database: AppDelegate.database, observationId: observationId) + self.coreModel = CoreObservationDetailsViewModel(repository: AppDelegate.shared.repositories, observationId: observationId) coreModel.onLoadObservationDetails { observationDetails in if let observationDetails { self.observationDetailModel = observationDetails diff --git a/iosApp/iosApp/Views/ObservationErrorListView.swift b/iosApp/iosApp/Views/ObservationErrorListView.swift index 0f2c2995f..784941bf3 100644 --- a/iosApp/iosApp/Views/ObservationErrorListView.swift +++ b/iosApp/iosApp/Views/ObservationErrorListView.swift @@ -14,8 +14,6 @@ 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 { @@ -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) } @@ -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") } } } diff --git a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift index 7f6d42eff..b5a3dbaf9 100644 --- a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift +++ b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift @@ -15,7 +15,7 @@ struct ObservationErrorsView: View { 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/QuestionObservation/SimpleQuestionObservationViewModel.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift index 79afa53fc..e14219ed9 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift +++ b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift @@ -20,7 +20,7 @@ protocol SimpleQuestionObservationListener { } class SimpleQuestionObservationViewModel: ObservableObject { - private let coreModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(appDatabase: AppDelegate.database, observationFactory: AppDelegate.shared.observationFactory) + private let coreModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(repository: AppDelegate.shared.repositories, observationFactory: AppDelegate.shared.observationFactory) @Published var simpleQuestoinModel: SimpleQuestionModel? @Published var answers: [String] = [] diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift index 80b45098f..881954d25 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift +++ b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift @@ -26,25 +26,25 @@ struct SimpleQuestionThankYouView: View { 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/SimpleQuetionObservationView.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift index dee8370ad..86a0991b9 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift +++ b/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift @@ -21,8 +21,6 @@ struct SimpleQuetionObservationView: View { @EnvironmentObject var navigationModalState: NavigationModalState - private let navigationStrings = "Navigation" - private let simpleQuestionStrings = "SimpleQuestinoObservation" var body: some View { MoreMainBackgroundView { @@ -48,7 +46,7 @@ struct SimpleQuetionObservationView: View { navigationModalState.closeView(screen: .questionObservation) } } label: { - Text(String.localize(forKey: "Answer", withComment: "Click answer button to send your answer.", inTable: simpleQuestionStrings)) + Text("Answer") } .padding(.top, 30) } @@ -63,7 +61,7 @@ struct SimpleQuetionObservationView: View { Spacer() } } - .customNavigationTitle(with: NavigationScreen.questionObservation.localize(useTable: navigationStrings, withComment: "Answer the Question Observation"), displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.questionObservation.localize(), displayMode: .inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button { diff --git a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift new file mode 100644 index 000000000..dc6edc986 --- /dev/null +++ b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift @@ -0,0 +1,66 @@ +// +// RegistrationObservable.swift +// More +// +// Created by Jan Cortiel on 15.09.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import Foundation +import shared +import KMPNativeCoroutinesCombine +import Combine + +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] error in + self?.error = error + if error != nil && self?.study != nil { + AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "consent_error_title", message: "consent_error_body", positiveTitle: "Ok", negativeTitle: nil, onPositive: 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..74cf32d05 100644 --- a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift +++ b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift @@ -26,6 +26,6 @@ struct RunningSchedules: View { 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) } } diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift index ea79298b2..be19dd6b6 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift @@ -32,9 +32,7 @@ struct ObservationButton: View { navigationModalState.openView(screen: .questionObservation, scheduleId: scheduleId) }) { VStack { - Text( - String.localize(forKey: "start_questionnaire", withComment: "Button to start a questionnaire", inTable: stringTable) - ) + Text("start_questionnaire") } } } else if observationType == "lime-survey-observation" { @@ -42,10 +40,7 @@ struct ObservationButton: View { navigationModalState.openView(screen: .limeSurvey, scheduleId: scheduleId) }) { VStack { - Text( - "Start LimeSurvey" - .localize(withComment: "Button to start a limesurvey", useTable: stringTable) - ) + Text("Button to start a limesurvey") } } } else { @@ -58,13 +53,9 @@ struct ObservationButton: View { }) { VStack { if state == ScheduleState.running { - Text( - String.localize(forKey: "pause_observation", withComment: "Button to pause an observation", inTable: stringTable) - ) + Text("pause_observation") } else { - Text( - String.localize(forKey: "start_observation", withComment: "Button to start an observation", inTable: stringTable) - ) + Text("start_observation") } } } diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift b/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift index 575810ec4..2d495cca1 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationTimeDetails.swift @@ -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/ScheduleView.swift b/iosApp/iosApp/Views/Schedule/ScheduleView.swift index 2baeb0e1e..5e7321f5d 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleView.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleView.swift @@ -18,21 +18,17 @@ import shared struct ScheduleView: View { @StateObject var viewModel: ScheduleViewModel - - @EnvironmentObject var navigationModalState: NavigationModalState - - private let stringsTable = "ScheduleListView" var body: some View { VStack { ScrollViewReader { _ in 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)) + EmptyListView(text: "No running tasks currently") } else if viewModel.scheduleListType == ScheduleListType.completed { - EmptyListView(text: "No tasks completed by now".localize(withComment: "No completed tasks in list", useTable: stringsTable)) + EmptyListView(text: "No tasks completed by now") } else { - EmptyListView(text: "No tasks to show".localize(withComment: "No tasks in list shown", useTable: stringsTable)) + EmptyListView(text: "No tasks to show") } } else { LazyVStack(alignment: .leading, pinnedViews: .sectionHeaders) { @@ -64,12 +60,6 @@ struct ScheduleView: View { } } } - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear { - viewModel.viewDidDisappear() - } } } diff --git a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift index db1d167b9..21f94b36e 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -14,6 +14,8 @@ // import shared +import Combine +import KMPNativeCoroutinesCombine class ScheduleViewModel: ObservableObject { let recorder = AppDelegate.shared.dataRecorder @@ -24,111 +26,30 @@ class ScheduleViewModel: ObservableObject { @Published var schedulesByDate: [Date: [ScheduleModel]] = [:] @Published var observationErrors: [String: Set] = [:] - @Published var observationErrorActions: [String: Set] = [:] + + private var cancellables = Set() init(scheduleListType: ScheduleListType) { self.scheduleListType = scheduleListType - coreModel = CoreScheduleViewModel(database: AppDelegate.database, dataRecorder: recorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) - loadSchedules() - - ViewManager.shared.studyIsUpdatingAsClosure { [weak self] kBool in - if kBool.boolValue { - DispatchQueue.main.async { - self?.schedulesByDate.removeAll() - } - } - } - } - - 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) - - 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 - } - } + coreModel = CoreScheduleViewModel(repos: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel, observationFactory: AppDelegate.shared.observationFactory) + + createPublisher(for: coreModel.schedulesByDate) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] schedules in + self?.schedulesByDate = schedules.mapKeys { $0.toInt64().toDate() } } - } - - 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 } - } + .store(in: &cancellables) + + createPublisher(for: coreModel.observationErrors) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] errors in + self?.observationErrors = errors } - } - } - - 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 + .store(in: &cancellables) } func numberOfObservationErrors() -> Int { - let errors = Set(observationErrors.values.flatMap { $0 }).count - return errors > 0 ? errors : Set(observationErrorActions.values.flatMap { $0 }).count + Set(observationErrors.values.flatMap { $0 }).count } } @@ -145,3 +66,4 @@ extension ScheduleViewModel: ObservationActionDelegate { coreModel.stop(scheduleId: scheduleId) } } + diff --git a/iosApp/iosApp/Views/Settings/SettingsView.swift b/iosApp/iosApp/Views/Settings/SettingsView.swift index 235d3a7bc..4bf2bfec6 100644 --- a/iosApp/iosApp/Views/Settings/SettingsView.swift +++ b/iosApp/iosApp/Views/Settings/SettingsView.swift @@ -17,15 +17,15 @@ import SwiftUI import shared struct SettingsView: View { - @StateObject var viewModel: SettingsViewModel - @State var exitButton = Color.more.important + @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() + @State private var exitButton = Color.more.important private let stringTable = "SettingsView" private let navigationStrings = "Navigation" var body: some View { VStack(alignment: .leading) { - Text(String.localize(forKey: "settings_text", withComment: "information about accepted permissions", inTable: stringTable)) + Text("settings_text") .foregroundColor(.more.secondary) .padding(.bottom, 15) if let permissions = viewModel.permissionModel { @@ -35,18 +35,12 @@ struct SettingsView: View { Spacer() } - .customNavigationTitle(with: NavigationScreen.settings.localize(useTable: navigationStrings, withComment: "Settings Screen")) - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear{ - viewModel.viewDidDisappear() - } + .customNavigationTitle(with: NavigationScreen.settings.localize()) } } 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..3e1c82c69 100644 --- a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift +++ b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift @@ -15,37 +15,35 @@ import Foundation import shared +import Combine +import KMPNativeCoroutinesCombine class SettingsViewModel: ObservableObject { - private let coreSettingsViewModel: CoreSettingsViewModel - var delegate: ConsentViewModelListener? = nil + private let coreSettingsViewModel = CoreSettingsViewModel(shared: AppDelegate.shared) @Published var studyTitle: String? - @Published private(set) var permissionModel: PermissionModel? - @Published var dataDeleted = false + @Published var permissionModel: PermissionModel? @Published var showSettings = false + private var cancellables: Set = [] 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 - } + createPublisher(for: coreSettingsViewModel.study) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in}) { [weak self] studyEntity in + self?.studyTitle = studyEntity?.studyTitle + } + .store(in: &cancellables) + + createPublisher(for: coreSettingsViewModel.permissionModel) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: {_ in}) { [weak self] permissions in + self?.permissionModel = permissions + } + .store(in: &cancellables) + } func leaveStudy() { - dataDeleted = true coreSettingsViewModel.exitStudy() - self.delegate?.credentialsDeleted() - } - - func viewDidAppear() { - coreSettingsViewModel.viewDidAppear() - } - - func viewDidDisappear() { - coreSettingsViewModel.viewDidDisappear() } } diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift index a81c5b62d..b70731734 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift @@ -34,12 +34,11 @@ struct StudyDetailsView: View { .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()), @@ -47,7 +46,7 @@ struct StudyDetailsView: View { ) }.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( @@ -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() } diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift index 5aef81963..2a916f09d 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift @@ -14,25 +14,31 @@ // import shared +import Combine +import KMPNativeCoroutinesCombine class StudyDetailsViewModel: ObservableObject { - private let coreModel = CoreStudyDetailsViewModel(appDatabase: AppDelegate.database) + private let coreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) @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.toInt64().toDate() - } - if let end = studyDetails.study.end { - self.studyEnd = end.toInt64().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 = studyDetails?.study.start?.toInt64().toDate() { + self?.studyStart = start + } + if let end = studyDetails?.study.end?.toInt64().toDate() { + self?.studyEnd = end + } } } - } + .store(in: &cancellables) } func viewDidAppear() { diff --git a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift index bfff029d3..7f05fb8e9 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift @@ -16,33 +16,28 @@ 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(onDeletion: {}) } 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 +46,6 @@ struct StudyClosedView: View { struct StudyClosedView_Previews: PreviewProvider { static var previews: some View { - StudyClosedView(viewModel: ContentViewModel()) + StudyClosedView() } } 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..5ea7fc5c7 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift @@ -16,13 +16,12 @@ 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() } } diff --git a/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift b/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift index 515950edb..e4333b0ba 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift @@ -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..6a0d5235f 100644 --- a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift +++ b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift @@ -41,9 +41,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 e9135ec2b..00670ef8f 100644 --- a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift +++ b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift @@ -14,22 +14,23 @@ // import shared +import Combine +import KMPNativeCoroutinesCombine class TaskCompletionBarViewModel: ObservableObject { @Published var taskCompletion: TaskCompletion = TaskCompletion(finishedTasks: 0, totalTasks: 0) @Published var taskCompletionPercentage: Double = 0 - var coreViewModel = CoreTaskCompletionBarViewModel(database: AppDelegate.database) + var coreViewModel = CoreTaskCompletionBarViewModel(repository: AppDelegate.shared.repositories) - init() { - loadTaskCompletion() - } + private var cancellables: Set = [] - func loadTaskCompletion() { - self.coreViewModel.onLoadTaskCompletion { taskCompletion in - self.taskCompletion = taskCompletion - if taskCompletion.totalTasks != 0 { - self.taskCompletionPercentage = (Double(taskCompletion.finishedTasks)/Double(taskCompletion.totalTasks)) * 100 + init() { + 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..be2b3a54f 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift @@ -17,7 +17,7 @@ import shared import SwiftUI struct TaskDetailsView: View { - @StateObject var viewModel: TaskDetailsViewModel + @StateObject private var viewModel: TaskDetailsViewModel @State private var scrollViewContentSize: CGSize = .zero @@ -27,6 +27,10 @@ struct TaskDetailsView: View { private let scheduleStringTable = "ScheduleListView" private let navigationStrings = "Navigation" private let errorStrings = "Errors" + + init(scheduleId: String) { + _viewModel = StateObject(wrappedValue: TaskDetailsViewModel(scheduleId: scheduleId)) + } var body: some View { MoreMainBackgroundView(contentPadding: 0) { @@ -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() @@ -65,11 +69,18 @@ struct TaskDetailsView: View { 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) @@ -91,7 +102,7 @@ struct TaskDetailsView: View { } } - .customNavigationTitle(with: NavigationScreen.taskDetails.localize(useTable: navigationStrings, withComment: "Task Detail")) + .customNavigationTitle(with: NavigationScreen.taskDetails.localize()) .onAppear { viewModel.viewDidAppear() } @@ -103,8 +114,10 @@ struct TaskDetailsView: View { } struct TaskDetailsViewPreview_Provider: PreviewProvider { + static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) + static let repos = MainRepository(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 783480e11..8ed404e10 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift @@ -36,8 +36,8 @@ class TaskDetailsViewModel: ObservableObject { var simpleQuestionObservationVM: SimpleQuestionObservationViewModel - init(dataRecorder: DataRecorder) { - self.coreModel = CoreTaskDetailsViewModel(database: AppDelegate.database, dataRecorder: dataRecorder) + init(scheduleId: String) { + self.coreModel = CoreTaskDetailsViewModel(repository: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, observationFactory: AppDelegate.shared.observationFactory, scheduleId: scheduleId) self.simpleQuestionObservationVM = SimpleQuestionObservationViewModel() coreModel.onLoadTaskDetails { [weak self] taskDetails in if let self { @@ -62,9 +62,6 @@ class TaskDetailsViewModel: ObservableObject { } } - func setSchedule(scheduleId: String) { - coreModel.setSchedule(scheduleId: scheduleId) - } func viewDidAppear() { coreModel.viewDidAppear() diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 02e0e5917..6c88318d0 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("com.android.library") id("androidx.room") id("com.google.devtools.ksp") + id("com.rickclephas.kmp.nativecoroutines") } val generated = "$rootDir/shared/build/generated" @@ -19,7 +20,7 @@ val coroutinesVersion = "1.10.2" val ktorVersion = "3.2.3" val napierVersion = "2.7.1" val serializationVersion = "1.9.0" -val gsonVersion = "2.13.1" +val gsonVersion = "2.13.2" val roomVersion = "2.7.2" val sqliteVersion = "2.5.2" @@ -57,6 +58,9 @@ kotlin { // 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 { @@ -72,6 +76,10 @@ kotlin { iosMain.dependencies { implementation("io.ktor:ktor-client-darwin:$ktorVersion") } + + all { + languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") + } } } @@ -86,6 +94,11 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } room { @@ -99,8 +112,3 @@ dependencies { add("kspIosX64", "androidx.room:room-compiler:$roomVersion") } -tasks.register("testClasses") { - doLast { - println("This is a dummy testClasses task") - } -} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/extensions/StringExtension.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/extensions/StringExtension.kt deleted file mode 100644 index a8669cd84..000000000 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/extensions/StringExtension.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.extensions - -import java.math.BigInteger -import java.security.MessageDigest - -fun String.toMD5(): String { - val md = MessageDigest.getInstance("MD5") - return BigInteger(1, md.digest(this.toByteArray())) - .toString(16) - .padStart(32, '0') -} \ No newline at end of file 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/AlertController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt index cee3d10c2..0811c0593 100644 --- 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 @@ -1,6 +1,6 @@ package io.redlink.more.more_app_mutliplatform -import io.redlink.more.more_app_mutliplatform.extensions.asNullableClosure +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines 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 @@ -10,16 +10,17 @@ 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) { - if (model.onPositive == {}) { + if (model.onPositive == null || model.onPositive == {}) { model.onPositive = { closeAlertDialog() } } - if (model.onNegative == {}) { + if (model.onNegative == null || model.onNegative == {}) { model.onNegative = { closeAlertDialog() } @@ -34,7 +35,4 @@ object AlertController { 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 index 600e04167..1e644964b 100644 --- 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 @@ -13,8 +13,7 @@ package io.redlink.more.more_app_mutliplatform import dev.tmapps.konnection.Konnection import io.github.aakira.napier.Napier import io.github.aakira.napier.log -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.repository.StudyRepository +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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 @@ -22,6 +21,8 @@ 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.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope 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 @@ -29,21 +30,17 @@ import io.redlink.more.more_app_mutliplatform.services.notification.Notification 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, - val database: AppDatabase, + val repositories: MainRepository, private val sharedStorageRepository: SharedStorageRepository, val observationDataManager: ObservationDataManager, val mainBluetoothConnector: BluetoothConnector, @@ -51,17 +48,21 @@ class Shared( val dataRecorder: DataRecorder ) { private val viewManager = ViewManager - val deeplinkManager = DeeplinkManager(database, 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(database, observationFactory, dataRecorder) - val bluetoothController = BluetoothController(database, mainBluetoothConnector) + val deeplinkManager = DeeplinkManager(repositories, observationFactory) + val endpointRepository = EndpointRepository(sharedStorageRepository) + val credentialRepository = CredentialRepository(sharedStorageRepository) + val networkService = NetworkService(endpointRepository, credentialRepository) + + val observationManager = ObservationManager( + repositories, + observationFactory, + dataRecorder + ) + val bluetoothController = + BluetoothController(repositories.bluetoothDevice, mainBluetoothConnector) val notificationManager = NotificationManager( - database, + repositories, localNotificationListener, networkService, deeplinkManager, @@ -72,15 +73,11 @@ class Shared( val unreadNotificationCount = notificationManager.unreadUserCount - val currentStudyState = studyStateRepository.currentStudyState - var finishText: String? = null private var bluetoothListener: Job? = null private val mutex = Mutex() private val konnection = Konnection.instance - val studyRepository = StudyRepository(database) - init { onApplicationStart() observationFactory.setCredentialsRepository(credentialRepository) @@ -88,7 +85,7 @@ class Shared( } private fun onApplicationStart() { - if (credentialRepository.hasCredentials()) { + if (credentialRepository.hasCredentials.value) { activateObservationWatcher() } } @@ -100,17 +97,20 @@ class Shared( Napier.i { "App is in foreground: $boolean" } appIsInForeGround = boolean if (appIsInForeGround) { - if (credentialRepository.hasCredentials()) { + if (credentialRepository.hasCredentials.value) { updateStudyBlocking() notificationManager.createNewFCMIfNecessary() bluetoothListener?.cancel() + bluetoothListener = StudyScope.launch(Dispatchers.IO) { bluetoothController.listenToConnectionChanges( observationFactory ) }.second - observationFactory.updateObservationErrors() - updateTaskStates() + if (repositories.study.studyState.value == StudyState.ACTIVE) { + observationFactory.updateObservationErrors() + updateTaskStates() + } } notificationManager.clearAllNotifications() } else { @@ -119,21 +119,23 @@ class Shared( } fun updateTaskStates() { - if (appIsInForeGround && credentialRepository.hasCredentials()) { + if (appIsInForeGround && credentialRepository.hasCredentials.value && repositories.study.studyState.value == StudyState.ACTIVE) { observationManager.updateTaskStates() notificationManager.downloadMissedNotifications() bluetoothController.startScanningForDevices(observationFactory.bleDevicesNeeded()) } } - private fun activateObservationWatcher(overwriteCheck: Boolean = false) { - StudyScope.launch(Dispatchers.IO) { - if (overwriteCheck || studyRepository.getStudy() - .firstOrNull()?.active == true - ) { - observationDataManager.listenToDatapointCountChanges() - updateTaskStates() - observationManager.activateScheduleUpdate() + private fun activateObservationWatcher() { + Scope.launch { + repositories.study.studyState.collect { state -> + if (state == StudyState.ACTIVE) { + observationDataManager.listenToDatapointCountChanges() + updateTaskStates() + observationManager.activateScheduleUpdate() + } else { + stopObservations() + } } } } @@ -163,11 +165,20 @@ class Shared( oldStudyState: StudyState? = null, newStudyState: StudyState? = null ) { - Scope.launch(Dispatchers.IO) { + Scope.launch(Dispatchers.Main) { updateStudy(oldStudyState, newStudyState) } } + /** + * Updates the study state and related resources. This function ensures that any change in the + * current study, study state, or study configuration is appropriately handled. It synchronizes + * updates using a lock and takes necessary actions such as storing the new state, updating study + * resources, managing notifications, and handling changes in connectivity. + * + * @param oldStudyState The previous state of the study. Can be null if there is no specific old state to compare with. + * @param newStudyState The new state to transition the study to. Can be null if no state change is required. + */ suspend fun updateStudy( oldStudyState: StudyState? = null, newStudyState: StudyState? = null @@ -178,29 +189,17 @@ class Shared( } else { Napier.d(tag = "Shared::updateStudy") { "Updating study..." } } - 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 - } - } - + val currentStudy = repositories.study.study.value if (newStudyState == StudyState.CLOSED || newStudyState == StudyState.PAUSED) { Napier.d(tag = "Shared::updateStudy") { "New study State is $newStudyState" } - studyStateRepository.storeState(newStudyState) + repositories.study.updateStudyState(newStudyState) StudyScope.cancel() - stopObservations() - removeStudyData() notificationManager.clearAllNotifications() } else { if (!konnection.isConnected()) { Napier.d(tag = "Shared::updateStudy") { "No network connection, skipping study update" } if (newStudyState != null) { - studyStateRepository.storeState(newStudyState) + repositories.study.updateStudyState(newStudyState) } return } @@ -251,43 +250,24 @@ class Shared( viewManager.studyIsUpdating(true) StudyScope.cancel() - stopObservations() - removeStudyData() - if (study.studyState?.let { StudyState.getState(it) } != StudyState.CLOSED) { - studyRepository.storeStudy(study) + observationFactory.clearNeededObservationTypes() + notificationManager.clearAllNotifications() + repositories.notification.deleteAll() + repositories.study.upsert(study) + if (study.studyState?.let { StudyState.getState(it) } == StudyState.ACTIVE) { 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) } else { Napier.d(tag = "Shared::updateStudy") { "No study update needed - study data is unchanged" } } - - if (newStudyState != null) { - studyStateRepository.storeState(newStudyState) - } } } } fun newLogin() { notificationManager.newFCMToken() - studyStateRepository.storeState(StudyState.ACTIVE) - StudyScope.launch { - finishText = studyRepository.getStudy().firstOrNull()?.finishText - } - activateObservationWatcher() observationFactory.updateObservationErrors() bluetoothListener?.cancel() bluetoothListener = StudyScope.launch { @@ -299,18 +279,17 @@ class Shared( fun exitStudy(onDeletion: () -> Unit) { StudyScope.cancel() - stopObservations() bluetoothController.resetAll() Scope.launch(Dispatchers.IO) { networkService.deleteParticipation() notificationManager.clearAllNotifications() notificationManager.deleteFCMToken() removeStudyData() + repositories.notification.deleteAll() + observationFactory.clearNeededObservationTypes() clearSharedStorage() onDeletion() - observationFactory.clearNeededObservationTypes() viewManager.resetAll() - studyStateRepository.storeState(StudyState.NONE) } } @@ -322,7 +301,6 @@ class Shared( observationFactory.clearNeededObservationTypes() clearSharedStorage() notificationManager.clearAllNotifications() - studyStateRepository.storeState(StudyState.NONE) } } @@ -336,18 +314,10 @@ class Shared( } suspend fun removeStudyData() { - database.studyDao().deleteAll() - database.observationDao().deleteAll() - database.scheduleDao().deleteAll() - database.observationDataDao().deleteAll() - database.dataPointDao().deleteAll() - database.notificationDao().deleteAll() + repositories.deleteAll() observationFactory.clearNeededObservationTypes() } - fun onStudyStateChange(providedState: (StudyState) -> Unit) = - currentStudyState.asClosure(providedState) - fun unreadNotificationCountAsClosure(state: (Int) -> Unit) = unreadNotificationCount.asClosure(state) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt index 43380b742..753e5e24d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt @@ -41,6 +41,9 @@ interface BaseDao { @Update suspend fun update(entity: T) + @Update + suspend fun updateAll(entities: List) + /** * Delete a single entity from the database */ diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt index de29175e6..c004b35c4 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt @@ -59,4 +59,7 @@ interface StudyDao : BaseDao { @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/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt index 09e102b05..17b2df113 100644 --- 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 @@ -12,9 +12,9 @@ package io.redlink.more.more_app_mutliplatform.database.repository import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity +import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope 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.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow 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 index 93dafc5f5..e444e0b44 100644 --- 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 @@ -12,7 +12,7 @@ package io.redlink.more.more_app_mutliplatform.database.repository import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.DataPointEntity -import io.redlink.more.more_app_mutliplatform.util.StudyScope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/MainRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/MainRepository.kt new file mode 100644 index 000000000..6c2480190 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/MainRepository.kt @@ -0,0 +1,20 @@ +package io.redlink.more.more_app_mutliplatform.database.repository + +import io.redlink.more.more_app_mutliplatform.database.AppDatabase + +class MainRepository(appDatabase: AppDatabase) { + val study = StudyRepository(appDatabase) + val observation = ObservationRepository(appDatabase) + val observationData = ObservationDataRepository(appDatabase) + val dataPointCount = DataPointCountRepository(appDatabase) + val schedule = ScheduleRepository(appDatabase) + + val notification = NotificationRepository(appDatabase) + val bluetoothDevice = BluetoothDeviceRepository(appDatabase) + + suspend fun deleteAll() { + study.deleteStudy() + notification.deleteAll() + + } +} \ 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 index 0feea507d..85ecbfe3d 100644 --- 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 @@ -13,7 +13,7 @@ package io.redlink.more.more_app_mutliplatform.database.repository import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.more_app_mutliplatform.scopes.Scope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow 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 index 55737b071..285a58dfd 100644 --- 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 @@ -14,9 +14,9 @@ import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity import io.redlink.more.more_app_mutliplatform.extensions.mapAsBulkData +import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope 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.sync.Mutex 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 index 9a995d90c..960346c33 100644 --- 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 @@ -19,8 +19,8 @@ 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.scopes.StudyScope import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow 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 index 8ef2115da..9114857b6 100644 --- 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 @@ -10,23 +10,58 @@ */ package io.redlink.more.more_app_mutliplatform.database.repository +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity +import io.redlink.more.more_app_mutliplatform.extensions.mapState +import io.redlink.more.more_app_mutliplatform.models.StudyState +import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.util.StudyScope +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.transform class StudyRepository(private val appDatabase: AppDatabase) { + private val _study = MutableStateFlow(null) - fun storeStudy(study: Study) { + @NativeCoroutines + val study: StateFlow = _study + + @NativeCoroutines + val studyState: StateFlow = + study.mapState(CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) { + it?.let { StudyState.getState(it.state) } ?: StudyState.NONE + } + private val _finishText = MutableStateFlow(null) + + @NativeCoroutines + val finishText: StateFlow = _finishText + + init { + Scope.launch(Dispatchers.IO) { + getStudy().collect { + _study.value = it + it?.let { + _finishText.value = it.finishText + } + } + } + } + + suspend fun upsert(study: Study) { + deleteStudy() StudyScope.launch(Dispatchers.IO) { val studyEntity = StudyEntity.fromStudy(study) appDatabase.studyDao().insert(studyEntity) + _finishText.value = study.finishText } StudyScope.launch(Dispatchers.IO) { @@ -54,11 +89,19 @@ class StudyRepository(private val appDatabase: AppDatabase) { return appDatabase.studyDao().getAllFlow().transform { emit(it.firstOrNull()) } } - fun getStudyById(studyId: String): Flow { - return appDatabase.studyDao().getByIdFlow(studyId) + suspend fun updateStudyState(state: StudyState) { + study.value?.let { + appDatabase.studyDao().updateStudyState(it.studyId, state.descr) + } } - fun getStudyCount(): Flow { - return appDatabase.studyDao().getCount() + suspend fun deleteStudy() { + study.value?.let { + appDatabase.studyDao().deleteAll() + appDatabase.observationDao().deleteAll() + appDatabase.observationDataDao().deleteAll() + appDatabase.scheduleDao().deleteAll() + appDatabase.dataPointDao().deleteAll() + } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt index 2b8651b23..8b9a83a34 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt @@ -11,14 +11,18 @@ package io.redlink.more.more_app_mutliplatform.extensions import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.more_app_mutliplatform.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() @@ -134,4 +138,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/more_app_mutliplatform/models/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/AlertDialogModel.kt index c5af0ffaa..9fdf571a8 100644 --- 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 @@ -5,8 +5,8 @@ data class AlertDialogModel( var message: String, var positiveTitle: String, var negativeTitle: String? = null, - var onPositive: () -> Unit = {}, - var onNegative: () -> Unit = {} + var onPositive: (() -> Unit)? = null, + var onNegative: (() -> Unit)? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/LoginModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/LoginModel.kt new file mode 100644 index 000000000..7dcf8bb7c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/LoginModel.kt @@ -0,0 +1,13 @@ +package io.redlink.more.more_app_mutliplatform.models + +import io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/models/ScheduleModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt index bae884bd2..6ff682c61 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/ScheduleModel.kt @@ -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 { 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 index 356c10ee8..b8c119c51 100644 --- 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 @@ -1,9 +1,7 @@ package io.redlink.more.more_app_mutliplatform.navigation -import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -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.MainRepository 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 @@ -14,10 +12,11 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.datetime.Clock -class DeeplinkManager(database: AppDatabase, val observationFactory: ObservationFactory) { +class DeeplinkManager( + private val repos: MainRepository, + val observationFactory: ObservationFactory +) { private val deepLinks = mutableSetOf() - private val scheduleRepository = ScheduleRepository(database) - private val observationRepository = ObservationRepository(database) fun addAvailableDeepLinks(deepLinks: Set) { this.deepLinks.addAll(deepLinks) @@ -32,14 +31,14 @@ class DeeplinkManager(database: AppDatabase, val observationFactory: Observation val queryParams = deepLink.mapQueryParams() val observationId = queryParams["observationId"] if (observationId.isNullOrEmpty() - || observationRepository.observationById(observationId.first()) + || repos.observation.observationById(observationId.first()) .firstOrNull() == null ) { emit(null) return@flow } val schedule = - scheduleRepository.firstScheduleAvailableForObservationId(observationId.first()) + repos.schedule.firstScheduleAvailableForObservationId(observationId.first()) .cancellable().firstOrNull() emit(deepLinkModifier(deepLink, schedule, protocolReplacement, hostReplacement)) 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 index 482ab2970..1be5e2931 100644 --- 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 @@ -11,15 +11,14 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity -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.MainRepository import io.redlink.more.more_app_mutliplatform.models.ScheduleState +import io.redlink.more.more_app_mutliplatform.models.StudyState import io.redlink.more.more_app_mutliplatform.observations.observationTypes.ObservationType +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope 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 @@ -28,16 +27,16 @@ 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(database: AppDatabase, val observationType: ObservationType) { - private val scheduleRepository = ScheduleRepository(database) +abstract class Observation( + private val repos: MainRepository, + val observationType: ObservationType +) { private var dataManager: ObservationDataManager? = null private var notificationManager: NotificationManager? = null - private val observationRepository = ObservationRepository(database) private var running = false private val observationIds = mutableSetOf() @@ -62,7 +61,7 @@ abstract class Observation(database: AppDatabase, val observationType: Observati observationIds.add(observationId) timestampCollectionJob?.cancel() timestampCollectionJob = StudyScope.launch { - observationRepository.collectTimestampForObservationIds(observationIds).collect { + repos.observation.collectTimestampForObservationIds(observationIds).collect { lastCollectionTimestamp = Instant.fromEpochMilliseconds(it) Napier.d(tag = "Observation::start") { "Last collection $lastCollectionTimestamp" } } @@ -136,7 +135,7 @@ abstract class Observation(database: AppDatabase, val observationType: Observati Napier.d(tag = "Observation::collectionTimeStampToNow") { "Collecting timestamp" } lastCollectionTimestamp = Clock.System.now() StudyScope.launch(Dispatchers.IO) { - observationRepository.updateLastCollection( + repos.observation.updateLastCollection( observationIds.toSet(), lastCollectionTimestamp.toEpochMilliseconds() ) @@ -158,15 +157,20 @@ abstract class Observation(database: AppDatabase, val observationType: Observati fun updateObservationErrors() { StudyScope.launch(Dispatchers.IO) { - scheduleRepository.allSchedulesToday(observationType).firstOrNull()?.let { + repos.schedule.allSchedulesToday(observationType).firstOrNull()?.let { if (it.isNotEmpty()) { Napier.d(tag = "Observation::updateObservationErrors") { "ObservationErrors for ${observationType.observationType}" } - _observationErrors.update { - Pair( + if (repos.study.studyState.value == StudyState.ACTIVE) { + _observationErrors.value = Pair( observationType.observationType, observerErrors() ) + } else { + _observationErrors.value = Pair( + observationType.observationType, + emptySet() + ) } } } @@ -213,7 +217,7 @@ abstract class Observation(database: AppDatabase, val observationType: Observati saveAndSend() scheduleIds.keys.forEach { StudyScope.launch(Dispatchers.IO) { - scheduleRepository.setRunningStateFor(it, state) + repos.schedule.setRunningStateFor(it, state) } } scheduleId?.let { @@ -230,7 +234,7 @@ abstract class Observation(database: AppDatabase, val observationType: Observati saveAndSend() scheduleIds.keys.forEach { StudyScope.launch(Dispatchers.IO) { - scheduleRepository.setCompletionStateFor(it, true) + repos.schedule.setCompletionStateFor(it, true) } } observationShutdown(scheduleId) @@ -273,7 +277,7 @@ abstract class Observation(database: AppDatabase, val observationType: Observati fallbackTitle: String = "Error" ) { val schedulesSchemaFlows = scheduleIds.keys.map { - scheduleRepository.scheduleWithId(it) + repos.schedule.scheduleWithId(it) } val combinedFlow = combine(schedulesSchemaFlows) { values -> values.mapNotNull { it } 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 index 3421747f4..905f95df5 100644 --- 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 @@ -12,19 +12,15 @@ 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.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity -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.util.Scope -import io.redlink.more.more_app_mutliplatform.util.StudyScope +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository +import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job -abstract class ObservationDataManager(database: AppDatabase) { - private val observationDataRepository = ObservationDataRepository(database) - private val dataPointCountRepository = DataPointCountRepository(database) +abstract class ObservationDataManager(private val repository: MainRepository) { private var countJob: Job? = null private var scheduleCount = mutableMapOf() @@ -39,22 +35,22 @@ abstract class ObservationDataManager(database: AppDatabase) { 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()) + repository.observationData.addData(dataList) + repository.dataPointCount.incrementCount(scheduleIdList, dataList.size.toLong()) } } fun saveAndSend() { Napier.i(tag = "ObservationDataManager::saveAndSend") { "Saving and sending observations" } StudyScope.launch(Dispatchers.IO) { - observationDataRepository.store() + repository.observationData.store() } } fun store() { Napier.i(tag = "ObservationDataManager::store") { "Storing observations" } StudyScope.launch(Dispatchers.IO) { - observationDataRepository.store() + repository.observationData.store() } } @@ -70,7 +66,7 @@ abstract class ObservationDataManager(database: AppDatabase) { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Starting to listen for changes in datapoint counts" } countJob = Scope.repeatedLaunch(60000, Dispatchers.IO) { if (konnection.isConnected() && (uploadJob == null || uploadJob?.isActive == false)) { - val count = observationDataRepository.getCount() + val count = repository.observationData.getCount() if (count > 0) { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Observation data count: $count! Sending data..." } uploadJob = Scope.launch(Dispatchers.IO) { @@ -99,7 +95,7 @@ abstract class ObservationDataManager(database: AppDatabase) { private fun deleteAll(idSet: Set) { Scope.launch(Dispatchers.IO) { - observationDataRepository.deleteAllWithId(idSet) + repository.observationData.deleteAllWithId(idSet) } } } 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 index c76b6ca5e..f093529b9 100644 --- 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 @@ -11,17 +11,16 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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.scopes.Scope 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 @@ -32,7 +31,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update abstract class ObservationFactory( - database: AppDatabase, + repository: MainRepository, private val dataManager: ObservationDataManager ) { private var credentialRepository: CredentialRepository? = null @@ -47,10 +46,10 @@ abstract class ObservationFactory( private var observationErrorWatcher: Job? = null init { - observations.add(SimpleQuestionObservation(database)) - observations.add(LimeSurveyObservation(database)) + observations.add(SimpleQuestionObservation(repository)) + observations.add(LimeSurveyObservation(repository)) Scope.launch(Dispatchers.IO) { - ObservationRepository(database).observationTypes().collect { + repository.observation.observationTypes().collect { Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } _studyObservationTypes.clear() _studyObservationTypes.appendAll(it) @@ -130,7 +129,7 @@ abstract class ObservationFactory( } fun updateObservationErrors() { - if (this.credentialRepository?.hasCredentials() == true) { + if (this.credentialRepository?.hasCredentials?.value == true) { studyObservations().forEach { it.updateObservationErrors() } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt index 27bef82b7..6735fb361 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt @@ -11,13 +11,10 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -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.repository.MainRepository import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.util.StudyScope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay @@ -27,13 +24,10 @@ import kotlinx.datetime.Clock import kotlin.math.ceil class ObservationManager( - database: AppDatabase, + private val repositories: MainRepository, private val observationFactory: ObservationFactory, private val dataRecorder: DataRecorder ) { - private val scheduleRepository = ScheduleRepository(database) - private val dataPointCountRepository = DataPointCountRepository(database) - private val observationRepository = ObservationRepository(database) private val runningObservations = mutableMapOf() @@ -45,12 +39,12 @@ class ObservationManager( fun activateScheduleUpdate() { Napier.i(tag = "ObservationManager::activateScheduleUpdate") { "ObservationManager: ScheduleUpdater activating..." } StudyScope.launch(Dispatchers.IO) { - scheduleRepository.allSchedulesWithStatus(true).cancellable().collect { list -> + repositories.schedule.allSchedulesWithStatus(true).cancellable().collect { list -> if (runningObservations.isNotEmpty()) { list.filter { it.scheduleId in runningObservations.keys } .map { it.scheduleId }.forEach { stop(it) - dataPointCountRepository.delete(it) + repositories.dataPointCount.delete(it) } } } @@ -63,7 +57,7 @@ class ObservationManager( } } StudyScope.launch(Dispatchers.IO) { - observationRepository.collectAllTimestamps().cancellable().collect { + repositories.observation.collectAllTimestamps().cancellable().collect { upToDateTimestamps = it } } @@ -71,14 +65,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 !in runningObservations.keys }.forEach { - if (start(it.scheduleId)) { - startedObservations.add(it.scheduleId) + 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 } @@ -89,7 +84,7 @@ 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() @@ -170,7 +165,7 @@ class ObservationManager( fun startObservationType(type: String) { StudyScope.launch(Dispatchers.IO) { Napier.d(tag = "ObservationManager::startObservationType") { "Restarting Observations with type: $type" } - scheduleRepository.allSchedulesWithStatus(false) + repositories.schedule.allSchedulesWithStatus(false) .firstOrNull() ?.filter { it.observationType == type && it.getState().active() } ?.forEach { @@ -208,11 +203,11 @@ class ObservationManager( } fun updateTaskStates() { - scheduleRepository.updateTaskStates(observationFactory, dataRecorder) + repositories.schedule.updateTaskStates(observationFactory, dataRecorder) } suspend fun updateTaskStatesWithBLEDevices() { - scheduleRepository.updateTaskStatesWithBLEDevices(observationFactory, dataRecorder) + repositories.schedule.updateTaskStatesWithBLEDevices(observationFactory, dataRecorder) } fun hasRunningTasks() = currentlyRunning.isNotEmpty() @@ -225,9 +220,9 @@ class ObservationManager( runningObs.forEach { (scheduleId, observation) -> observation.stopAndFinish(scheduleId) StudyScope.launch(Dispatchers.IO) { - scheduleRepository.setCompletionStateFor(scheduleId, true) + repositories.schedule.setCompletionStateFor(scheduleId, true) } - dataPointCountRepository.delete(scheduleId) + repositories.dataPointCount.delete(scheduleId) runningObservations.remove(scheduleId) scheduleSchemaList.removeAll { it.scheduleId == scheduleId } currentlyRunning.clear() @@ -258,7 +253,7 @@ class ObservationManager( } private suspend fun findOrCreateObservation(scheduleId: String): ScheduleEntity? { - return scheduleRepository.scheduleWithId(scheduleId).firstOrNull()?.let { + return repositories.schedule.scheduleWithId(scheduleId).firstOrNull()?.let { val fixedScheduleSchema = it Napier.d(tag = "ObservationManager::findOrCreateObservation") { "Found Schema $it" } if (findOrCreateObservation(fixedScheduleSchema)) fixedScheduleSchema else null @@ -284,10 +279,10 @@ class ObservationManager( private fun setObservationState(scheduleId: String, state: ScheduleState) { StudyScope.launch(Dispatchers.IO) { if (state != ScheduleState.DONE) { - scheduleRepository.setRunningStateFor(scheduleId, state) + repositories.schedule.setRunningStateFor(scheduleId, state) } else { - scheduleRepository.setCompletionStateFor(scheduleId, true) - dataPointCountRepository.delete(scheduleId) + repositories.schedule.setCompletionStateFor(scheduleId, true) + repositories.dataPointCount.delete(scheduleId) } } } 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/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt index 2fefe78ac..6fee72fe7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/limesurvey/LimeSurveyObservation.kt @@ -14,14 +14,14 @@ 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.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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 kotlinx.coroutines.flow.MutableStateFlow -class LimeSurveyObservation(database: AppDatabase) : - Observation(database, observationType = LimeSurveyType()) { +class LimeSurveyObservation(repos: MainRepository) : + Observation(repos, observationType = LimeSurveyType()) { val limeURL = MutableStateFlow(null) override fun start(): Boolean { 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/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt index 1d450448a..bf449eef0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt @@ -10,12 +10,12 @@ */ package io.redlink.more.more_app_mutliplatform.observations.simpleQuestionObservation -import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.observations.observationTypes.SimpleQuestionType -class SimpleQuestionObservation(database: AppDatabase) : - Observation(database, observationType = SimpleQuestionType()) { +class SimpleQuestionObservation(repos: MainRepository) : + Observation(repos, observationType = SimpleQuestionType()) { override fun start(): Boolean { return true } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt new file mode 100644 index 000000000..3fb2bac2d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt @@ -0,0 +1,164 @@ +package io.redlink.more.more_app_mutliplatform.registration + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import dev.tmapps.konnection.Konnection +import io.github.aakira.napier.Napier +import io.ktor.util.encodeBase64 +import io.ktor.utils.io.core.toByteArray +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.getPlatform +import io.redlink.more.more_app_mutliplatform.models.CredentialModel +import io.redlink.more.more_app_mutliplatform.models.LoginModel +import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.kotlincrypto.hash.md.MD5 + +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 konnection = Konnection.createInstance() + + private val _connected = MutableStateFlow(false) + + @NativeCoroutines + val connected: StateFlow = _connected + + init { + Scope.launch(Dispatchers.IO) { + konnection.observeHasConnection().collect { + _connected.value = it + Napier.i("Device connected: $it") + } + } + } + + fun getEndpointRepository(): EndpointRepository = shared.endpointRepository + + fun clearError() { + _error.value = null + } + + 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 + }.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 + StudyScope.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) + if (shared.credentialRepository.store(credentialModel)) { + val (study, error) = shared.networkService.getStudyConfig() + _error.value = error + study?.let { study -> + shared.observationFactory.clearNeededObservationTypes() + shared.repositories.study.upsert(study) + shared.resetFirstStartUp() + shared.newLogin() + } ?: run { + if (_error.value == null) { + _error.value = NetworkServiceError(null, "Could not get study") + } + } + } else { + _error.value = NetworkServiceError(null, "Could not store credentials") + } + } + }.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/more_app_mutliplatform/util/Scope.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt similarity index 98% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/Scope.kt rename to shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt index 464ef428e..228350261 100644 --- 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/scopes/Scope.kt @@ -8,10 +8,11 @@ * (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.more_app_mutliplatform.scopes import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.extensions.repeatEveryFewSeconds +import io.redlink.more.more_app_mutliplatform.util.createUUID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope 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/scopes/StudyScope.kt similarity index 97% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StudyScope.kt rename to shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt index fcb7031ba..8e4b492ab 100644 --- 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/scopes/StudyScope.kt @@ -1,4 +1,4 @@ -package io.redlink.more.more_app_mutliplatform.util +package io.redlink.more.more_app_mutliplatform.scopes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart 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 index 55aeb668c..6aca0b062 100644 --- 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 @@ -1,8 +1,8 @@ package io.redlink.more.more_app_mutliplatform.services.bluetooth +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity 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 @@ -11,18 +11,25 @@ import kotlinx.coroutines.flow.StateFlow object BluetoothDeviceManager { 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 @@ -62,20 +69,6 @@ object BluetoothDeviceManager { _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: (BluetoothDeviceEntity) -> Unit) { this.connectedDevices.value.forEach(handler) } 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 index 8c28dec95..6fe1dd4f5 100644 --- 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 @@ -32,10 +32,10 @@ import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.core.Closeable import io.redlink.more.app.android.services.network.errors.NetworkServiceError +import io.redlink.more.more_app_mutliplatform.models.LoginModel 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.PushNotificationToken import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study @@ -56,7 +56,7 @@ class NetworkService( private fun initHttpClientWithAuth(): HttpClient? { initHttpClient() - val creds = credentialRepository.credentials() + val creds = credentialRepository.credentials.value return httpClient?.config { install(Auth) { creds?.let { authCredentials -> @@ -88,7 +88,7 @@ class NetworkService( suspend fun deleteParticipation(): Pair { try { - credentialRepository.credentials()?.let { credentials -> + credentialRepository.credentials.value?.let { credentials -> Napier.i(tag = "NetworkService::deleteParticipation") { "Deleting Participation..." } val client = initHttpClientWithAuth() ?: return Pair( false, @@ -115,20 +115,18 @@ class NetworkService( } } - suspend fun validateRegistrationToken( - registrationToken: String, endpoint: String? = null - ): Pair { + suspend fun validateRegistrationToken(loginModel: LoginModel): Pair { try { Napier.i(tag = "NetworkService::validateRegistrationToken") { "Validating Registration token..." } initHttpClient() - val baseUrl = endpoint ?: endpointRepository.endpoint() + val baseUrl = loginModel.endpoint ?: endpointRepository.endpoint() val client = httpClient ?: return Pair( null, NetworkServiceError(null, "HTTP client not initialized") ) val response = client.get("$baseUrl/registration") { - header("More-Registration-Token", registrationToken) + header("More-Registration-Token", loginModel.token) contentType(ContentType.Application.Json) } @@ -148,19 +146,19 @@ class NetworkService( } suspend fun sendConsent( - registrationToken: String, studyConsent: StudyConsent, endpoint: String? = null + loginModel: LoginModel, studyConsent: StudyConsent ): Pair { try { Napier.i(tag = "NetworkService::sendConsent") { "Sending Consent..." } initHttpClient() - val baseUrl = endpoint ?: endpointRepository.endpoint() + val baseUrl = loginModel.endpoint ?: endpointRepository.endpoint() val client = httpClient ?: return Pair( null, NetworkServiceError(null, "HTTP client not initialized") ) val response = client.post("$baseUrl/registration") { - header("More-Registration-Token", registrationToken) + header("More-Registration-Token", loginModel.token) contentType(ContentType.Application.Json) setBody(studyConsent) } @@ -180,7 +178,7 @@ class NetworkService( suspend fun getStudyConfig(): Pair { try { Napier.i(tag = "NetworkService::getStudyConfig") { "Downloading study data..." } - credentialRepository.credentials()?.let { credentials -> + credentialRepository.credentials.value?.let { credentials -> val client = initHttpClientWithAuth() ?: return Pair( null, NetworkServiceError(null, "Failed to init HTTP client") @@ -208,7 +206,7 @@ class NetworkService( suspend fun sendNotificationToken(token: String): Pair { try { Napier.i(tag = "NetworkService::sendNotificationToken") { "Sending notification token..." } - credentialRepository.credentials()?.let { credentials -> + credentialRepository.credentials.value?.let { credentials -> val client = initHttpClientWithAuth() ?: return Pair( false, NetworkServiceError(null, "Failed to init HTTP client") @@ -237,7 +235,7 @@ class NetworkService( 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()}..." } - credentialRepository.credentials()?.let { credentials -> + credentialRepository.credentials.value?.let { credentials -> val client = initHttpClientWithAuth() ?: return Pair( emptySet(), NetworkServiceError(null, "Failed to init HTTP client") @@ -266,7 +264,7 @@ class NetworkService( suspend fun downloadMissedNotifications(): List { return try { Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloading missed notifications from the Server..." } - credentialRepository.credentials()?.let { credentials -> + credentialRepository.credentials.value?.let { credentials -> val client = initHttpClientWithAuth() ?: return@let emptyList() val baseUrl = endpointRepository.endpoint() @@ -294,7 +292,7 @@ class NetworkService( suspend fun deletePushNotification(msgId: String) { try { - credentialRepository.credentials()?.let { credentials -> + credentialRepository.credentials.value?.let { credentials -> val client = initHttpClientWithAuth() ?: return val baseUrl = endpointRepository.endpoint() @@ -313,25 +311,31 @@ class NetworkService( } } - private fun createErrorBody(code: Int, responseBody: HttpResponse?): NetworkServiceError { + private suspend 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") + val error: Error? = try { + responseBody.body() + } catch (_: Exception) { + null + } + + NetworkServiceError(code = code, message = error?.msg ?: "Error") } catch (e: Exception) { - getException(e) + getException(e, code) } } - private fun getException(exception: Exception): NetworkServiceError { + private fun getException(exception: Exception, code: Int? = null): NetworkServiceError { val errorResponse = "System error!" Napier.e("Exception: ${exception.stackTraceToString()}", tag = TAG) exception.printStackTrace() - return NetworkServiceError(null, errorResponse) + return NetworkServiceError(code, errorResponse) } override fun close() { @@ -341,13 +345,4 @@ class NetworkService( httpClient = null } - private fun serializeToNDJson(logs: List): String { - return buildString { - logs.forEach { log -> - appendLine("{\"index\":{}}") - appendLine(Json.encodeToString(log)) - } - } - } -} - +} \ No newline at end of file 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 000294f61..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/RegistrationService.kt +++ /dev/null @@ -1,154 +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.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 -import io.redlink.more.more_app_mutliplatform.util.validateAndNormalizeUrl -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO - -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 normalizedEndpoint = manualEndpoint?.validateAndNormalizeUrl() - val (result, networkError) = shared.networkService.validateRegistrationToken( - token.uppercase(), - normalizedEndpoint - ) - result?.let { - endpoint = normalizedEndpoint - 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() - shared.removeStudyData() - } - 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(Dispatchers.IO) { - 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() - shared.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/notification/NotificationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt index 98594e2be..2adcc7415 100644 --- 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 @@ -12,21 +12,18 @@ package io.redlink.more.more_app_mutliplatform.services.notification import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity -import io.redlink.more.more_app_mutliplatform.database.repository.NotificationRepository -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.MainRepository import io.redlink.more.more_app_mutliplatform.extensions.mapQueryParams import io.redlink.more.more_app_mutliplatform.models.NotificationModel import io.redlink.more.more_app_mutliplatform.models.NotificationStatusType import io.redlink.more.more_app_mutliplatform.models.ScheduleState 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.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope 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 io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -50,21 +47,18 @@ interface LocalNotificationListener { } class NotificationManager( - database: AppDatabase, + val repository: MainRepository, private val localNotificationListener: LocalNotificationListener, private val networkService: NetworkService, private val deeplinkManager: DeeplinkManager, private val sharedStorageRepository: SharedStorageRepository ) { - val notificationRepository = NotificationRepository(database) val _unreadUserCount = MutableStateFlow(0) val unreadUserCount: StateFlow = _unreadUserCount - private val scheduleRepository = ScheduleRepository(database) - private val observationRepository = ObservationRepository(database) init { Scope.launch(Dispatchers.IO) { - notificationRepository.getUnreadUserNotifications().collect { notificationList -> + repository.notification.getUnreadUserNotifications().collect { notificationList -> _unreadUserCount.update { notificationList.count() } withContext(Dispatchers.Main) { localNotificationListener.updateBadgeCount(notificationList.count()) @@ -121,7 +115,7 @@ class NotificationManager( ) { if (notification.title != null && notification.notificationBody != null) { StudyScope.launch(Dispatchers.IO) { - notificationRepository.storeNotification(notification) + repository.notification.storeNotification(notification) } if (displayNotification) { Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } @@ -132,7 +126,7 @@ class NotificationManager( fun storeNotifications(notifications: List) { StudyScope.launch(Dispatchers.IO) { - notificationRepository.storeNotifications(notifications) + repository.notification.storeNotifications(notifications) } } @@ -145,7 +139,7 @@ class NotificationManager( fun deleteNotificationFromRepository(notificationId: String) { deleteNotificationFromSystemTray(notificationId) - notificationRepository.deleteNotification(notificationId) + repository.notification.deleteNotification(notificationId) } fun deleteNotificationFromServer(msgID: String) { @@ -160,12 +154,12 @@ class NotificationManager( } fun markNotificationAsRead(notificationId: String) { - notificationRepository.setNotificationReadStatus(notificationId, true) + repository.notification.setNotificationReadStatus(notificationId, true) deleteNotificationFromSystemTray(notificationId) } fun markNotificationAsCompleted(notificationId: String) { - notificationRepository.setNotificationCompletedStatus(notificationId, true) + repository.notification.setNotificationCompletedStatus(notificationId, true) deleteNotificationFromSystemTray(notificationId) } @@ -216,11 +210,11 @@ class NotificationManager( val state = checkIfCompletedOrRead(deepLink).cancellable().firstOrNull() if (state != null) { - if (NotificationStatusType.READ == state) notificationRepository.setNotificationReadStatus( + if (NotificationStatusType.READ == state) repository.notification.setNotificationReadStatus( notification.notificationId, true ) - if (NotificationStatusType.COMPLETED == state) notificationRepository.setNotificationCompletedStatus( + if (NotificationStatusType.COMPLETED == state) repository.notification.setNotificationCompletedStatus( notification.notificationId, true ) @@ -259,14 +253,14 @@ class NotificationManager( val queryParams = notificationDeeplink.mapQueryParams() val observationId = queryParams["observationId"] if (observationId.isNullOrEmpty() - || observationRepository.observationById(observationId.first()) + || repository.observation.observationById(observationId.first()) .firstOrNull() == null ) { emit(null) return@flow } val schedule = - scheduleRepository.firstScheduleAvailableForObservationId(observationId.first()) + repository.schedule.firstScheduleAvailableForObservationId(observationId.first()) .cancellable().firstOrNull() // ScheduleState.DEACTIVATED -> ACTIVE -> PAUSE/RUNNING -> ENDED/COMPLETED @@ -316,7 +310,7 @@ class NotificationManager( fun updateNotificationBadgeCount() { Scope.launch { - notificationRepository.getUnreadUserNotifications().firstOrNull().let { + repository.notification.getUnreadUserNotifications().firstOrNull().let { withContext(Dispatchers.Main) { localNotificationListener.updateBadgeCount(it?.count() ?: 0) } 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/more_app_mutliplatform/services/store/CredentialRepository.kt index ab3b2c39c..88bdc64a6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/CredentialRepository.kt @@ -10,20 +10,41 @@ */ package io.redlink.more.more_app_mutliplatform.services.store +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.more_app_mutliplatform.extensions.mapState import io.redlink.more.more_app_mutliplatform.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 + private val _credentialsLoaded = MutableStateFlow(false) + + @NativeCoroutines + val credentialsLoaded: StateFlow = _credentialsLoaded + private var _cache = MutableStateFlow(null) + + @NativeCoroutines + val credentials: StateFlow = _cache + + @NativeCoroutines + 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 { 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 @@ -41,13 +62,9 @@ class CredentialRepository(private val sharedStorageRepository: SharedStorageRep 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" 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/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt index babd29000..fdbe23188 100644 --- 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 @@ -11,55 +11,30 @@ 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 kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext abstract class CoreViewModel : Closeable { - private val mutex = Mutex() - private var viewJobs = mutableSetOf() + protected val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - abstract fun viewDidAppear() + open fun viewDidAppear() {} open fun viewDidDisappear() { - cancelScope() + close() } 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) - } - } + viewModelScope.launch(coroutineContext, block = block) } override fun close() { - cancelScope() + viewModelScope.cancel() } } \ 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 index e95de2ea0..570547917 100644 --- 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 @@ -1,32 +1,38 @@ package io.redlink.more.more_app_mutliplatform.viewModels -import io.redlink.more.more_app_mutliplatform.extensions.asClosure +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines 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) private val _checkingForNewStudyData = MutableStateFlow(false) + + @NativeCoroutines val studyIsUpdating: StateFlow = _studyIsUpdating + + @NativeCoroutines val checkingForNewStudyData: StateFlow = _checkingForNewStudyData + + @NativeCoroutines val showBluetoothView: StateFlow = _showBluetoothView private var bleViewOpen = false fun studyIsUpdating(state: Boolean) { - _studyIsUpdating.set(state) + _studyIsUpdating.value = state + _showBluetoothView.value = false } fun checkingForUpdate(state: Boolean) { - _checkingForNewStudyData.set(state) + _checkingForNewStudyData.value = state } fun showBLEView(state: Boolean): Boolean { - if (!state || !bleViewOpen) { - _showBluetoothView.update { state } + if (!state || !_showBluetoothView.value || !bleViewOpen || !_studyIsUpdating.value) { + _showBluetoothView.value = state return state } return false @@ -36,13 +42,6 @@ object ViewManager { bleViewOpen = state } - fun showBluetoothViewAsClosure(state: (Boolean) -> Unit) = showBluetoothView.asClosure(state) - - fun studyIsUpdatingAsClosure(state: (Boolean) -> Unit) = studyIsUpdating.asClosure(state) - - fun checkingForNewStudyDataAsClosure(state: (Boolean) -> Unit) = - checkingForNewStudyData.asClosure(state) - fun resetAll() { _studyIsUpdating.set(false) _showBluetoothView.set(false) 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 index d809e587c..92712751c 100644 --- 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 @@ -10,22 +10,21 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity 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.scopes.Scope +import io.redlink.more.more_app_mutliplatform.scopes.StudyScope 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.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.Dispatchers @@ -36,15 +35,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.firstOrNull class BluetoothController( - database: AppDatabase, + private val bluetoothDeviceRepository: BluetoothDeviceRepository, private val bluetoothConnector: BluetoothConnector, private val scanDuration: Long = 5000, private val scanInterval: Long = 10000 ) : CoreViewModel(), BluetoothConnectorObserver, Closeable { private val deviceManager = BluetoothDeviceManager - private val bluetoothDeviceRepository = BluetoothDeviceRepository(database) private val _isScanning = MutableStateFlow(false) + + @NativeCoroutines val isScanning: StateFlow = _isScanning private var backgroundScanningEnabled = false @@ -52,7 +52,10 @@ class BluetoothController( private var scanJob: String? = null - val bluetoothPower = MutableStateFlow(BluetoothState.ON) + private val _bluetoothPower = MutableStateFlow(BluetoothState.ON) + + @NativeCoroutines + val bluetoothPower: StateFlow = _bluetoothPower private var bleViewHasBeenOpened = false @@ -136,7 +139,7 @@ class BluetoothController( customScanInterval: Long = scanInterval ) { launchScope { - bluetoothPower.collect { + _bluetoothPower.collect { if (it == BluetoothState.ON) { startPeriodicScan(customScanDuration, customScanInterval) } else { @@ -232,7 +235,7 @@ class BluetoothController( override fun onBluetoothStateChange(bluetoothState: BluetoothState) { Napier.i(tag = "BluetoothController::onBluetoothStateChange") { "Bluetooth state changed to $bluetoothState" } - bluetoothPower.set(bluetoothState) + _bluetoothPower.set(bluetoothState) if (bluetoothState == BluetoothState.OFF) { deviceManager.clearDiscovered() deviceManager.clearConnected() @@ -260,11 +263,6 @@ class BluetoothController( 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 ) { 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/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt index 4fe1bce14..c4e3bd3c0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardFilterViewModel.kt @@ -10,14 +10,13 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.dashboard -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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.scopes.Scope import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull @@ -26,23 +25,19 @@ import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.plus -class CoreDashboardFilterViewModel(database: AppDatabase) : CoreViewModel() { +class CoreDashboardFilterViewModel(repository: MainRepository) : CoreViewModel() { val currentTypeFilter = MutableStateFlow(emptyMap()) val currentDateFilter = MutableStateFlow( DateFilterModel.entries.associateWith { it == DateFilterModel.ENTIRE_TIME }) init { Scope.launch { - ObservationRepository(database).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) { @@ -84,7 +79,7 @@ class CoreDashboardFilterViewModel(database: AppDatabase) : CoreViewModel() { fun filterActive() = activeDateFilter() || activeTypeFilter() fun applyFilter(scheduleModelList: Collection): Collection { - var schedules = scheduleModelList + var schedules = scheduleModelList.toList() if (filterActive()) { if (activeTypeFilter()) { val activeTypes = currentTypeFilter.value.filterValues { it }.keys 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 45b8aa1ed..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/dashboard/CoreDashboardViewModel.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.more_app_mutliplatform.viewModels.dashboard - -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity -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.viewModels.CoreViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.cancellable - -class CoreDashboardViewModel(database: AppDatabase) : CoreViewModel() { - private val studyRepository: StudyRepository = StudyRepository(database) - val study: MutableStateFlow = MutableStateFlow(null) - - override fun viewDidAppear() { - launchScope(Dispatchers.IO) { - studyRepository.getStudy().cancellable().collect { - study.value = it - } - } - } - - fun onLoadStudy(provideNewState: ((StudyEntity?) -> 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 index 094e59cb3..5555e6785 100644 --- 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 @@ -11,9 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.limeSurvey import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -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.MainRepository 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 @@ -27,7 +25,10 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.transform -class CoreLimeSurveyViewModel(database: AppDatabase, observationFactory: ObservationFactory) : +class CoreLimeSurveyViewModel( + private val repositories: MainRepository, + observationFactory: ObservationFactory +) : CoreViewModel() { private var observation: LimeSurveyObservation? = observationFactory.observation("lime-survey-observation") as? LimeSurveyObservation @@ -36,9 +37,6 @@ class CoreLimeSurveyViewModel(database: AppDatabase, observationFactory: Observa val limeSurveyLink: StateFlow? = observation?.limeURL val dataLoading = MutableStateFlow(false) - private val scheduleRepository = ScheduleRepository(database) - private val observationRepository = ObservationRepository(database) - fun setScheduleId(scheduleId: String, notificationId: String?) { if (scheduleId != this.scheduleId) { Napier.i { "Setting scheduleId: $scheduleId for LimeSurvey" } @@ -47,10 +45,10 @@ class CoreLimeSurveyViewModel(database: AppDatabase, observationFactory: Observa this.scheduleId = scheduleId launchScope(Dispatchers.Main) { dataLoading.set(true) - scheduleRepository.scheduleWithId(scheduleId).cancellable() + repositories.schedule.scheduleWithId(scheduleId).cancellable() .transform { scheduleSchema -> emit(scheduleSchema?.let { - observationRepository.observationById(it.observationId) + repositories.observation.observationById(it.observationId) .cancellable().firstOrNull() }) }.cancellable().firstOrNull().let { observationSchema -> @@ -70,7 +68,8 @@ class CoreLimeSurveyViewModel(database: AppDatabase, observationFactory: Observa fun setObservationId(observationId: String, notificationId: String?) { launchScope { - scheduleRepository.firstScheduleIdAvailableForObservationId(observationId).cancellable() + repositories.schedule.firstScheduleIdAvailableForObservationId(observationId) + .cancellable() .firstOrNull()?.let { setScheduleId(it, notificationId) } } } 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 index dcebf9559..772a1871d 100644 --- 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 @@ -10,40 +10,23 @@ */ 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.models.LoginModel +import io.redlink.more.more_app_mutliplatform.registration.RegistrationService 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 + loginModel: LoginModel ) { - if (token.isNotEmpty()) { - loadingFlow.value = true + if (loginModel.valid()) { registrationService.sendRegistrationToken( - token.uppercase(), - endpoint, - onSuccess, - onError - ) { - loadingFlow.value = false - } + loginModel + ) } } - fun onLoadingChange(provideNewState: ((Boolean) -> Unit)) = - loadingFlow.asClosure(provideNewState) - - override fun viewDidAppear() { - + fun clearError() { + registrationService.clearError() } } \ 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/more_app_mutliplatform/viewModels/notifications/CoreNotificationFilterViewModel.kt index fcffe3c2e..a90f840ad 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -10,26 +10,36 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.notifications -import io.redlink.more.more_app_mutliplatform.extensions.asClosure +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.more_app_mutliplatform.extensions.mapState 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow 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,11 +56,7 @@ class CoreNotificationFilterViewModel : CoreViewModel() { } else { filterMap[filter] = true } - filters.set(filterMap) - } - - override fun viewDidAppear() { - + _filters.set(filterMap) } fun setPlatformHighPriority(priority: Long) { @@ -60,11 +66,11 @@ class CoreNotificationFilterViewModel : CoreViewModel() { 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 +79,9 @@ class CoreNotificationFilterViewModel : CoreViewModel() { } else notificationList } - fun filterActive() = filters.value[NotificationFilterTypeModel.ALL] == false + 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) } \ 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/more_app_mutliplatform/viewModels/notifications/CoreNotificationViewModel.kt index ddd23d66a..f16e85ea5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/notifications/CoreNotificationViewModel.kt @@ -10,14 +10,14 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.notifications -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.extensions.asClosure +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable class CoreNotificationViewModel( @@ -27,38 +27,39 @@ class CoreNotificationViewModel( 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) 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/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt index 9f014148d..98be41a9f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/observationDetails/CoreObservationDetailsViewModel.kt @@ -11,9 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.observationDetails import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -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.MainRepository 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 @@ -22,18 +20,15 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine class CoreObservationDetailsViewModel( - database: AppDatabase, + private val repository: MainRepository, private val observationId: String ) : CoreViewModel() { - private val scheduleRepository: ScheduleRepository = ScheduleRepository(database) - private val observationRepository: ObservationRepository = ObservationRepository(database) - val observationDetailsModel = MutableStateFlow(null) 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, diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CoreConsentViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CoreConsentViewModel.kt new file mode 100644 index 000000000..621e66a35 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CoreConsentViewModel.kt @@ -0,0 +1,29 @@ +/* + * 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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.more_app_mutliplatform.extensions.mapState +import io.redlink.more.more_app_mutliplatform.models.PermissionModel +import io.redlink.more.more_app_mutliplatform.registration.RegistrationService +import io.redlink.more.more_app_mutliplatform.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) } + } +} \ 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 index 166bb1a82..c853ae6a3 100644 --- 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 @@ -10,81 +10,100 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.schedules -import io.redlink.more.more_app_mutliplatform.database.AppDatabase +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -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.database.repository.MainRepository +import io.redlink.more.more_app_mutliplatform.extensions.time 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.observations.Observation +import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel +import kotlinx.coroutines.Dispatchers 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 +import kotlinx.coroutines.withContext +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime class CoreScheduleViewModel( - database: AppDatabase, + private val repos: MainRepository, private val dataRecorder: DataRecorder, private val scheduleListType: ScheduleListType, - private val coreFilterModel: CoreDashboardFilterViewModel, + val coreFilterModel: CoreDashboardFilterViewModel, + private val observationFactory: ObservationFactory, ) : CoreViewModel() { - private val scheduleRepository = ScheduleRepository(database) private var originalScheduleList = emptySet() - private val _scheduleListState = MutableStateFlow( - Triple( - emptySet(), - emptySet(), - emptySet() - ) - ) + private val _schedulesByDate = MutableStateFlow>>(emptyMap()) - val scheduleListState: StateFlow, Set, Set>> = - _scheduleListState + @NativeCoroutines + val schedulesByDate: StateFlow>> = _schedulesByDate + + private val _observationErrors = MutableStateFlow>>(emptyMap()) + + @NativeCoroutines + val observationErrors: StateFlow>> = _observationErrors + + private val sortedSchedulesCache = mutableMapOf>() + private var cacheVersion = 0L init { + launchScope { + observationFactory.observationErrors.collect { + val errors = it.mapValues { entry -> + entry.value.filter { it != Observation.ERROR_DEVICE_NOT_CONNECTED }.toSet() + } + withContext(Dispatchers.Main) { + _observationErrors.update { errors } + } + } + } + launchScope { coreFilterModel.currentTypeFilter .combine(coreFilterModel.currentDateFilter) { typeFilter, dateFilter -> - typeFilter.values.any() - && dateFilter[DateFilterModel.ENTIRE_TIME] == false && dateFilter.any { it.value } + typeFilter.any { it.value } + || (dateFilter[DateFilterModel.ENTIRE_TIME] == false && dateFilter.any { it.value }) } - .cancellable().collect { - if (it) { - updateList(coreFilterModel.applyFilter(originalScheduleList).toSet()) + .cancellable().collect { applyFilter -> + if (applyFilter) { + updateSchedulesFromSnapshot( + coreFilterModel.applyFilter(originalScheduleList).toSet() + ) } else { - val copy = originalScheduleList.toSet() - originalScheduleList = emptySet() - updateList(copy) + updateSchedulesFromSnapshot(originalScheduleList) } } } - } - - override fun viewDidAppear() { launchScope { - scheduleRepository.allSchedulesWithStatus(done = scheduleListType == ScheduleListType.COMPLETED) + repos.schedule.allSchedulesWithStatus(done = scheduleListType == ScheduleListType.COMPLETED) .cancellable() - .collect { + .collect { schedules -> val newList = when (scheduleListType) { - ScheduleListType.COMPLETED -> createCompletedModels(it) - ScheduleListType.RUNNING -> createRunningModels(it) - ScheduleListType.MANUALS -> createManualTasks(it) - else -> createModels(it) + 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() - updateList(modified) + updateSchedulesFromSnapshot(modified) } } } @@ -101,37 +120,6 @@ class CoreScheduleViewModel( 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) } @@ -149,7 +137,32 @@ class CoreScheduleViewModel( return createModels(scheduleList.filter { !it.hidden }) } - fun onScheduleStateUpdated(providedState: (Triple, Set, Set>) -> Unit) = - scheduleListState.asClosure(providedState) + fun numberOfObservationErrors(): Int = _observationErrors.value.values.flatten().toSet().count() + + 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++ + } } 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 index 4f73dbf34..eee11bc5c 100644 --- 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 @@ -10,63 +10,52 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.settings -import io.ktor.utils.io.core.Closeable +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity -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.models.PermissionModel import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine class CoreSettingsViewModel( private val shared: Shared ) : CoreViewModel() { - val dataDeleted = MutableStateFlow(false) - private val observationRepository = ObservationRepository(shared.database) + private val _dataDeleted = MutableStateFlow(false) - val study = MutableStateFlow(null) - val observations = MutableStateFlow(emptyList()) + @NativeCoroutines + val dataDeleted: StateFlow = _dataDeleted - val permissionModel = MutableStateFlow(null) + private val _study = MutableStateFlow(null) - override fun viewDidAppear() { + @NativeCoroutines + val study: StateFlow = _study + private val _permissionModel = MutableStateFlow(null) + + @NativeCoroutines + val permissionModel: StateFlow = _permissionModel + + init { launchScope { - shared.studyRepository.getStudy().cancellable() - .combine(observationRepository.observations()) { study, observations -> - Pair(study, observations) - }.cancellable().collect { - if (it.first?.active == true) { - study.value = it.first - it.first?.let { study -> - permissionModel.value = - PermissionModel.createFromSchema(study, it.second) - } + shared.repositories.study.study.combine(shared.repositories.observation.observations()) { study, observations -> + Pair(study, observations) + }.cancellable().collect { + if (it.first?.active == true) { + _study.value = it.first + it.first?.let { study -> + _permissionModel.value = + PermissionModel.createFromSchema(study, it.second) } } - } - launchScope { - observationRepository.observations().cancellable().collect { - observations.value = it } } } - - fun onLoadStudy(provideNewState: ((StudyEntity?) -> Unit)): Closeable { - return study.asClosure(provideNewState) - } - - fun onPermissionChange(provideNewState: (PermissionModel?) -> Unit): Closeable { - return permissionModel.asClosure(provideNewState) - } - + fun exitStudy() { shared.exitStudy { - dataDeleted.value = true + _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 index 1f0cfecc5..13da70964 100644 --- 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 @@ -11,9 +11,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.simpleQuestion import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -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.MainRepository 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 @@ -25,12 +23,10 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull class SimpleQuestionCoreViewModel( - appDatabase: AppDatabase, + private val repository: MainRepository, observationFactory: ObservationFactory, ) : CoreViewModel() { private var scheduleId: String? = null - private val scheduleRepository: ScheduleRepository = ScheduleRepository(appDatabase) - private val observationRepository: ObservationRepository = ObservationRepository(appDatabase) val simpleQuestionModel = MutableStateFlow(null) private var observation: Observation? = @@ -42,24 +38,25 @@ class SimpleQuestionCoreViewModel( this.scheduleId = scheduleId this.notificationId = notificationId launchScope { - scheduleRepository.scheduleWithId(scheduleId).cancellable().firstOrNull() + repository.schedule.scheduleWithId(scheduleId).cancellable().firstOrNull() ?.let { scheduleSchema -> - observationRepository.observationById(scheduleSchema.observationId) + repository.observation.observationById(scheduleSchema.observationId) .cancellable().firstOrNull()?.let { observationSchema -> - simpleQuestionModel.emit( - SimpleQuestionModel.createModelFrom( - observationSchema, - scheduleId + simpleQuestionModel.emit( + SimpleQuestionModel.createModelFrom( + observationSchema, + scheduleId + ) ) - ) - } + } } } } fun setScheduleViaObservationId(observationId: String, notificationId: String? = null) { launchScope { - scheduleRepository.firstScheduleIdAvailableForObservationId(observationId).cancellable() + repository.schedule.firstScheduleIdAvailableForObservationId(observationId) + .cancellable() .firstOrNull()?.let { setScheduleId(it, notificationId) } } } 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/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt index a9d87d232..21713eafb 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt @@ -10,24 +10,25 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.startupConnection +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity -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.viewModels.CoreViewModel import io.redlink.more.more_app_mutliplatform.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 viewDidAppear() { coreBluetooth.viewDidAppear() } override fun viewDidDisappear() { - super.viewDidDisappear() coreBluetooth.viewDidDisappear() } @@ -38,7 +39,4 @@ class CoreBluetoothViewModel( 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/more_app_mutliplatform/viewModels/studydetails/CoreStudyDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/studydetails/CoreStudyDetailsViewModel.kt index a25228bc5..b12f0e86a 100644 --- 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 @@ -10,52 +10,43 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.studydetails -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.more_app_mutliplatform.Shared import io.redlink.more.more_app_mutliplatform.models.StudyDetailsModel import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO 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(appDatabase: AppDatabase) : CoreViewModel() { - val studyModel = MutableStateFlow(null) - val studyRepository = StudyRepository(appDatabase) - val scheduleRepository = ScheduleRepository(appDatabase) - val observationRepository = ObservationRepository(appDatabase) +class CoreStudyDetailsViewModel(shared: Shared) : CoreViewModel() { + private val _studyModel = MutableStateFlow(null) - fun onLoadStudyDetails(provideNewState: ((StudyDetailsModel?) -> Unit)): Closeable { - return studyModel.asClosure(provideNewState) - } - - override fun viewDidAppear() { - launchScope(Dispatchers.IO) { - val taskCount: Int = scheduleRepository.count().cancellable().firstOrNull() ?: 0 + @NativeCoroutines + val studyModel: StateFlow = _studyModel - studyRepository.getStudy().cancellable() - .combine( - scheduleRepository.allSchedulesWithStatus(true).cancellable() - ) { study, doneTasks -> - Pair(study, doneTasks.size) - }.combine( - observationRepository.observations().cancellable() - ) { (study, doneTaskCount), observations -> - study?.let { studySchema -> - studyModel.value = StudyDetailsModel.createModelFrom( - studySchema, - observations.sortedBy { it.observationTitle }, + 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(), - doneTaskCount.toLong() + schedules.size.toLong() ) } - }.collect { } + }.collect { studyDetailsModel -> + _studyModel.value = studyDetailsModel + } } } } \ 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 index e0f1ab562..8024a8402 100644 --- 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 @@ -10,41 +10,35 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.taskCompletionBar -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository -import io.redlink.more.more_app_mutliplatform.extensions.asClosure +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine -class CoreTaskCompletionBarViewModel(database: AppDatabase) : CoreViewModel() { - val taskCompletion: MutableStateFlow = MutableStateFlow(TaskCompletion()) - private val repository = ScheduleRepository(database) +class CoreTaskCompletionBarViewModel(private val repository: MainRepository) : CoreViewModel() { + private val _taskCompletion: MutableStateFlow = + MutableStateFlow(TaskCompletion()) + + @NativeCoroutines + val taskCompletion: StateFlow = _taskCompletion init { launchScope { - repository.count() + repository.schedule.count() .combine( - repository.allSchedulesWithStatus(true).cancellable() + repository.schedule.allSchedulesWithStatus(true).cancellable() ) { scheduleCount, doneSchedules -> TaskCompletion( doneSchedules.size, scheduleCount ) }.cancellable().collect { - taskCompletion.emit(it) + _taskCompletion.value = 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 index 53ed72668..f37ae8a43 100644 --- 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 @@ -11,53 +11,71 @@ package io.redlink.more.more_app_mutliplatform.viewModels.tasks import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -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.repository.MainRepository 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.observations.Observation +import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull class CoreTaskDetailsViewModel( - database: AppDatabase, - private val dataRecorder: DataRecorder + private val repository: MainRepository, + private val dataRecorder: DataRecorder, + private val observationFactory: ObservationFactory, + private var scheduleId: String ) : CoreViewModel() { - private var scheduleId: String? = null - private val dataPointCountRepository: DataPointCountRepository = - DataPointCountRepository(database) - private val observationRepository: ObservationRepository = ObservationRepository(database) - private val scheduleRepository: ScheduleRepository = ScheduleRepository(database) - val taskDetailsModel = MutableStateFlow(null) - val dataCount = MutableStateFlow(0) + 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 - 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)) + 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 { - dataPointCountRepository.get(it).cancellable().collect { - it?.let { - dataCount.emit(it.count) + } + launchScope { + repository.dataPointCount.get(scheduleId).cancellable().collect { + it?.let { + _dataCount.emit(it.count) + } + } + } + launchScope { + observationFactory.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() + } } } } @@ -65,19 +83,19 @@ class CoreTaskDetailsViewModel( } fun onLoadTaskDetails(provideNewState: ((TaskDetailsModel?) -> Unit)): Closeable = - taskDetailsModel.asClosure(provideNewState) + _taskDetailsModel.asClosure(provideNewState) - fun onNewDataCount(provideNewState: (Long?) -> Unit) = dataCount.asClosure(provideNewState) + fun onNewDataCount(provideNewState: (Long?) -> Unit) = _dataCount.asClosure(provideNewState) fun startObservation() { - scheduleId?.let { dataRecorder.start(it) } + dataRecorder.start(scheduleId) } fun stopObservation() { - scheduleId?.let { dataRecorder.stop(it) } + dataRecorder.stop(scheduleId) } fun pauseObservation() { - scheduleId?.let { dataRecorder.pause(it) } + dataRecorder.pause(scheduleId) } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/GreetingTests.kt b/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/GreetingTests.kt deleted file mode 100644 index 06495dfd6..000000000 --- a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/GreetingTests.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 - -class GreetingTests { -} \ No newline at end of file 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 From f0e3b69e1e93ccafc424b4ce167c6ffbf48d8d5a Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:27:39 +0200 Subject: [PATCH 35/59] #331: Fixed a bug where the app would get stuck in "Study loading" view if the study was not stored correctly in the app and the server could also not be used as it is not reachable or the study has a non-active state. (#332) - Added a view StudyLoadingErrorView that allow the user to exit the study of such problem occurs. - Renamed a few AlertDialogModel.kt parameters for better understandability and added default AlertDialog closing on any button press - Also fixed an issue in BluetoothController.kt that would crash the app sometimes --- .../app/android/activities/ContentActivity.kt | 8 +- .../activities/bluetooth/BluetoothActivity.kt | 3 +- .../activities/consent/ConsentViewModel.kt | 20 ++- .../activities/login/LoginViewModel.kt | 6 +- .../android/activities/main/MainActivity.kt | 33 ++++- .../android/activities/main/MainViewModel.kt | 11 -- .../limeSurvey/LimeSurveyActivity.kt | 3 +- .../studyStates/StudyLoadingErrorView.kt | 117 ++++++++++++++++++ .../observations/ObservationExtension.kt | 23 ++-- .../shared_composables/MessageAlertDialog.kt | 8 +- .../MoreBackgroundComposable.kt | 9 +- .../res/values-de/alert-dialog-strings.xml | 2 + .../res/values-de/study_states_strings.xml | 4 +- .../main/res/values/alert-dialog-strings.xml | 2 + .../main/res/values/study_states_strings.xml | 4 +- iosApp/iosApp.xcodeproj/project.pbxproj | 4 + iosApp/iosApp/ContentView.swift | 24 ++-- .../Resources/Strings/Localizable.xcstrings | 68 +++++++--- .../iosApp/Services/PermissionManager.swift | 40 +++--- .../Views/Components/MoreAlertDialog.swift | 34 ++--- .../Views/Consent/ConsentViewModel.swift | 76 ++++++------ .../LeaveStudyConfirmationView.swift | 27 ++-- iosApp/iosApp/Views/Login/LoginView.swift | 8 +- .../iosApp/Views/Login/LoginViewModel.swift | 4 +- .../iosApp/Views/NavigationModalState.swift | 54 ++++---- .../Registration/RegistrationObservable.swift | 91 ++++++++------ .../StudyStates/StudyLoadingErrorView.swift | 52 ++++++++ .../more_app_mutliplatform/AlertController.kt | 23 ++-- .../more/more_app_mutliplatform/Shared.kt | 10 ++ .../extensions/FunctionExtension.kt | 3 + .../models/AlertDialogModel.kt | 16 +-- .../viewModels/ViewManager.kt | 15 ++- .../BluetoothController.kt | 2 +- 33 files changed, 545 insertions(+), 259 deletions(-) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingErrorView.kt create mode 100644 iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt 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 dc748cc59..c58234262 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 @@ -22,12 +22,14 @@ 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.more_app_mutliplatform.viewModels.ViewManager import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -78,7 +80,9 @@ fun ContentView(viewModel: ContentViewModel) { val validLogin by viewModel.registrationService.validLoginModel.collectAsStateWithLifecycle() val hasCredentials by MoreApplication.shared!!.credentialRepository.hasCredentials.collectAsStateWithLifecycle() val credentialsLoaded by MoreApplication.shared!!.credentialRepository.credentialsLoaded.collectAsStateWithLifecycle() - MoreBackground(showBackButton = false, alertDialogModel = viewModel.alertDialogOpen.value) { + val studyLoadingError by ViewManager.studyLoadingError.collectAsStateWithLifecycle() + + MoreBackground(showBackButton = false) { if (credentialsLoaded && !hasCredentials) { if (validLogin != null) { ConsentView(viewModel.registrationService) @@ -86,6 +90,8 @@ fun ContentView(viewModel: ContentViewModel) { LoginView(viewModel.registrationService) AppVersion() } + } else if (studyLoadingError) { + StudyLoadingErrorView() } else { StudyLoadingView() } 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 ff4de9338..7392cd914 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 @@ -105,8 +105,7 @@ fun LoginBLESetupView(viewModel: BluetoothViewModel, showDescrPart2: Boolean) { showBackButton = true, onBackButtonClick = { (context as? Activity)?.finish() - }, - alertDialogModel = viewModel.alertDialogOpen.value + } ) { LazyColumn { item { 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 a98f355b4..d29984024 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 @@ -38,15 +38,13 @@ class ConsentViewModel( 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 = { + confirmLabel = stringResource(R.string.proceed_to_settings_button), + cancelLabel = stringResource(R.string.proceed_without_granting_button), + onConfirm = { MoreApplication.openSettings.value = true - AlertController.closeAlertDialog() }, - onNegative = { + onDecline = { acceptConsent(context) - AlertController.closeAlertDialog() } )) } @@ -56,15 +54,13 @@ class ConsentViewModel( 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 = { + confirmLabel = stringResource(R.string.proceed_to_settings_button), + cancelLabel = stringResource(R.string.proceed_without_granting_button), + onConfirm = { MoreApplication.openSettings.value = true - AlertController.closeAlertDialog() }, - onNegative = { + onDecline = { acceptConsent(context) - AlertController.closeAlertDialog() } )) } 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 01aca96bc..11a4b31dd 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 @@ -27,7 +27,7 @@ class LoginViewModel( val isLoading = registrationService.isLoading val error = registrationService.error - val dataEndpoint = mutableStateOf(""); + val dataEndpoint = mutableStateOf("") val defaultEndpoint = mutableStateOf(registrationService.getEndpointRepository().endpoint()) val endpointError = mutableStateOf(null) @@ -47,7 +47,7 @@ class LoginViewModel( AlertDialogModel( stringResource(R.string.more_token_error), stringResource(R.string.more_404), - positiveTitle = "Ok" + confirmLabel = "Ok" ) ) } @@ -55,7 +55,7 @@ class LoginViewModel( val dialogModel = AlertDialogModel( title = stringResource(R.string.no_internet_connection_title), message = stringResource(R.string.no_internet_connection_body), - positiveTitle = "Ok" + confirmLabel = "Ok" ) AlertController.openAlertDialog(dialogModel) } 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 95d6e40e6..4b47ad1ad 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 @@ -51,6 +51,7 @@ 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 @@ -106,6 +107,7 @@ class MainActivity : ComponentActivity() { 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) @@ -113,9 +115,8 @@ class MainActivity : ComponentActivity() { if (studyIsUpdating) { StudyUpdateView() - navHostController.navigate( - NavigationScreen.DASHBOARD.navigationRoute() - ) + } else if (studyLoadingError) { + StudyLoadingErrorView() } else if (studyState == StudyState.PAUSED) { StudyPausedView() } else if (studyState == StudyState.CLOSED) { @@ -127,7 +128,8 @@ class MainActivity : ComponentActivity() { viewModel.navigationBarTitle.value, viewModel, navHostController, - activityLauncher + activityLauncher, + studyState ) } } @@ -139,7 +141,8 @@ 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() } @@ -159,7 +162,6 @@ fun MainView( } }, unreadNotificationCount = viewModel.unreadNotificationCount.intValue, - alertDialogModel = viewModel.alertDialogOpen.value ) { NavHost( navController = navController, @@ -449,5 +451,24 @@ fun MainView( } } } + 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 + } + } + } + } } } 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 205e882ce..7b79c68de 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 @@ -24,8 +24,6 @@ import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewMod 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.studyDetails.observationDetails.ObservationDetailsViewModel -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.viewModels.ViewManager import io.redlink.more.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel @@ -61,18 +59,9 @@ class MainViewModel(context: Context) : ViewModel() { QuestionnaireViewModel() } - val alertDialogOpen = mutableStateOf(null) private var lastBleViewState = false init { - viewModelScope.launch(Dispatchers.IO) { - AlertController.alertDialogModel.collect { - withContext(Dispatchers.Main) { - alertDialogOpen.value = it - } - } - } - viewModelScope.launch(Dispatchers.IO) { MoreApplication.shared!!.unreadNotificationCount.collect { withContext(Dispatchers.Main) { 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 0d31f7c66..eec98fe81 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 @@ -156,8 +156,7 @@ fun LimeSurveyView(viewModel: LimeSurveyViewModel, webView: WebView?) { ) } } - }, - alertDialogModel = viewModel.alertDialogOpen.value + } ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (viewModel.dataLoading.value) { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingErrorView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingErrorView.kt new file mode 100644 index 000000000..d7e7cda84 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingErrorView.kt @@ -0,0 +1,117 @@ +package io.redlink.more.app.android.activities.studyStates + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.work.WorkManager +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.extensions.Image +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.extensions.showNewActivityAndClearStack +import io.redlink.more.app.android.shared_composables.MediumTitle +import io.redlink.more.app.android.shared_composables.MoreBackground +import io.redlink.more.app.android.shared_composables.SmallTextButton +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.moreImportant +import io.redlink.more.more_app_mutliplatform.AlertController +import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel + +@Composable +fun StudyLoadingErrorView() { + val context = LocalContext.current + MoreBackground { + Box( + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 16.dp) + ) { + 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) + ) + } + Title( + text = getStringResource(id = R.string.study_loading_error_title), + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 12.dp) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + MediumTitle( + getStringResource(R.string.study_loading_error_message), + textAlign = TextAlign.Center + ) + } + } + + SmallTextButton( + text = getStringResource(id = R.string.more_settings_resign_confirm), + buttonColors = ButtonDefaults.moreImportant(), + borderStroke = MoreColors.borderImportant(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp) + ) { + AlertController.openAlertDialog( + AlertDialogModel( + title = context.getString(R.string.more_settings_withdraw_question_confirm), + message = context.getString(R.string.more_settings_withdraw_statement_long), + confirmLabel = context.getString(R.string.more_settings_resign_confirm), + cancelLabel = context.getString(R.string.more_settings_continue), + onConfirm = { + WorkManager.getInstance(context).cancelAllWork() + MoreApplication.shared!!.exitStudy { + (context as? Activity)?.let { activity -> + activity.finish() + showNewActivityAndClearStack( + activity, + ContentActivity::class.java + ) + } + } + } + ) + ) + } + } + } +} + +@Preview +@Composable +fun StudyLoadingErrorPreview() { + StudyLoadingErrorView() +} \ No newline at end of file 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..2f55edc5f 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 @@ -9,19 +9,16 @@ import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.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() - } - )) + AlertController.openAlertDialog( + AlertDialogModel( + title = stringResource(R.string.required_permissions_not_granted_title), + message = stringResource(R.string.required_permission_not_granted_message), + confirmLabel = stringResource(R.string.proceed_to_settings_button), + cancelLabel = stringResource(R.string.proceed_without_granting_button), + onConfirm = { + MoreApplication.openSettings.value = true + } + )) } fun Observation.Companion.pauseObservation(observationType: ObservationType) { 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 66c138716..54e5b17f0 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 @@ -27,10 +27,10 @@ fun MessageAlertDialog(model: AlertDialogModel) { MessageAlertDialog( title = model.title, message = model.message, - positiveButtonTitle = model.positiveTitle, - negativeButtonTitle = model.negativeTitle, - onPositive = model.onPositive, - onNegative = model.onNegative + positiveButtonTitle = model.confirmLabel, + negativeButtonTitle = model.cancelLabel, + onPositive = model.onConfirm, + onNegative = model.onDecline ) } 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 1ecb1a502..9033701ff 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,19 @@ 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.more_app_mutliplatform.AlertController @Composable fun MoreBackground( @@ -54,11 +56,11 @@ 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 + val alertDialogModel by AlertController.alertDialogModel.collectAsStateWithLifecycle(null) if (MoreApplication.openSettings.value) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", context.packageName, null) @@ -182,8 +184,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/res/values-de/alert-dialog-strings.xml b/androidApp/src/main/res/values-de/alert-dialog-strings.xml index 4a5bbcc4c..6a69ddd79 100644 --- a/androidApp/src/main/res/values-de/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values-de/alert-dialog-strings.xml @@ -8,4 +8,6 @@ Ohne Berechtigungen fortfahren 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/study_states_strings.xml b/androidApp/src/main/res/values-de/study_states_strings.xml index 737fe7ff2..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,8 +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... + 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 900ba4da1..ea09ebbcf 100644 --- a/androidApp/src/main/res/values/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values/alert-dialog-strings.xml @@ -8,4 +8,6 @@ Proceed Without Granting 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/study_states_strings.xml b/androidApp/src/main/res/values/study_states_strings.xml index 766178ab7..e3d599802 100644 --- a/androidApp/src/main/res/values/study_states_strings.xml +++ b/androidApp/src/main/res/values/study_states_strings.xml @@ -4,8 +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 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/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c75e19cd5..3a19277fb 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */; }; 1FA763E82A42F834007C1CF9 /* NotificationFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA763E62A42F834007C1CF9 /* NotificationFilterViewModel.swift */; }; 1FA763E92A42F834007C1CF9 /* NotificationFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA763E72A42F834007C1CF9 /* NotificationFilterView.swift */; }; + 1FB338B72E853936006BA594 /* StudyLoadingErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */; }; 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 */; }; @@ -315,6 +316,7 @@ 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MoreColors.xcassets; 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 = ""; }; + 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyLoadingErrorView.swift; 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 = ""; }; @@ -477,6 +479,7 @@ 1F750CAC2A6FA771006E455E /* StudyPausedView.swift */, 1F750CAE2A6FA8AE006E455E /* StudyClosedView.swift */, 1F29C68B2E7A78CA003693C5 /* StudyLoadingView.swift */, + 1FB338B62E853936006BA594 /* StudyLoadingErrorView.swift */, ); path = StudyStates; sourceTree = ""; @@ -1189,6 +1192,7 @@ B708884D2DF2EE600048A4AC /* CameraPreviewView.swift in Sources */, 1F2C4BDD2A6F976F00C29888 /* StudyUpdateView.swift in Sources */, EDCCC31229DAC3D6006D252F /* ObservationTimeDetails.swift in Sources */, + 1FB338B72E853936006BA594 /* StudyLoadingErrorView.swift in Sources */, 07F67D8A29EECF1A006DCFB5 /* BasicNavLinkButton.swift in Sources */, B79DBA3B29D3135E00A1F547 /* ForwardButton.swift in Sources */, 1F34797529B8BECB0030CA15 /* AccelerometerObservation.swift in Sources */, diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 266db94f9..5f22d8814 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -15,10 +15,10 @@ struct ContentView: View { @StateObject private var navigationModalState = AppDelegate.navigationScreenHandler var body: some View { ZStack { - MoreMainBackgroundView() { + MoreMainBackgroundView { VStack { if viewModel.hasCredentials { - CredentialsView(navigationModalState: navigationModalState, viewModel: viewModel) + CredentialsView(navigationModalState: navigationModalState, viewModel: viewModel) } else if !viewModel.credentialsLoaded || (viewModel.hasCredentials && navigationModalState.currentStudyState == .none) { StudyLoadingView() .padding(.horizontal, navigationModalState.horizontalContentPadding) @@ -43,16 +43,18 @@ struct CredentialsView: View { var body: some View { VStack { 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() - .padding(.horizontal, navigationModalState.horizontalContentPadding) + 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) { diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index b4680e715..c84a227c1 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -820,6 +820,22 @@ } } }, + "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" : { @@ -1821,22 +1837,6 @@ } } }, - "second_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!" - } - } - } - }, "Select Time" : { "extractionState" : "manual", "localizations" : { @@ -2131,12 +2131,46 @@ } } }, + "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" : "needs_review", + "state" : "translated", "value" : "Die Studie wird gerade aktualisiert" } }, diff --git a/iosApp/iosApp/Services/PermissionManager.swift b/iosApp/iosApp/Services/PermissionManager.swift index 93ffde37f..e5f99c669 100644 --- a/iosApp/iosApp/Services/PermissionManager.swift +++ b/iosApp/iosApp/Services/PermissionManager.swift @@ -82,14 +82,16 @@ 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( + 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() } @@ -278,23 +280,25 @@ extension PermissionManager: CLLocationManagerDelegate { gpsStatus = .accepted } } - + private static var 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( + 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/Views/Components/MoreAlertDialog.swift b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift index 1552bab4d..6c4fd772e 100644 --- a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift +++ b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift @@ -11,7 +11,7 @@ import SwiftUI struct MoreAlertDialog: View { let alertDialogModel: AlertDialogModel - + var body: some View { ZStack { Color.black.opacity(0.5) @@ -39,24 +39,24 @@ struct MoreAlertDialog: View { VStack { MoreActionButton(disabled: .constant(false)) { - if let onPositive = alertDialogModel.onPositive { - onPositive() + if let onConfirm = alertDialogModel.onConfirm { + onConfirm() } } label: { - Text(LocalizedStringKey(alertDialogModel.positiveTitle)) + Text(LocalizedStringKey(alertDialogModel.confirmLabel)) } - if let negativeTitle = alertDialogModel.negativeTitle { + if let cancelLabel = alertDialogModel.cancelLabel { MoreActionButton(backgroundColor: .more.secondaryLight, disabled: .constant(false)) { - if let onNegative = alertDialogModel.onNegative { - onNegative() + if let onDecline = alertDialogModel.onDecline { + onDecline() } } label: { if #available(iOS 17.0, *) { - Text(LocalizedStringKey(negativeTitle)) + Text(LocalizedStringKey(cancelLabel)) .foregroundStyle(Color.more.primary) } else { - Text(LocalizedStringKey(negativeTitle)) + Text(LocalizedStringKey(cancelLabel)) .foregroundColor(.more.primary) } } @@ -76,9 +76,15 @@ 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( + 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/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index 73a7a867e..a443209bd 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -7,45 +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 shared -import UIKit import AVFoundation -import KMPNativeCoroutinesCombine import Combine +import KMPNativeCoroutinesCombine +import shared +import UIKit class ConsentViewModel: ObservableObject { private let coreModel: CoreConsentViewModel private let registration: RegistrationService - var consentInfo: String? = nil + var consentInfo: String? private let stringTable = "SettingsView" - + @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) { - self.registration = registrationService + 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) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] model in + self?.permissionModel = model + } + .store(in: &cancellables) } - + func onAppear() { permissionManager.observer = self } @@ -53,23 +54,23 @@ class ConsentViewModel: ObservableObject { func onDisappear() { permissionManager.observer = nil } - + func resetPermissionRequest() { - self.requestedPermissions = false - self.permissionManager.resetRequest() + requestedPermissions = false + permissionManager.resetRequest() } func requestPermissions() { - self.requestedPermissions = true + requestedPermissions = true permissionManager.requestPermission(permissionRequest: true) } - + private func acceptConsent() { if let uniqueId = UIDevice.current.identifierForVendor?.uuidString { - self.registration.acceptConsent(uniqueDeviceId: uniqueId) + registration.acceptConsent(uniqueDeviceId: uniqueId) } } - + func decline() { registration.declineConsent() } @@ -79,17 +80,22 @@ extension ConsentViewModel: PermissionManagerObserver { func accepted() { Task { @MainActor in 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: { - self.acceptConsent() - self.requestedPermissions = false - AlertController.shared.closeAlertDialog() - })) + 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?", + 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 diff --git a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift index 594764944..c8b98833f 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift @@ -14,18 +14,19 @@ // import SwiftUI + struct LeaveStudyConfirmationView: View { @StateObject var viewModel: SettingsViewModel @EnvironmentObject private var navigationModalState: NavigationModalState - + @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 { @@ -33,28 +34,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("second_message") + + Text("leave_confirmation_message") .foregroundColor(Color.more.secondary) .padding(.bottom, 2) .multilineTextAlignment(.center) - + 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) @@ -65,14 +66,14 @@ struct LeaveStudyConfirmationView: View { Text("continue_study").foregroundColor(Color.more.white) } .padding(.bottom, 2) - + MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { viewModel.leaveStudy() navigationModalState.clearViews() } label: { Text("withdraw") } - + Spacer() } .padding(.horizontal, 40) diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 91f86a3d3..91824be0a 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -97,8 +97,12 @@ struct LoginView: View { if registration.connected { model.validate() } else { - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "no_internet_title", message: "no_internet_message", positiveTitle: "Ok", negativeTitle: nil) { AlertController.shared.closeAlertDialog() - }) + AlertController.shared.openAlertDialog(model: AlertDialogModel( + title: "no_internet_title", + message: "no_internet_message", + confirmLabel: "Ok", + cancelLabel: nil, onConfirm: nil) + ) } } } diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index 88058b9e0..1afa78d78 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -57,9 +57,7 @@ class LoginViewModel: ObservableObject { if loginModel.valid() { registrationService.sendRegistrationToken(loginModel: loginModel) } else { - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "Token or Endpoint invalid", message: "login_model_invalid_body", positiveTitle: "Ok", negativeTitle: nil, onPositive: { - AlertController.shared.closeAlertDialog() - }, onNegative: nil)) + AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "Token or Endpoint invalid", message: "login_model_invalid_body", confirmLabel: "Ok", cancelLabel: nil, onConfirm: nil, onDecline: nil)) } } diff --git a/iosApp/iosApp/Views/NavigationModalState.swift b/iosApp/iosApp/Views/NavigationModalState.swift index 8b8595cfe..717d45eca 100644 --- a/iosApp/iosApp/Views/NavigationModalState.swift +++ b/iosApp/iosApp/Views/NavigationModalState.swift @@ -13,10 +13,10 @@ // https://commonsclause.com/). // -import shared -import SwiftUI import Combine import KMPNativeCoroutinesCombine +import shared +import SwiftUI struct NavigationState: Hashable { var scheduleId: String? = nil @@ -31,7 +31,6 @@ struct NavigationActions { } class NavigationModalState: ObservableObject { - let horizontalContentPadding: CGFloat = 24 @Published var navigationStack: [NavigationScreen] = [] @@ -45,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 { @@ -52,20 +53,28 @@ 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() - } + .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) + } + .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) } func screenBinding(for screen: NavigationScreen) -> Binding { @@ -182,21 +191,23 @@ 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 + !studyIsUpdating && !studyLoadingError && currentStudyState == StudyState.active || currentStudyState == StudyState.none } func openWithDeepLink(url: URL, notificationId: String? = nil) { - guard !ViewManager.shared.studyIsUpdatingValue else { return } + guard !ViewManager.shared.studyIsUpdatingValue else { + return + } AppDelegate.shared.deeplinkManager.modifyDeepLink(deepLink: url.absoluteString, protocolReplacement: nil, hostReplacement: nil) { modifiedDeepLink in if let modifiedDeepLink, let modifiedURL = URL(string: modifiedDeepLink) { @@ -204,27 +215,26 @@ class NavigationModalState: ObservableObject { 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 } } - + let observationId = parameters[.observationId] let notificationId = parameters[.notificaitonId] ?? notificationId let scheduleId = parameters[.scheduleId] - + if let notificationId { AppDelegate.shared.notificationManager.handleNotificationInteraction(notificationId: notificationId, deeplink: modifiedDeepLink) } - + self.openView(screen: matchingScreen, scheduleId: scheduleId, observationId: observationId, notificationId: notificationId) } } else if modifiedDeepLink == nil, let notificationId { AppDelegate.shared.notificationManager.markNotificationAsRead(notificationId: notificationId) } } - } } diff --git a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift index dc6edc986..e0c37c419 100644 --- a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift +++ b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift @@ -6,61 +6,76 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // +import Combine import Foundation -import shared import KMPNativeCoroutinesCombine -import Combine +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) - + .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) - + .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] error in - self?.error = error - if error != nil && self?.study != nil { - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "consent_error_title", message: "consent_error_body", positiveTitle: "Ok", negativeTitle: nil, onPositive: nil)) - } - }.store(in: &cancellables) - + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] error in + self?.error = error + if error != nil && self?.study != nil { + AlertController.shared.openAlertDialog(model: AlertDialogModel( + 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) - + .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) + .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/StudyStates/StudyLoadingErrorView.swift b/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift new file mode 100644 index 000000000..1c79600c3 --- /dev/null +++ b/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift @@ -0,0 +1,52 @@ +// +// StudyLoadingErrorView.swift +// More +// +// Created by Jan Cortiel on 25.09.25. +// Copyright © 2025 Redlink GmbH. All rights reserved. +// + +import shared +import SwiftUI + +struct StudyLoadingErrorView: View { + @EnvironmentObject private var navigationModalState: NavigationModalState + var body: some View { + VStack(alignment: .center) { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(Color.more.important) + .padding() + Title(titleText: "sure_message", textAlignment: .center) + .padding(.bottom, 8) + Title2(titleText: "leave_confirmation_message", textAlignment: .center) + Spacer() + + MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { + withdraw() + } label: { + Text("withdraw") + } + } + } + + private func withdraw() { + AlertController.shared.openAlertDialog(model: AlertDialogModel( + title: "sure_message", + message: "leave_confirmation_message", + confirmLabel: "withdraw", + cancelLabel: "continue_study", + onConfirm: { + AppDelegate.shared.exitStudy { + Task { @MainActor in + navigationModalState.clearViews() + } + } + })) + } +} + +#Preview { + StudyLoadingErrorView() +} 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 index 0811c0593..dbc2c3dc1 100644 --- 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 @@ -1,8 +1,7 @@ package io.redlink.more.more_app_mutliplatform import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -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.extensions.then import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,24 +14,20 @@ object AlertController { private var alertDialogQueue = mutableListOf() fun openAlertDialog(model: AlertDialogModel) { - if (model.onPositive == null || model.onPositive == {}) { - model.onPositive = { - closeAlertDialog() - } - } - if (model.onNegative == null || model.onNegative == {}) { - model.onNegative = { - closeAlertDialog() - } - } + model.onConfirm = composeWithClose(model.onConfirm) + model.onDecline = composeWithClose(model.onDecline) + if (this.alertDialogQueue.isEmpty() && this.alertDialogModel.value == null) { - this._alertDialogModel.set(model) + 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.setNullable(alertDialogQueue.removeFirstOrNull()) + this._alertDialogModel.value = alertDialogQueue.removeFirstOrNull() } } \ 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 index 1e644964b..4a8756286 100644 --- 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 @@ -201,16 +201,25 @@ class Shared( if (newStudyState != null) { repositories.study.updateStudyState(newStudyState) } + if (currentStudy == null) { + ViewManager.studyError(true) + } return } val (study, error) = networkService.getStudyConfig() if (error != null) { Napier.e { error.message } + if (currentStudy == null) { + ViewManager.studyError(true) + } return } if (study == null) { Napier.d { "Study is null" } + if (currentStudy == null) { + ViewManager.studyError(true) + } return } @@ -253,6 +262,7 @@ class Shared( observationFactory.clearNeededObservationTypes() notificationManager.clearAllNotifications() repositories.notification.deleteAll() + ViewManager.studyError(false) repositories.study.upsert(study) if (study.studyState?.let { StudyState.getState(it) } == StudyState.ACTIVE) { resetFirstStartUp() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt new file mode 100644 index 000000000..89986ad1e --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt @@ -0,0 +1,3 @@ +package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/models/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/AlertDialogModel.kt index 9fdf571a8..836b9a748 100644 --- 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 @@ -3,10 +3,10 @@ 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)? = null, - var onNegative: (() -> Unit)? = null + var confirmLabel: String, + var cancelLabel: String? = null, + var onConfirm: (() -> Unit)? = null, + var onDecline: (() -> Unit)? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -16,8 +16,8 @@ data class 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 + if (confirmLabel != other.confirmLabel) return false + if (cancelLabel != other.cancelLabel) return false return true } @@ -25,8 +25,8 @@ data class AlertDialogModel( override fun hashCode(): Int { var result = title.hashCode() result = 31 * result + message.hashCode() - result = 31 * result + positiveTitle.hashCode() - result = 31 * result + (negativeTitle?.hashCode() ?: 0) + result = 31 * result + confirmLabel.hashCode() + result = 31 * result + (cancelLabel?.hashCode() ?: 0) return result } } \ 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 index 570547917..192694570 100644 --- 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 @@ -1,7 +1,6 @@ package io.redlink.more.more_app_mutliplatform.viewModels import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.extensions.set import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -10,6 +9,11 @@ object ViewManager { private val _showBluetoothView = MutableStateFlow(false) private val _checkingForNewStudyData = MutableStateFlow(false) + private val _studyLoadingError = MutableStateFlow(false) + + @NativeCoroutines + val studyLoadingError: StateFlow = _studyLoadingError + @NativeCoroutines val studyIsUpdating: StateFlow = _studyIsUpdating @@ -42,8 +46,13 @@ object ViewManager { bleViewOpen = state } + fun studyError(hasError: Boolean) { + _studyLoadingError.value = hasError + } + fun resetAll() { - _studyIsUpdating.set(false) - _showBluetoothView.set(false) + _studyIsUpdating.value = false + _showBluetoothView.value = false + _studyLoadingError.value = 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 index 92712751c..2c678cb8a 100644 --- 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 @@ -235,7 +235,7 @@ class BluetoothController( override fun onBluetoothStateChange(bluetoothState: BluetoothState) { Napier.i(tag = "BluetoothController::onBluetoothStateChange") { "Bluetooth state changed to $bluetoothState" } - _bluetoothPower.set(bluetoothState) + _bluetoothPower.value = bluetoothState if (bluetoothState == BluetoothState.OFF) { deviceManager.clearDiscovered() deviceManager.clearConnected() From 1cc78448054325e600eebc5cbdb111625abd2a33 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Thu, 25 Sep 2025 14:34:11 +0200 Subject: [PATCH 36/59] Version Update --- androidApp/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index ad28ce399..fa350cf81 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -49,8 +49,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 29 - versionName = "4.0.29" + versionCode = 32 + versionName = "4.1.4" } buildFeatures { compose = true From 4777fdbb572e410b2df6b7a98257ab4da5fe4276 Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:29:23 +0200 Subject: [PATCH 37/59] #341: Removed an inner Coroutine that blocked the sending process on Android (#345) --- .../observations/ObservationDataManager.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) 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 index 905f95df5..6fa44f165 100644 --- 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 @@ -26,8 +26,6 @@ abstract class ObservationDataManager(private val repository: MainRepository) { private var scheduleCount = mutableMapOf() private val konnection = Konnection.instance - private var uploadJob: Job? = null - init { Napier.i(tag = "ObservationDataManager::init") { "ObservationDataManager init!" } } @@ -65,20 +63,14 @@ abstract class ObservationDataManager(private val repository: MainRepository) { if (countJob == null) { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Starting to listen for changes in datapoint counts" } countJob = Scope.repeatedLaunch(60000, Dispatchers.IO) { - if (konnection.isConnected() && (uploadJob == null || uploadJob?.isActive == false)) { + if (konnection.isConnected()) { val count = repository.observationData.getCount() if (count > 0) { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Observation data count: $count! Sending data..." } - uploadJob = Scope.launch(Dispatchers.IO) { - sendData() - }.second - uploadJob?.invokeOnCompletion { - uploadJob = null - } + sendData() } } else { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "No connection" } - uploadJob?.cancel() } }.second countJob?.invokeOnCompletion { From cf86729247985e5da74fa7218d71ed3f3addf63a Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Wed, 1 Oct 2025 11:49:59 +0200 Subject: [PATCH 38/59] Version Upgrade --- androidApp/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index fa350cf81..ce4052a3f 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -49,8 +49,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 32 - versionName = "4.1.4" + versionCode = 33 + versionName = "4.1.5" } buildFeatures { compose = true From 063e5fa45b6fe1e985111f587ddf7f5ed74503fc Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:56:39 +0200 Subject: [PATCH 39/59] =?UTF-8?q?#333:=20Fixed=20an=20issue,=20where=20Blu?= =?UTF-8?q?etooth=20would=20crash=20the=20iOS=20app.=20This=20f=E2=80=A6?= =?UTF-8?q?=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #333: Fixed an issue, where Bluetooth would crash the iOS app. This fix includes multiple changes: - Moved all necessary states into one single object - Changed necessary coroutines to run on the default thread queue, as this is runs much faster and better on iOS - Changed the Bluetooth Controller completely to dynamically react to different states and turn on/off (foreground/background) scans - Changed the Shared class, to better react to study state, credentials and foreground states to not call the bluetooth controller multiple times and improve reactions * Formatted Code --- .github/workflows/build.yml | 13 +- .github/workflows/deploy-beta.yml | 13 +- .../more/app/android/MoreApplication.kt | 7 +- .../android/activities/ContentViewModel.kt | 3 - .../bluetooth/BluetoothViewModel.kt | 15 +- .../dashboard/schedule/ScheduleViewModel.kt | 13 +- .../android/activities/main/MainViewModel.kt | 2 +- .../ObservationErrorViewModel.kt | 5 +- .../activities/tasks/TaskDetailsViewModel.kt | 5 +- .../observations/HR/HeartRateListener.kt | 16 - .../observations/HR/PolarConnectorListener.kt | 3 - .../HR/PolarHeartRateObservation.kt | 64 ++- .../observations/HR/PolarObserverCallback.kt | 20 +- .../services/ObservationRecordingService.kt | 7 +- .../bluetooth/AndroidBluetoothConnector.kt | 396 ------------------ .../services/bluetooth/PolarConnector.kt | 97 ++--- .../shared_composables/ScheduleListHeader.kt | 7 +- .../android/workers/ScheduleUpdateWorker.kt | 2 +- iosApp/fastlane/Fastfile | 2 - iosApp/iosApp.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- iosApp/iosApp/AppDelegate.swift | 14 +- iosApp/iosApp/AppState.swift | 8 +- .../BackgroundTaskHandler.swift | 6 +- iosApp/iosApp/ContentViewModel.swift | 92 ++-- iosApp/iosApp/Extensions/ArrayExtension.swift | 14 +- .../Extensions/BluetoothDeviceExtension.swift | 6 +- iosApp/iosApp/Extensions/Bundle.swift | 45 +- .../Extensions/CMLogItemExtension.swift | 8 +- .../CMSensorDataListExtension.swift | 6 +- iosApp/iosApp/Extensions/DateExtension.swift | 4 +- .../Extensions/DictionaryExtension.swift | 12 +- iosApp/iosApp/Extensions/Int64Extension.swift | 14 +- .../Extensions/KotlinLongExtension.swift | 9 +- .../iosApp/Extensions/NavigationScreen.swift | 2 +- .../Extensions/ObservationExtension.swift | 4 +- .../iosApp/Extensions/StringExtension.swift | 6 +- .../Extensions/TimeIntervalExtension.swift | 4 +- .../UISegmentedControlExtension.swift | 12 +- .../AccelerometerBackgroundObservation.swift | 2 +- .../AccelerometerObservation.swift | 36 +- .../iosApp/Observations/IOSDataRecorder.swift | 33 +- .../Observations/IOSObservationFactory.swift | 6 +- .../ObservationActionDelegate.swift | 4 +- .../ObservationDataCollector.swift | 23 +- .../PolarVerityHeartRateObservation.swift | 78 ++-- .../iOSObservationDataManager.swift | 4 +- .../Bluetooth/IOSBluetoothConnector.swift | 145 +++---- .../Services/Bluetooth/PolarConnector.swift | 213 +++++----- iosApp/iosApp/Services/FCMService.swift | 2 +- .../LocalPushNotificationService.swift | 2 +- iosApp/iosApp/Services/Semaphore.swift | 8 +- .../iosApp/Services/TaskScheduleService.swift | 62 --- iosApp/iosApp/Style/MoreAnimation.swift | 10 +- iosApp/iosApp/Style/MoreBorder.swift | 6 +- iosApp/iosApp/Style/MoreColor.swift | 18 +- iosApp/iosApp/Style/MoreContainer.swift | 10 +- iosApp/iosApp/Style/MoreFont.swift | 6 +- iosApp/iosApp/Style/MoreFontWeight.swift | 6 +- iosApp/iosApp/Style/MoreFrame.swift | 6 +- iosApp/iosApp/Style/MoreImage.swift | 6 +- .../Style/MoreListStyleEdgeInsets.swift | 8 +- iosApp/iosApp/Style/MoreTextFieldStyle.swift | 7 +- iosApp/iosApp/Style/MoreTextStyle.swift | 6 +- .../Bluetooth/BluetoothConnectionView.swift | 15 +- .../BluetoothConnectionViewModel.swift | 138 +++--- .../CompletedSchedules.swift | 8 +- .../Views/Components/AccordionItem.swift | 8 +- .../iosApp/Views/Components/AppVersion.swift | 4 +- .../Views/Components/BasicNavLinkButton.swift | 6 +- .../iosApp/Views/Components/BasicText.swift | 4 +- .../iosApp/Views/Components/ConsentList.swift | 8 +- .../Views/Components/ConsentListHeader.swift | 4 +- .../Views/Components/ConsentListItem.swift | 11 +- .../Components/DatapointsCollection.swift | 6 +- .../Views/Components/DetailsTitle.swift | 4 +- .../Views/Components/EmptyListView.swift | 4 +- .../iosApp/Views/Components/ErrorText.swift | 5 +- .../Views/Components/ExpandableContent.swift | 14 +- .../ExpandableContentWithLink.swift | 8 +- .../Views/Components/ExpandableInput.swift | 4 +- .../Views/Components/ExpandableText.swift | 28 +- .../Views/Components/Filters/MoreFilter.swift | 2 +- .../Components/Filters/MoreFilterOption.swift | 4 +- .../Components/Filters/MoreFilterText.swift | 4 +- .../Views/Components/ForwardButton.swift | 5 +- .../Views/Components/InactiveText.swift | 4 +- .../Views/Components/InlineAbortButton.swift | 7 +- .../Views/Components/ModuleListItem.swift | 19 +- .../Views/Components/MoreActionButton.swift | 30 +- .../Views/Components/MoreTextField.swift | 4 +- .../Views/Components/MoreTextFieldHL.swift | 20 +- .../Components/MoreTextFieldSmBottom.swift | 7 +- .../Components/NavigationLinkButton.swift | 11 +- .../Views/Components/NavigationText.swift | 4 +- .../Views/Components/NotificationItem.swift | 12 +- .../Components/ObservatinoDetailsData.swift | 21 +- .../Views/Components/RadioButtonField.swift | 24 +- .../Views/Components/ScheduleListHeader.swift | 8 +- .../Views/Components/SectionHeading.swift | 6 +- iosApp/iosApp/Views/Components/Title.swift | 4 +- iosApp/iosApp/Views/Components/Title2.swift | 4 +- .../Components/UIToggleFoldViewButton.swift | 4 +- .../Components/ViewAdaptsToOpenKeyboard.swift | 9 +- .../Views/Components/WebView/WebView.swift | 22 +- .../Components/WebView/WebViewViewModel.swift | 6 +- iosApp/iosApp/Views/Consent/ConsentView.swift | 15 +- .../DashboardFilter/DashboardFilterView.swift | 10 +- .../DashboardFilterViewModel.swift | 30 +- iosApp/iosApp/Views/Info/ContactInfo.swift | 23 +- iosApp/iosApp/Views/Info/InfoList.swift | 4 +- iosApp/iosApp/Views/Info/InfoListItem.swift | 5 +- .../iosApp/Views/Info/InfoListItemModal.swift | 9 +- iosApp/iosApp/Views/Info/InfoView.swift | 8 +- iosApp/iosApp/Views/Info/InfoViewModel.swift | 35 +- .../LeaveStudyConfirmationView.swift | 5 +- .../Views/LeaveStudy/LeaveStudyView.swift | 24 +- .../Views/LimeSurvey/LimeSurveyView.swift | 2 +- .../LimeSurvey/LimeSurveyViewModel.swift | 14 +- .../Views/Login/CameraPreviewView.swift | 4 +- iosApp/iosApp/Views/Login/LoginButton.swift | 6 +- iosApp/iosApp/Views/Login/LoginView.swift | 1 - .../iosApp/Views/Login/LoginViewModel.swift | 59 ++- .../iosApp/Views/Login/QRCodeCameraView.swift | 12 +- .../Views/Login/QRCodeScanDelegate.swift | 3 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 29 +- .../Views/Login/ScanQrCodeViewModel.swift | 16 +- iosApp/iosApp/Views/ModalView.swift | 22 +- iosApp/iosApp/Views/Navigation.swift | 10 +- .../Filter/NotificationFilterView.swift | 21 +- .../Filter/NotificationFilterViewModel.swift | 28 +- .../Views/Notification/NotificationView.swift | 2 +- .../Notification/NotificationViewModel.swift | 61 ++- .../ObservationDetailsView.swift | 23 +- .../ObservationDetailsViewModel.swift | 14 +- .../Views/ObservationErrorListView.swift | 5 +- .../ObservationErrorsView.swift | 2 +- .../ObservationErrorsViewModel.swift | 28 +- .../SimpleQuestionObservationViewModel.swift | 25 +- .../SimpleQuestionThankYouView.swift | 11 +- .../SimpleQuetionObservationView.swift | 55 ++- .../Schedule/List/ObservationButton.swift | 8 +- .../Schedule/List/ObservationDetails.swift | 10 +- .../List/ObservationTimeDetails.swift | 4 +- .../Views/Schedule/List/ScheduleList.swift | 7 +- .../Schedule/List/ScheduleListItem.swift | 6 +- .../iosApp/Views/Schedule/ScheduleView.swift | 4 +- .../Views/Schedule/ScheduleViewModel.swift | 42 +- .../iosApp/Views/Settings/SettingsView.swift | 12 +- .../Views/Settings/SettingsViewModel.swift | 38 +- .../Views/StudyDetails/StudyDetailsView.swift | 13 +- .../StudyDetails/StudyDetailsViewModel.swift | 35 +- .../Views/StudyStates/StudyClosedView.swift | 5 +- .../Views/StudyStates/StudyPausedView.swift | 4 +- .../Views/StudyStates/StudyUpdateView.swift | 4 +- .../TaskCompletionBarView.swift | 10 +- .../TaskCompletionBarViewModel.swift | 22 +- .../Views/TaskDetails/TaskDetailsView.swift | 10 +- .../TaskDetails/TaskDetailsViewModel.swift | 64 +-- iosApp/iosApp/iOSApp.swift | 56 +-- .../more/more_app_mutliplatform/Platform.kt | 2 +- .../more/more_app_mutliplatform/Shared.kt | 177 +++----- .../database/dao/StudyDao.kt | 2 +- .../repository/BluetoothDeviceRepository.kt | 48 +-- .../database/repository/ScheduleRepository.kt | 118 ++---- .../extensions/AnyExtension.kt | 1 - .../extensions/CoroutineExtension.kt | 4 + .../extensions/FlowExtension.kt | 62 --- .../models/DateFilterModel.kt | 4 +- .../models/NotificationFilterTypeModel.kt | 6 +- .../models/SettingsModel.kt | 19 - .../models/StudyState.kt | 2 + .../observations/Observation.kt | 60 ++- .../observations/ObservationDataManager.kt | 2 +- .../observations/ObservationFactory.kt | 54 +-- .../observations/ObservationManager.kt | 61 ++- .../observations/ObservationStates.kt | 24 ++ .../observationTypes/LimeSurveyType.kt | 2 +- .../PolarVerityHeartRateType.kt | 3 +- .../observationTypes/SimpleQuestionType.kt | 4 +- .../registration/RegistrationService.kt | 1 - .../more_app_mutliplatform/scopes/Scope.kt | 3 +- .../scopes/StudyScope.kt | 3 +- .../services/bluetooth/BluetoothConnector.kt | 6 - .../bluetooth/BluetoothConnectorObserver.kt | 4 +- .../bluetooth/BluetoothDeviceManager.kt | 95 ----- .../bluetooth/BluetoothStateManagement.kt | 161 +++++++ .../services/bluetooth/ScanMode.kt | 7 + .../services/bluetooth/polar/PolarStates.kt | 20 + .../services/network/openapi/model/ApiKey.kt | 9 +- .../network/openapi/model/AppConfiguration.kt | 8 +- .../network/openapi/model/DataBulk.kt | 6 +- .../services/network/openapi/model/Error.kt | 7 +- .../openapi/model/FcmNotificationConfig.kt | 10 +- .../model/FcmNotificationConfigAllOf.kt | 11 +- .../services/network/openapi/model/Log.kt | 2 +- .../network/openapi/model/Observation.kt | 18 +- .../openapi/model/ObservationConsent.kt | 9 +- .../network/openapi/model/ObservationData.kt | 14 +- .../openapi/model/ObservationSchedule.kt | 9 +- .../network/openapi/model/Participant.kt | 5 +- .../openapi/model/PushNotificationConfig.kt | 15 +- .../model/PushNotificationServiceType.kt | 12 +- .../openapi/model/PushNotificationToken.kt | 7 +- .../services/network/openapi/model/Study.kt | 11 +- .../network/openapi/model/StudyConsent.kt | 7 +- .../network/openapi/model/StudyContact.kt | 5 +- .../notification/NotificationManager.kt | 8 +- .../viewModels/ViewManager.kt | 28 +- .../BluetoothController.kt | 278 +++++------- .../bluetoothConnection/PolarController.kt | 22 + .../schedules/CoreScheduleViewModel.kt | 39 +- .../settings/CoreSettingsViewModel.kt | 2 +- .../CoreBluetoothViewModel.kt | 2 +- .../tasks/CoreTaskDetailsViewModel.kt | 5 +- .../extensions/toString.kt | 9 +- 216 files changed, 1950 insertions(+), 2731 deletions(-) delete mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/HR/HeartRateListener.kt delete mode 100644 androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/AndroidBluetoothConnector.kt delete mode 100644 iosApp/iosApp/Services/TaskScheduleService.swift delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SettingsModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationStates.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDeviceManager.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/ScanMode.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04e7f5504..94da39cf5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,23 +35,12 @@ jobs: - name: Checkout repo uses: actions/checkout@v3 - - name: Set Xcode Version - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '26.0.0' - - # Needs to be removed, after github fixed an issue with the Xcode 26 beta version - - name: Download iOS 26.0 Platform - run: | - xcodebuild -downloadPlatform iOS - working-directory: . - - name: Build iOS app run: fastlane build android: name: Android - runs-on: macos-latest + runs-on: ubuntu-latest environment: PlayStore env: GOOGLE_PLAY_KEY_FILE: ${{ secrets.GOOGLE_PLAY_KEY_FILE }} diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 837d19e84..c70a07f47 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -38,17 +38,6 @@ jobs: - name: Checkout repo uses: actions/checkout@v3 - - name: Set Xcode Version - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '26.0.0' - - # Needs to be removed, after github fixed an issue with the Xcode 26 beta version - - name: Download iOS 26.0 Platform - run: | - xcodebuild -downloadPlatform iOS - working-directory: . - - name: Set FASTLANE_BUILD_NUMBER from tag run: echo "FASTLANE_BUILD_NUMBER=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV @@ -58,7 +47,7 @@ jobs: android: name: Android - runs-on: macos-latest + runs-on: ubuntu-latest environment: PlayStore if: startsWith(github.ref, 'refs/tags/') env: 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 c3211ba93..4a17d5097 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 @@ -31,6 +31,7 @@ import io.redlink.more.more_app_mutliplatform.database.getRoomDatabase import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.napierDebugBuild import io.redlink.more.more_app_mutliplatform.services.store.SharedPreferencesRepository +import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager /** * Main Application class of the project. @@ -47,21 +48,21 @@ class MoreApplication : Application(), DefaultLifecycleObserver { } 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) + ViewManager.appIsInForeground(true) shared?.notificationManager?.updateNotificationBadgeCount() } override fun onPause(owner: LifecycleOwner) { super.onPause(owner) Napier.i { "App is in the background..." } - shared?.appInForeground(false) + ViewManager.appIsInForeground(false) } companion object { 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 171f03f25..9b94e968f 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 @@ -31,9 +31,7 @@ import io.redlink.more.more_app_mutliplatform.AlertController import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel import io.redlink.more.more_app_mutliplatform.registration.RegistrationService import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -85,7 +83,6 @@ class ContentViewModel : ViewModel() { val notificationId = activity.intent.getStringExtra(NotificationManager.MSG_ID) viewModelScope.launch(Dispatchers.IO) { - ViewManager.checkingForNewStudyData.first { !it } val modifiedDeepLink = rawDeepLink?.let { link -> val sharedInstance = MoreApplication.shared ?: throw IllegalStateException("MoreApplication.shared is not initialized") 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 b7d42a881..5588fcf59 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 @@ -20,7 +20,8 @@ import io.redlink.more.app.android.services.sensorsListener.GPSStateListener import io.redlink.more.more_app_mutliplatform.AlertController import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager +import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager import io.redlink.more.more_app_mutliplatform.viewModels.startupConnection.CoreBluetoothViewModel import kotlinx.coroutines.Dispatchers @@ -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 } @@ -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 }) @@ -121,7 +122,9 @@ class BluetoothViewModel : ViewModel() { } fun connectToDevice(device: BluetoothDeviceEntity) { - coreBluetoothViewModel.connectToDevice(device) + Scope.launch { + coreBluetoothViewModel.connectToDevice(device) + } } fun disconnectFromDevice(device: BluetoothDeviceEntity) { 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 5d56dcfde..7bc491b04 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 @@ -15,8 +15,8 @@ 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.ScheduleListType +import io.redlink.more.more_app_mutliplatform.services.bluetooth.polar.PolarStates import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel import io.redlink.more.more_app_mutliplatform.viewModels.schedules.CoreScheduleViewModel import kotlinx.coroutines.Dispatchers @@ -33,15 +33,14 @@ class ScheduleViewModel( MoreApplication.shared!!.repositories, MoreApplication.shared!!.dataRecorder, scheduleListType = scheduleListType, - coreFilterModel = coreDashboardFilterViewModel, - observationFactory = MoreApplication.shared!!.observationFactory + coreFilterModel = coreDashboardFilterViewModel ) val polarHrReady: MutableState = mutableStateOf(false) init { viewModelScope.launch(Dispatchers.IO) { - PolarHeartRateObservation.hrReady.collect { + PolarStates.hrFeatureReady.collect { withContext(Dispatchers.Main) { polarHrReady.value = it } @@ -56,10 +55,4 @@ class ScheduleViewModel( fun pauseObservation(scheduleId: String) { coreViewModel.pause(scheduleId) } - - fun stopObservation(scheduleId: String) { - coreViewModel.stop(scheduleId) - } - - fun numberOfObservationErrors(): Int = coreViewModel.numberOfObservationErrors() } \ No newline at end of file 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 7b79c68de..339018203 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 @@ -71,7 +71,7 @@ class MainViewModel(context: Context) : ViewModel() { } viewModelScope.launch { - ViewManager.showBluetoothView.collect { + ViewManager.bleViewActive.collect { if (it && !lastBleViewState) { openBLESetupActivity(context) } 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..95f29d375 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.more_app_mutliplatform.observations.ObservationStates import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -16,7 +16,7 @@ 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 } @@ -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/tasks/TaskDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt index e954043ac..6cb89eeb7 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 @@ -14,9 +14,9 @@ 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.observations.DataRecorder import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory +import io.redlink.more.more_app_mutliplatform.services.bluetooth.polar.PolarStates import io.redlink.more.more_app_mutliplatform.viewModels.tasks.CoreTaskDetailsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -31,7 +31,6 @@ class TaskDetailsViewModel( CoreTaskDetailsViewModel( MoreApplication.shared!!.repositories, dataRecorder, - observationFactory, scheduleId ) @@ -39,7 +38,7 @@ class TaskDetailsViewModel( init { viewModelScope.launch(Dispatchers.IO) { - PolarHeartRateObservation.hrReady.collect { + PolarStates.hrFeatureReady.collect { withContext(Dispatchers.Main) { polarHrReady.value = it } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/HeartRateListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/HeartRateListener.kt deleted file mode 100644 index b32f13831..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/HeartRateListener.kt +++ /dev/null @@ -1,16 +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.observations.HR - -interface HeartRateListener { - fun onHeartRateUpdate(hr: Int) - fun onHeartRateReady() -} \ No newline at end of file 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 f27f28faf..219526e18 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 @@ -25,14 +25,13 @@ import io.redlink.more.app.android.observations.showPermissionAlertDialog import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository 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.scopes.Scope -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager +import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement +import io.redlink.more.more_app_mutliplatform.services.bluetooth.polar.PolarStates +import io.redlink.more.more_app_mutliplatform.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) { @@ -63,16 +62,36 @@ class PolarHeartRateObservation(repos: MainRepository) : repos, observationType = PolarVerityHeartRateType(permissions) ) { - private val deviceManager = BluetoothDeviceManager + 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.startObservationType( + super.observationType.observationType + ) + } else { + Observation.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 { @@ -132,14 +151,17 @@ class PolarHeartRateObservation(repos: MainRepository) : 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)) { + 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 @@ -173,37 +195,13 @@ class PolarHeartRateObservation(repos: MainRepository) : 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) + PolarStates.hrFeatureReady(false) Napier.d(tag = "PolarHeartRateObservation::Companion::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 } - } - } } 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 61468e1bc..3b6f83069 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 @@ -16,32 +16,16 @@ 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.more_app_mutliplatform.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) { 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 1a01916ff..e8029fd53 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 @@ -34,6 +34,7 @@ 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.scopes.Scope import io.redlink.more.more_app_mutliplatform.scopes.StudyScope +import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -606,7 +607,7 @@ class ObservationRecordingService : Service() { } fun pause(scheduleId: String) { - if (MoreApplication.shared?.appIsInForeGround == true) { + if (ViewManager.appInForeground.value) { val serviceIntent = Intent(MoreApplication.appContext, ObservationRecordingService::class.java) serviceIntent.action = SERVICE_RECEIVER_PAUSE_ACTION @@ -618,7 +619,7 @@ class ObservationRecordingService : Service() { } fun stop(scheduleId: String) { - if (MoreApplication.shared?.appIsInForeGround == true) { + if (ViewManager.appInForeground.value) { val serviceIntent = Intent(MoreApplication.appContext, ObservationRecordingService::class.java) serviceIntent.action = SERVICE_RECEIVER_STOP_ACTION @@ -630,7 +631,7 @@ class ObservationRecordingService : Service() { } fun stopAll() { - if (MoreApplication.shared?.appIsInForeGround == true) { + if (ViewManager.appInForeground.value) { val serviceIntent = Intent(MoreApplication.appContext, ObservationRecordingService::class.java) serviceIntent.action = SERVICE_RECEIVER_STOP_ALL_ACTION 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 65ba55dc9..000000000 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/AndroidBluetoothConnector.kt +++ /dev/null @@ -1,396 +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.database.entities.BluetoothDeviceEntity -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.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: BluetoothDeviceEntity): 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: BluetoothDeviceEntity) { - 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: BluetoothDeviceEntity) { - i { "Connecting to $bluetoothDevice..." } - updateObserver { it.isConnectingToDevice(bluetoothDevice) } - } - - override fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { - i { "Connected to $bluetoothDevice!" } - updateObserver { it.didConnectToDevice(bluetoothDevice) } - isConnecting = false - } - - override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { - i { "Disconnected from $bluetoothDevice!" } - foundBluetoothDevices.remove(bluetoothDevice.address) - updateObserver { it.didDisconnectFromDevice(bluetoothDevice) } - } - - override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { - i { "Failed to connect to $bluetoothDevice!" } - updateObserver { didFailToConnectToDevice(bluetoothDevice) } - isConnecting = false - } - - override fun didDiscoverDevice(device: BluetoothDeviceEntity) { - i { "Discovered $device!" } - updateObserver { it.didDiscoverDevice(device) } - } - - override fun removeDiscoveredDevice(device: BluetoothDeviceEntity) { - 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: BluetoothDeviceEntity) { - val (hasSpecialConnector, error) = connectToSpecificConnectors(bluetoothDevice) - if (hasSpecialConnector) { - if (error != null) { - Napier.e { error.stackTraceToString() } - } - } - didConnectToDevice(bluetoothDevice) - } - - private fun connectToSpecificConnectors(device: BluetoothDeviceEntity): 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: BluetoothDeviceEntity): 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(): BluetoothDeviceEntity { - return BluetoothDeviceEntity.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 ac362dd8a..eb95f799b 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.database.entities.BluetoothDeviceEntity -import io.redlink.more.more_app_mutliplatform.scopes.Scope 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.BluetoothState -import kotlinx.coroutines.delay +import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement +import io.redlink.more.more_app_mutliplatform.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( @@ -113,17 +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::Companion::setHRFeature") { "HR Feature Ready!" } } } @@ -141,11 +137,6 @@ 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() @@ -161,6 +152,9 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe 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: BluetoothDeviceEntity) { @@ -182,25 +176,13 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe } } - override fun onBluetoothStateChange(bluetoothState: BluetoothState) { - this.bluetoothState = bluetoothState - updateObserver { it.onBluetoothStateChange(bluetoothState) } - if (MoreApplication.shared?.credentialRepository?.hasCredentials?.value == true && bluetoothState == BluetoothState.ON) { - Scope.launch { - scan() - delay(5000) - stopScanning() - } - } else { - stopScanning() - } + override fun resetAll() { + PolarStates.resetAll() + polarApi.cleanup() } override fun addObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { this.observer.add(bluetoothConnectorObserver) - if (this.observer.isNotEmpty()) { - replayStates() - } } override fun removeObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { @@ -219,21 +201,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(): BluetoothDeviceEntity { - return BluetoothDeviceEntity.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/shared_composables/ScheduleListHeader.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt index 3163e3839..6bce4e584 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,10 +19,12 @@ 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 @@ -43,16 +45,17 @@ fun ScheduleListHeader( ) { 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, 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 f08e2f9e2..2a04ac1c0 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 @@ -33,7 +33,7 @@ class ScheduleUpdateWorker(context: Context, workerParameters: WorkerParameters) override suspend fun doWork() = withContext(Dispatchers.IO) { Napier.i { "Running $WORKER_TAG! Updating Schedule..." } - shared.repositories.schedule.updateTaskStatesSync( + shared.repositories.schedule.updateTaskStates( shared.observationFactory, shared.dataRecorder ) diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile index 15d041855..e4e5bb2a9 100644 --- a/iosApp/fastlane/Fastfile +++ b/iosApp/fastlane/Fastfile @@ -87,8 +87,6 @@ platform :ios do bundle_identifier: identifier ) end - - xcodes(version: "26.0-beta", select_for_current_build_only: true) end build_app( diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3a19277fb..017426df6 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -24,7 +24,6 @@ 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 */; }; 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 */; }; @@ -234,7 +233,6 @@ 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 = ""; }; 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 = ""; }; @@ -458,7 +456,6 @@ 1F0026DE29CCA24F0034EF65 /* DataUploadManager.swift */, ED6A7F3E29DC405B00E266EC /* FCMService.swift */, 1F5F842529E6C67A0010C2D2 /* LocalPushNotificationService.swift */, - 1F0FA80629ED7A1300B8D80E /* TaskScheduleService.swift */, ); path = Services; sourceTree = ""; @@ -1181,7 +1178,6 @@ 1FDC264829C1CEF40011D8A4 /* InfoListItem.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 */, @@ -1621,7 +1617,7 @@ repositoryURL = "https://github.com/polarofficial/polar-ble-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 6.6.0; + minimumVersion = 6.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2806a965d..9f4ea9909 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/polarofficial/polar-ble-sdk.git", "state" : { - "revision" : "a077cbaef98c9ba8110eed815c0da25cef7ddbd3", - "version" : "6.6.0" + "revision" : "299639de113d0946c4a84f4c15e67bbca2173bad", + "version" : "6.7.0" } }, { diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 93a98e47b..7f7638a87 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -14,12 +14,12 @@ // import BackgroundTasks +import FirebaseCore +import FirebaseCrashlyticsSwift +import FirebaseMessaging import Foundation import shared import UIKit -import FirebaseCore -import FirebaseMessaging -import FirebaseCrashlyticsSwift class AppDelegate: NSObject, UIApplicationDelegate { static let appGroup = "group.ac.at.lbg.dhp.more.group" @@ -61,11 +61,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { return true } - - 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()) - + completionHandler(.newData) } @@ -89,7 +89,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { static func registerForNotifications() { DispatchQueue.main.async { if !UIApplication.shared.isRegisteredForRemoteNotifications { - UIApplication.shared.registerForRemoteNotifications() + UIApplication.shared.registerForRemoteNotifications() } } } 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/ContentViewModel.swift b/iosApp/iosApp/ContentViewModel.swift index bb971b169..735e4bfd2 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -7,17 +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 Foundation -import shared import BackgroundTasks -import KMPNativeCoroutinesCombine import Combine +import Foundation +import KMPNativeCoroutinesCombine +import shared class ContentViewModel: ObservableObject { @Published var hasCredentials = false @@ -28,7 +28,6 @@ class ContentViewModel: ObservableObject { @Published var mainTabViewSelection = 0 - @Published var alertDialogModel: AlertDialogModel? = nil @Published var unreadNotificationCount: Int = 0 @@ -39,57 +38,54 @@ class ContentViewModel: ObservableObject { lazy var runningViewModel = ScheduleViewModel(scheduleListType: .running) lazy var completedViewModel = ScheduleViewModel(scheduleListType: .completed) lazy var settingsViewModel: SettingsViewModel = SettingsViewModel() - + let coreNotificationFilterViewModel = CoreNotificationFilterViewModel() lazy var infoViewModel = InfoViewModel() - + private var cancellables = Set() init() { - createPublisher(for: AppDelegate.shared.credentialRepository.credentials) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] credentials in - self?.hasCredentials = credentials != nil - } - .store(in: &cancellables) - + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] credentials in + self?.hasCredentials = credentials != nil + } + .store(in: &cancellables) + createPublisher(for: AppDelegate.shared.credentialRepository.credentialsLoaded) - .map { $0.boolValue } - .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 - } - .store(in: &cancellables) - - - + .map { + $0.boolValue + } + .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 + } + .store(in: &cancellables) + createPublisher(for: ViewManager.shared.studyIsUpdating) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in}) { updating in - AppDelegate.navigationScreenHandler.studyIsUpdating(updating.boolValue) - } - .store(in: &cancellables) - - createPublisher(for: ViewManager.shared.showBluetoothView) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] show in - self?.showBleView = show.boolValue - } - .store(in: &cancellables) - + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { updating in + AppDelegate.navigationScreenHandler.studyIsUpdating(updating.boolValue) + } + .store(in: &cancellables) + + createPublisher(for: ViewManager.shared.bleViewActive) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] show in + self?.showBleView = show.boolValue + } + .store(in: &cancellables) + createPublisher(for: AlertController.shared.alertDialogModel) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: {_ in }) { [weak self] alertDialogModel in - self?.alertDialogModel = alertDialogModel - } - .store(in: &cancellables) - - + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] alertDialogModel in + self?.alertDialogModel = alertDialogModel + } + .store(in: &cancellables) AppDelegate.shared.unreadNotificationCountAsClosure { [weak self] kInt in self?.unreadNotificationCount = kInt.intValue @@ -106,7 +102,6 @@ class ContentViewModel: ObservableObject { return limeSurveyVM } - private func reinitAllViewModels() { runningViewModel = ScheduleViewModel(scheduleListType: .running) completedViewModel = ScheduleViewModel(scheduleListType: .completed) @@ -116,4 +111,3 @@ class ContentViewModel: ObservableObject { 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 08a5a4cd7..5cdb578f5 100644 --- a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift +++ b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.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 Foundation -import shared import PolarBleSdk +import shared extension BluetoothDeviceEntity { static func fromPolarDevice(polarInfo: PolarDeviceInfo) -> BluetoothDeviceEntity { 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..87a7510f9 100644 --- a/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift +++ b/iosApp/iosApp/Extensions/CMSensorDataListExtension.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 CoreMotion +import Foundation extension CMSensorDataList: Sequence { public typealias Iterator = NSFastEnumerationIterator 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 7b86847a7..6531365f0 100644 --- a/iosApp/iosApp/Extensions/DictionaryExtension.swift +++ b/iosApp/iosApp/Extensions/DictionaryExtension.swift @@ -25,30 +25,28 @@ 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 self.reduce(into: [NewKey: Value]()) { result, element in + 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 self.reduce(into: [Key: NewValue]()) { result, element in + 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/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index 7024c5d31..4ffe94f7c 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -30,7 +30,7 @@ enum NavigationParameter: String { enum NavigationScreen: CaseIterable, Equatable, Identifiable { var id: Self { self } - + case dashboard case notifications case info 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/StringExtension.swift b/iosApp/iosApp/Extensions/StringExtension.swift index fa734dce5..6972fe3f1 100644 --- a/iosApp/iosApp/Extensions/StringExtension.swift +++ b/iosApp/iosApp/Extensions/StringExtension.swift @@ -28,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/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift index 6da0be2f1..d6e41be18 100644 --- a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift @@ -28,7 +28,7 @@ class AccelerometerBackgroundObservation: Observation_ { private let observationRepository: ObservationRepository init(repos: MainRepository, sensorPermissions: Set) { - self.observationRepository = repos.observation + observationRepository = repos.observation super.init(repos: repos, observationType: AccelerometerType(sensorPermissions: sensorPermissions)) } diff --git a/iosApp/iosApp/Observations/AccelerometerObservation.swift b/iosApp/iosApp/Observations/AccelerometerObservation.swift index d4c75836e..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 - + 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/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 3f89f301d..a41c822b5 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,7 +17,7 @@ import Foundation import shared class IOSObservationFactory: ObservationFactory { - override init(repository: MainRepository,dataManager: ObservationDataManager) { + override init(repository: MainRepository, dataManager: ObservationDataManager) { super.init(repository: repository, dataManager: dataManager) observations.add(GPSObservation(repos: repository, sensorPermissions: ["gpsAlways"])) observations.add(AccelerometerBackgroundObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"])) 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 2e9732761..eb6e5f716 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -13,48 +13,62 @@ // https://commonsclause.com/). // +import Combine import CoreBluetooth import Foundation +import KMPNativeCoroutinesCombine import PolarBleSdk import RxSwift import shared import UIKit -import Combine -import KMPNativeCoroutinesCombine 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: [BluetoothDeviceEntity] = [] private var hrObservation: Disposable? - private let deviceManager = BluetoothDeviceManager.shared + private let bleManager = BluetoothStateManagement.shared private var deviceListener: AnyCancellable? - + private let errorStringTable = "Errors" + 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.startObservationType(type: self.observationType.observationType) + } 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.connectedDevicesValue.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 @@ -65,14 +79,14 @@ 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!", fallbackTitle: "Observation Error") + 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!", fallbackTitle: "Observation Error") + showCannotStartNotificationWithBackoff(title: "Observation Error", message: "Cannot start Observation! Please make sure to enable Bluetooth and connect all necessary devices!") return false } @@ -84,18 +98,20 @@ class PolarVerityHeartRateObservation: Observation_ { override func observerErrors() -> Set { var errors: Set = [] - let state = AppDelegate.shared.bluetoothController.bluetoothPowerValue if CBManager.authorization != .allowedAlways { errors.insert("Access to Bluetooth not granted") PermissionManager.openSensorPermissionDialog() + PolarStates.shared.hrFeatureReady(ready: false) } - if 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 @@ -113,13 +129,23 @@ class PolarVerityHeartRateObservation: Observation_ { observerAccessible() } + 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: deviceManager.connectedDevices) + deviceListener = createPublisher(for: bleManager.connectedDevices) .removeDuplicates() .receive(on: DispatchQueue.main) - .sink(receiveCompletion: {_ in}, receiveValue: { [weak self] devices in + .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] devices in if let self, !self.deviceIdentificer.anyNameIn(items: devices) { - PolarVerityHeartRateObservation.setHRFeature(state: false) + PolarStates.shared.hrFeatureReady(ready: false) self.deviceListener?.cancel() } }) diff --git a/iosApp/iosApp/Observations/iOSObservationDataManager.swift b/iosApp/iosApp/Observations/iOSObservationDataManager.swift index d9e140ab6..abf3f411d 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/). // diff --git a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift index 4272ad165..5b669b0e9 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/). // @@ -26,27 +26,28 @@ protocol BLEConnectorDelegate { 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 } @@ -54,56 +55,48 @@ class IOSBluetoothConnector: NSObject, BluetoothConnector { 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: BluetoothDeviceEntity) { - if let cbPeripheral = discoveredDevices[device] { + 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") @@ -118,88 +111,83 @@ 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: BluetoothDeviceEntity) { updateObserver { $0.isConnectingToDevice(bluetoothDevice: bluetoothDevice) } } - + func didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didConnectToDevice(bluetoothDevice: bluetoothDevice) } } - + func didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didDisconnectFromDevice(bluetoothDevice: bluetoothDevice) } } - + func didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { updateObserver { $0.didFailToConnectToDevice(bluetoothDevice: bluetoothDevice) } } - + func didDiscoverDevice(device: BluetoothDeviceEntity) { updateObserver { $0.didDiscoverDevice(device: device) } } - + 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: BluetoothDeviceEntity) -> (Bool, KotlinError?) { if let connector = specificBluetoothConnectors - .first(where: {device.deviceName?.lowercased().contains(($0.key as? String)?.lowercased() ?? "") ?? false})?.value as? BluetoothConnector { + .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: BluetoothDeviceEntity) -> Bool { if let connector = specificBluetoothConnectors - .first(where: {device.deviceName?.lowercased().contains(($0.key as? String)?.lowercased() ?? "") ?? false})?.value as? BluetoothConnector { + .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() } @@ -209,60 +197,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() -> BluetoothDeviceEntity { BluetoothDeviceEntity.Companion().create( - deviceId: self.identifier.uuidString, - deviceName: self.name ?? "Unknown", - address: self.identifier.uuidString + 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 2c8e4d501..6503e532f 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,84 +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 - } + private var devicesSubscription: Disposable? - return api - }() + private(set) var polarApi: PolarBleApi - var observer: KotlinMutableSet = KotlinMutableSet() + 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, + ] + ) - var scanning = false { - didSet { - isScanning(boolean: scanning) - } + 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() + func addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) { specificBluetoothConnectors[key] = connector } func connect(device: BluetoothDeviceEntity) -> KotlinError? { - do { - try polarApi.connectToDevice(device.deviceId) - return nil - } catch { - print(error) - return KotlinError(message: error.localizedDescription) + let performConnect = { () -> KotlinError? in + do { + self.stopScanning() + try self.polarApi.connectToDevice(device.deviceId) + return nil + } catch { + print(error) + return KotlinError(message: error.localizedDescription) + } + } + + if Thread.isMainThread { + return performConnect() + } else { + var result: KotlinError? + DispatchQueue.main.sync { + result = performConnect() + } + return result } } func disconnect(device: BluetoothDeviceEntity) { - do { - try polarApi.disconnectFromDevice(device.deviceId) - } catch { - print(error) + let performConnect = { () in + do { + 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: 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) }) } } @@ -106,18 +128,18 @@ 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: BluetoothDeviceEntity) { @@ -136,6 +158,9 @@ class PolarConnector: NSObject, BluetoothConnector { updateObserver { $0.didDisconnectFromDevice(bluetoothDevice: bluetoothDevice) } + if bleManager.connectedDevicesValue.map({ $0.deviceName?.lowercased().contains("polar") }).isEmpty { + PolarStates.shared.hrFeatureReady(ready: false) + } } func didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { @@ -156,32 +181,13 @@ class PolarConnector: NSObject, BluetoothConnector { } } - 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() } } @@ -194,76 +200,46 @@ class PolarConnector: NSObject, BluetoothConnector { } } - func replayStates() { - print("Polar Connector: Replaying states...") - onBluetoothStateChange(bluetoothState: self.bluetoothState) - isScanning(boolean: scanning) + func resetAll() { + PolarStates.shared.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: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) + didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } func deviceConnecting(_ identifier: PolarBleSdk.PolarDeviceInfo) { print("Polar connecting: \(identifier.name)") - self.isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) + isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity.fromPolarDevice(polarInfo: identifier)) } func deviceConnected(_ identifier: PolarDeviceInfo) { print("Polar connected: \(identifier.name)") - self.didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity.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) } } } @@ -272,11 +248,11 @@ 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)") } @@ -284,7 +260,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/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift index 086aed3bb..ce4688057 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -47,7 +47,7 @@ extension FCMService: UNUserNotificationCenterDelegate { @MainActor func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - AppDelegate.shared.appInForeground(boolean: true) + ViewManager.shared.appIsInForeground(state: true) let data = response.notification.request.content.userInfo.notNilStringDictionary() let msgId = data[NotificationManager.companion.MSG_ID] ?? response.notification.request.identifier if let deepLinkString = data[NotificationManager.companion.DEEP_LINK] { diff --git a/iosApp/iosApp/Services/LocalPushNotificationService.swift b/iosApp/iosApp/Services/LocalPushNotificationService.swift index bb0666715..66e9e5b15 100644 --- a/iosApp/iosApp/Services/LocalPushNotificationService.swift +++ b/iosApp/iosApp/Services/LocalPushNotificationService.swift @@ -62,7 +62,7 @@ class LocalPushNotifications: LocalNotificationListener { requestLocalNotification(identifier: notification.notificationId, title: title, subtitle: body) } } - + func updateBadgeCount(count: Int32) { AppDelegate.appGroupUserDefaults?.set(Int(count), forKey: LocalPushNotifications.notificationCountKey) UNUserNotificationCenter.current().setBadgeCount(Int(count)) { error in 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 0976291c6..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(appDatabase: AppDelegate.database) - 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/Views/Bluetooth/BluetoothConnectionView.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift index 81bb855f3..da6dffaff 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.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 BluetoothConnectionView: View { @StateObject private var viewModel = BluetoothConnectionViewModel() @@ -43,19 +43,18 @@ struct BluetoothConnectionView: View { 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", color: Color.more.secondary) .padding(.top, 8) } - - if viewModel.bluetoothPower == .on { + if viewModel.bluetoothPower { Section(header: SectionHeading(sectionTitle: "Connected devices")) { if viewModel.connectedDevices.isEmpty { EmptyListView(text: "\("No devices connected")!") @@ -88,7 +87,7 @@ struct BluetoothConnectionView: View { } } } - + Section(header: SectionHeading(sectionTitle: "Discovered devices")) { if viewModel.discoveredDevices.isEmpty { EmptyListView(text: "\("No devices found nearby")!") diff --git a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift index cde8fc225..24a1f8088 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift @@ -13,15 +13,15 @@ // https://commonsclause.com/). // -import Foundation -import shared import Combine -import KMPNativeCoroutinesCombine 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: [BluetoothDeviceEntity] = [] @Published var connectedDevices: [BluetoothDeviceEntity] = [] @@ -31,70 +31,84 @@ class BluetoothConnectionViewModel: ObservableObject { @Published var neededDevices: [String] = [] - @Published var bluetoothPower: BluetoothState = .off - + @Published var bluetoothPower = false + private var cancellables = Set() init() { - createPublisher(for: deviceManager.connectedDevices) - .map { devices in - return Array(devices) - .compactMap { device -> BluetoothDeviceEntity? in - guard let name = device.deviceName, !name.isEmpty else { return nil } - return device - } - .sorted { ($0.deviceName ?? "") < ($1.deviceName ?? "") } + 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 } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] devices in - self?.connectedDevices = devices + .sorted { + ($0.deviceName ?? "") < ($1.deviceName ?? "") } - .store(in: &cancellables) - - createPublisher(for: deviceManager.devicesCurrentlyConnecting) - .map { devices in devices.compactMap { $0.address } } - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] addresses in - self?.connectingDevices = addresses + } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] devices in + self?.connectedDevices = devices + } + .store(in: &cancellables) + + createPublisher(for: bleManager.devicesCurrentlyConnecting) + .map { devices in + devices.compactMap { + $0.address } - .store(in: &cancellables) - - createPublisher(for: deviceManager.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 + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] addresses in + self?.connectingDevices = addresses + } + .store(in: &cancellables) + + 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 } - return sorted.map { $0.0 } - } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] devices in - self?.discoveredDevices = devices + let matchesNeeded = needed.isEmpty ? false : needed.contains(where: { name.contains($0) }) + return (device, name, matchesNeeded) } - .store(in: &cancellables) - - createPublisher(for: coreViewModel.coreBluetooth.isScanning) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in}) { [weak self] scanning in - self?.bluetoothIsScanning = scanning.boolValue + let sorted = filtered.sorted { lhs, rhs in + if lhs.2 != rhs.2 { + return lhs.2 && !rhs.2 + } + return lhs.1 < rhs.1 } - .store(in: &cancellables) - - createPublisher(for: coreViewModel.coreBluetooth.bluetoothPower) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in}) { [weak self] power in - self?.bluetoothPower = power + return sorted.map { + $0.0 } - .store(in: &cancellables) + } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] devices in + self?.discoveredDevices = devices + } + .store(in: &cancellables) + + createPublisher(for: bleManager.scanning) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] scanning in + self?.bluetoothIsScanning = scanning.boolValue + } + .store(in: &cancellables) + + 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() { @@ -108,7 +122,13 @@ class BluetoothConnectionViewModel: ObservableObject { } func connectToDevice(device: BluetoothDeviceEntity) { - coreViewModel.connectToDevice(device: device) + Task { + do { + try await coreViewModel.connectToDevice(device: device) + } catch { + print("Cannot connect to bluetooth device: \(device.deviceName ?? "Unknown"): \(error)") + } + } } func disconnectFromDevice(device: BluetoothDeviceEntity) { diff --git a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift index 97bf4a80f..bafd157f7 100644 --- a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift +++ b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.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 CompletedSchedules: View { @StateObject var scheduleViewModel: ScheduleViewModel @@ -26,6 +26,6 @@ struct CompletedSchedules: View { ScheduleListHeader(scheduleViewModel: scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) ScheduleView(viewModel: scheduleViewModel) } - .customNavigationTitle(with: NavigationScreen.pastObservations.localize(),displayMode: .inline) + .customNavigationTitle(with: NavigationScreen.pastObservations.localize(), displayMode: .inline) } } 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 83bd063e4..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/). // 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 76da8a4f6..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/). // 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 9dbc91111..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/). // @@ -24,7 +24,7 @@ struct DatapointsCollection: View { CircleActivityIndicator() } 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 c52ed148f..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/). // 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/ErrorText.swift b/iosApp/iosApp/Views/Components/ErrorText.swift index 9ee65c845..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/). // @@ -21,7 +21,6 @@ struct ErrorText: View { Text(LocalizedStringKey(message)) .foregroundColor(.more.important) .fontWeight(.more.error) - } } diff --git a/iosApp/iosApp/Views/Components/ExpandableContent.swift b/iosApp/iosApp/Views/Components/ExpandableContent.swift index b8471bece..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,17 +19,17 @@ 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() - + if expanded { VStack { self.content() @@ -39,14 +39,12 @@ struct ExpandableContent: View { .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: { diff --git a/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift b/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift index 663eb2795..52274021d 100644 --- a/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift +++ b/iosApp/iosApp/Views/Components/ExpandableContentWithLink.swift @@ -19,10 +19,10 @@ 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) @@ -33,9 +33,9 @@ struct ExpandableContentWithLink: View { expanded.toggle() } } - + Divider().padding(.bottom) - + if expanded { VStack { self.content() diff --git a/iosApp/iosApp/Views/Components/ExpandableInput.swift b/iosApp/iosApp/Views/Components/ExpandableInput.swift index f070e384d..fb3dd0798 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/). // diff --git a/iosApp/iosApp/Views/Components/ExpandableText.swift b/iosApp/iosApp/Views/Components/ExpandableText.swift index f70923c4b..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 { @@ -71,7 +70,7 @@ struct ExpandableText: View { } } } - + Text(self.text) .foregroundColor(self.color) .lineLimit(self.expanded ? nil : self.lineLimit) @@ -80,7 +79,7 @@ struct ExpandableText: View { self.determineTruncation(geometry) } }) - + if self.truncated { Button(action: { self.expanded.toggle() @@ -88,7 +87,6 @@ struct ExpandableText: View { }) { Text(LocalizedStringKey(self.expanded ? "Read Less" : "Read More")) .font(.system(size: 16)) - } } } @@ -97,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 aced08ff1..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 { diff --git a/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift b/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift index c555e77aa..1250b5415 100644 --- a/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift +++ b/iosApp/iosApp/Views/Components/Filters/MoreFilterText.swift @@ -10,7 +10,7 @@ import SwiftUI struct MoreFilterText: View { @Binding var text: String - + var body: some View { Text(LocalizedStringKey(text)) .font(.system(size: 16)) @@ -18,5 +18,3 @@ struct MoreFilterText: View { .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 959fba901..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/). // diff --git a/iosApp/iosApp/Views/Components/InlineAbortButton.swift b/iosApp/iosApp/Views/Components/InlineAbortButton.swift index 420d4e55f..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,7 +22,7 @@ struct InlineAbortButton: View { Button { action() } label: { - HStack{ + HStack { Image(systemName: "square.fill") .padding(0.5) .foregroundColor(.more.important) @@ -30,7 +30,6 @@ struct InlineAbortButton: View { .foregroundColor(.more.secondary) } .padding(5) - } .tint(.more.primaryLight) .overlay( diff --git a/iosApp/iosApp/Views/Components/ModuleListItem.swift b/iosApp/iosApp/Views/Components/ModuleListItem.swift index 5f5f3c06b..9bc6b6cba 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: 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,7 +33,6 @@ struct ModuleListItem: View { } Divider() } - } } 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/MoreTextField.swift b/iosApp/iosApp/Views/Components/MoreTextField.swift index cebc76613..a54e22918 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/). // diff --git a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift index dd056d1bd..1c20b1ec0 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,38 +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: .constant(inputPlaceholder), inputText: $input, capitalization: capitalization, autoCorrectDisabled: autoCorrectDisabled, textType: textType) } else { MoreTextField(titleKey: .constant(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..cabb7c0d4 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/). // @@ -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 3d173198d..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/). // diff --git a/iosApp/iosApp/Views/Components/NotificationItem.swift b/iosApp/iosApp/Views/Components/NotificationItem.swift index ce7b112a9..7f2b6bc09 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,17 +37,17 @@ 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).toDateString(dateFormat: "dd.MM.yyyy HH:mm:ss")) .padding(.top, 4) } Spacer() if notificationModel.deepLink != nil { - if (!notificationModel.read || notificationModel.completed) { + if !notificationModel.read || notificationModel.completed { Image(systemName: notificationModel.completed ? "checkmark.circle" : "chevron.right") .foregroundColor(notificationModel.completed ? .more.approved : .more.secondary) } diff --git a/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift b/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift index 6b009a52b..e9809ebc4 100644 --- a/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift +++ b/iosApp/iosApp/Views/Components/ObservatinoDetailsData.swift @@ -7,21 +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 - + var body: some View { - VStack { HStack { Image(systemName: "calendar") @@ -30,15 +28,14 @@ struct ObservationDetailsData: View { Spacer() } HStack { - Image(systemName: "clock.fill") - .padding(0.7) + Image(systemName: "clock.fill") + .padding(0.7) Text("Timeframe") - .foregroundColor(.more.primary) - - BasicText(text: timeframe, color: .more.secondary) - Spacer() + .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..19af1d35d 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,23 +18,23 @@ 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:{ + Button(action: { self.callback(self.id) }) { HStack(alignment: .center) { @@ -54,7 +54,7 @@ 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/ScheduleListHeader.swift b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift index 8b3e63339..f30765e4b 100644 --- a/iosApp/iosApp/Views/Components/ScheduleListHeader.swift +++ b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift @@ -19,22 +19,21 @@ 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: "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(verbatim: String(scheduleViewModel.numberOfObservationErrors())) + Text(verbatim: String(scheduleViewModel.numberOfErrors)) Text("Error") } } @@ -45,4 +44,3 @@ struct ScheduleListHeader: View { } } } - diff --git a/iosApp/iosApp/Views/Components/SectionHeading.swift b/iosApp/iosApp/Views/Components/SectionHeading.swift index 6e6369832..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,7 +19,7 @@ struct SectionHeading: View { var sectionTitle: String var font: Font = Font.more.headline var showAllText = false - + var body: some View { if showAllText { Text(LocalizedStringKey(sectionTitle)) diff --git a/iosApp/iosApp/Views/Components/Title.swift b/iosApp/iosApp/Views/Components/Title.swift index 004454350..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/). // diff --git a/iosApp/iosApp/Views/Components/Title2.swift b/iosApp/iosApp/Views/Components/Title2.swift index 6bc94db6f..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/). // 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 7d92e351e..33dbd533f 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/). // @@ -19,7 +19,7 @@ import WebKit struct WebView: View { @State var url: URL? @StateObject var viewModel: WebViewViewModel - + var body: some View { VStack { if viewModel.progress < 1 { @@ -32,7 +32,7 @@ struct WebView: View { } } } - + private func refreshPage() { if let currentURL = viewModel.webView.url { viewModel.webView.load(URLRequest(url: currentURL)) @@ -42,25 +42,25 @@ struct WebView: View { struct SwiftUIWebView: UIViewRepresentable { typealias UIViewType = WKWebView - + private let url: URL? private let viewModel: WebViewViewModel - + init(viewModel: WebViewViewModel, url: URL?) { 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(URLRequest(url: url)) } else { - self.viewModel.webView.load(URLRequest(url: URL(string: "about:blank")!)) + viewModel.webView.load(URLRequest(url: URL(string: "about:blank")!)) } } } diff --git a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift index d842b4685..d3250d7e1 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/). // @@ -23,7 +23,7 @@ protocol WebViewListener { class WebViewViewModel: NSObject, ObservableObject { private static let webViewProgressObserverKey = "estimatedProgress" let webView = WKWebView(frame: .zero) - + var delegate: WebViewListener? @Published var progress: Float = 0 diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 3563606fa..2320cfe68 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.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,32 +19,31 @@ import SwiftUI struct ConsentView: View { @StateObject private var viewModel: ConsentViewModel @ObservedObject private var registration: RegistrationObservable - + init(registration: RegistrationObservable) { _registration = ObservedObject(wrappedValue: registration) _viewModel = StateObject(wrappedValue: ConsentViewModel(registrationService: registration.service)) } - + var body: some View { if let permissionModel = viewModel.permissionModel { VStack { Title2(titleText: permissionModel.studyTitle) .padding(.bottom, 30) - + ScrollView { ExpandableText(permissionModel.studyParticipantInfo, "Participant Information", lineLimit: 4) .padding(.bottom, 35) - + ConsentList(permissionModel: permissionModel) } - + Spacer() if registration.isLoading { ProgressView() .progressViewStyle(.circular) .tint(.more.primary) } else { - MoreActionButton(disabled: .constant(viewModel.requestedPermissions || registration.isLoading), alertOpen: $viewModel.showErrorAlert) { viewModel.requestPermissions() } label: { diff --git a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift index 5f5492fcc..04126a5b3 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterView.swift @@ -22,7 +22,7 @@ struct DashboardFilterView: View { 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,12 +39,12 @@ struct DashboardFilterView: View { } } }.padding(.vertical, 20) - + VStack { SectionHeading(sectionTitle: "Select Type") .padding(15) Divider() - + Button { viewModel.clearTypeFilter() } label: { @@ -55,7 +55,7 @@ struct DashboardFilterView: View { } .buttonStyle(.borderless) .frame(maxWidth: .infinity) - + Divider() ForEach(viewModel.currentTypeFilter.keys.sorted(), id: \.self) { filter in if let selected = viewModel.currentTypeFilter[filter]?.boolValue { @@ -69,7 +69,7 @@ struct DashboardFilterView: View { } .buttonStyle(.borderless) .frame(maxWidth: .infinity) - + Divider() } } diff --git a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift index cf81b381e..3ce8a6051 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardFilter/DashboardFilterViewModel.swift @@ -15,19 +15,19 @@ protocol DashboardFilterObserver { class DashboardFilterViewModel: ObservableObject { let coreViewModel: CoreDashboardFilterViewModel = CoreDashboardFilterViewModel(repository: AppDelegate.shared.repositories) - - var delegate: DashboardFilterObserver? = nil - + + 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 @@ -35,31 +35,31 @@ 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(localized: "All Items") 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 a7bcc8492..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/). // 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 39973112d..d4977a089 100644 --- a/iosApp/iosApp/Views/Info/InfoView.swift +++ b/iosApp/iosApp/Views/Info/InfoView.swift @@ -34,16 +34,16 @@ struct InfoView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - + Spacer() - + if let id = viewModel.participantId, let alias = viewModel.participantAlias { HStack(alignment: .center) { BasicText(text: "\("Participant") \(id): \(alias)", color: .more.secondary) } Divider() } - + ContactInfo( title: "info_contact_title", info: "info_disclaimer", @@ -52,7 +52,7 @@ struct InfoView: View { contactEmail: viewModel.contactEmail, contactPhoneNumber: viewModel.contactPhoneNumber ) - + Spacer() AppVersion() } diff --git a/iosApp/iosApp/Views/Info/InfoViewModel.swift b/iosApp/iosApp/Views/Info/InfoViewModel.swift index dcce4a800..6d9f9730b 100644 --- a/iosApp/iosApp/Views/Info/InfoViewModel.swift +++ b/iosApp/iosApp/Views/Info/InfoViewModel.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 shared import Combine import KMPNativeCoroutinesCombine +import shared class InfoViewModel: ObservableObject { - private let studyCoreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) @Published var studyTitle: String? @Published var contactInstitute: String? @@ -27,23 +26,23 @@ class InfoViewModel: ObservableObject { @Published var contactPhoneNumber: String? @Published var participantId: Int? @Published var participantAlias: String? - + private var cancellables = Set() - + init() { 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 - self.contactEmail = studyDetails.study.contactEmail - self.contactPhoneNumber = studyDetails.study.contactPhoneNumber - self.participantId = studyDetails.study.participantId?.intValue - self.participantAlias = studyDetails.study.participantAlias - } + .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 + self.contactEmail = studyDetails.study.contactEmail + self.contactPhoneNumber = studyDetails.study.contactPhoneNumber + self.participantId = studyDetails.study.participantId?.intValue + self.participantAlias = studyDetails.study.participantAlias } - .store(in: &cancellables) + } + .store(in: &cancellables) } } diff --git a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift index c8b98833f..e05a92c1b 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyConfirmationView.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/). // @@ -26,7 +26,6 @@ struct LeaveStudyConfirmationView: View { @State private var alertPresented: Bool = false @State var continueButton = Color.more.approved - var body: some View { MoreMainBackgroundView { VStack { diff --git a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift index 128929d7b..bd7f9b554 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.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 LeaveStudyView: View { @State var accButton = Color.more.approved @State var decButton = Color.more.important - + var body: some View { MoreMainBackgroundView { VStack(alignment: .center) { @@ -30,25 +30,24 @@ 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: "first_message") .foregroundColor(Color.more.important) .padding(.bottom, 2) .multilineTextAlignment(.center) - + Spacer() - + Text("really_message") .padding(.bottom) - - + MoreActionButton( backgroundColor: .more.approved, disabled: .constant(false) @@ -58,7 +57,7 @@ struct LeaveStudyView: View { Text("continue_study").foregroundColor(Color.more.white) } .padding(.bottom, 2) - + MoreActionButton( backgroundColor: .more.important, disabled: .constant(false) @@ -67,8 +66,7 @@ struct LeaveStudyView: View { } label: { Text("withdraw_study").foregroundColor(Color.more.white) } - - + Spacer() } .padding(.horizontal, 40) diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift index 0c8944085..337f963cd 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift @@ -20,7 +20,7 @@ struct LimeSurveyView: View { private let stringsTable = "LimeSurvey" var body: some View { - MoreMainBackgroundView(contentPadding: 0) { + MoreMainBackgroundView(contentPadding: 0) { VStack { if viewModel.dataLoading { HStack { diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift index b3b2a473d..8abda00e4 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/). // @@ -27,7 +27,7 @@ class LimeSurveyViewModel: ObservableObject { @Published var wasAnswered = false private var navigationModalState: NavigationModalState? - + private var limeSurveyLinkChange: Ktor_ioCloseable? init() { @@ -37,12 +37,11 @@ class LimeSurveyViewModel: ObservableObject { self?.dataLoading = boolean.boolValue } } - } func viewDidAppear() { coreViewModel.viewDidAppear() - + limeSurveyLinkChange = coreViewModel.onLimeSurveyLinkChange { [weak self] link in DispatchQueue.main.async { if let link { @@ -52,7 +51,6 @@ class LimeSurveyViewModel: ObservableObject { } } } - } func viewDidDisappear() { @@ -62,7 +60,7 @@ class LimeSurveyViewModel: ObservableObject { wasAnswered = false coreViewModel.viewDidDisappear() } - + func setNavigationModalState(navigationModalState: NavigationModalState) { self.navigationModalState = navigationModalState if let state = navigationModalState.navigationState(for: .limeSurvey) { @@ -80,7 +78,7 @@ class LimeSurveyViewModel: ObservableObject { } else { coreViewModel.cancel() } - self.navigationModalState?.closeView(screen: .limeSurvey) + navigationModalState?.closeView(screen: .limeSurvey) } private func extractPathAndParameters(url: URL) -> (String, [String: String]) { diff --git a/iosApp/iosApp/Views/Login/CameraPreviewView.swift b/iosApp/iosApp/Views/Login/CameraPreviewView.swift index 5d5e4e458..e8b72fbde 100644 --- a/iosApp/iosApp/Views/Login/CameraPreviewView.swift +++ b/iosApp/iosApp/Views/Login/CameraPreviewView.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // -import UIKit import AVFoundation +import UIKit class CameraPreviewView: UIView { private var previewLayer: AVCaptureVideoPreviewLayer? @@ -21,7 +21,7 @@ class CameraPreviewView: UIView { layer.videoGravity = .resizeAspectFill layer.frame = bounds self.layer.insertSublayer(layer, at: 0) - self.previewLayer = layer + previewLayer = layer } override func layoutSubviews() { diff --git a/iosApp/iosApp/Views/Login/LoginButton.swift b/iosApp/iosApp/Views/Login/LoginButton.swift index 5895e867f..d6ad9f2e6 100644 --- a/iosApp/iosApp/Views/Login/LoginButton.swift +++ b/iosApp/iosApp/Views/Login/LoginButton.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 LoginButton: View { @Binding var disabled: Bool diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 91824be0a..6138bd0a2 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -25,7 +25,6 @@ struct LoginView: View { @State private var showEndpoint = false @State private var disabledQRCodeButton = false - init(registration: RegistrationObservable) { _registration = ObservedObject(wrappedValue: registration) _model = StateObject(wrappedValue: LoginViewModel(registration: registration.service)) diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index 1afa78d78..09945795d 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -7,51 +7,51 @@ // 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 KMPNativeCoroutinesCombine import Combine +import KMPNativeCoroutinesCombine +import shared class LoginViewModel: ObservableObject { private let registrationService: RegistrationService @Published var endpoint: String = "" @Published var defaultEndpoint: String = "" @Published var token: String = "" - + @Published var showQRCodeView: Bool = false - + private var cancellables: Set = [] - - + init(registration: RegistrationService) { - self.registrationService = registration + registrationService = registration defaultEndpoint = registration.getEndpointRepository().endpoint() - + Publishers.CombineLatest($endpoint, $token) - .map { !$0.isEmpty || !$1.isEmpty } - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] hasInput in - self?.registrationService.clearError() - } - .store(in: &cancellables) + .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 ?? "" - } - } - + + if let tokenPart = qrCodeUrl.components(separatedBy: "token=").last, + tokenPart != qrCodeUrl { + token = tokenPart.components(separatedBy: "&").first ?? "" + } + } + func validate() { let loginModel = LoginModel(token: token, endpoint: currentStudyEndpoint()) if loginModel.valid() { @@ -60,13 +60,12 @@ class LoginViewModel: ObservableObject { AlertController.shared.openAlertDialog(model: AlertDialogModel(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 } } - diff --git a/iosApp/iosApp/Views/Login/QRCodeCameraView.swift b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift index c8fd27b03..dd597e37f 100644 --- a/iosApp/iosApp/Views/Login/QRCodeCameraView.swift +++ b/iosApp/iosApp/Views/Login/QRCodeCameraView.swift @@ -6,27 +6,26 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // -import SwiftUI 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() + 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() @@ -37,4 +36,3 @@ struct QRCodeCameraView_Previews: PreviewProvider { ) } } - diff --git a/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift b/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift index 89718d912..adb770d11 100644 --- a/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift +++ b/iosApp/iosApp/Views/Login/QRCodeScanDelegate.swift @@ -6,9 +6,9 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // -import SwiftUI import AVFoundation import AVKit +import SwiftUI class QRScannerDelegate: NSObject, AVCaptureMetadataOutputObjectsDelegate { var onCodeScanned: ((String) -> Void)? @@ -20,4 +20,3 @@ class QRScannerDelegate: NSObject, AVCaptureMetadataOutputObjectsDelegate { } } } - diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index b79e98b51..3cdf6f4ed 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -8,17 +8,17 @@ import shared -import SwiftUI import AVFoundation +import SwiftUI 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 { @@ -28,32 +28,32 @@ struct ScanQRCodeView: View { .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() { + .onAppear { viewModel.setupCamera() } - + ZStack { ForEach(0...4, id: \.self) { index in let rotation = Double(index) * 90 @@ -66,8 +66,8 @@ struct ScanQRCodeView: View { .frame(width: size.width, height: size.width) .frame(maxWidth: .infinity, maxHeight: .infinity) } - - if(viewModel.showError) { + + if viewModel.showError { HStack(spacing: 8) { Text("provide_camera_access") .foregroundColor(.more.important) @@ -77,7 +77,7 @@ struct ScanQRCodeView: View { } } } - + Spacer(minLength: 45) } .padding(15) @@ -113,4 +113,3 @@ struct ScanQRCodeView_Previews: PreviewProvider { ScanQRCodeView(model: LoginViewModel(registration: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder())))) } } - diff --git a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift index 956195507..90614ded0 100644 --- a/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift +++ b/iosApp/iosApp/Views/Login/ScanQrCodeViewModel.swift @@ -6,10 +6,9 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // +import AVFoundation import shared import SwiftUI -import AVFoundation - enum CameraPermissionStatus: String { case idle = "Not Determined" @@ -22,16 +21,16 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { @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 @@ -101,21 +100,20 @@ class ScanQRCodeViewModel: NSObject, ObservableObject { guard let session = self?.cameraSession else { return } session.startRunning() } - + } catch { presentError(errorDescription: error.localizedDescription) } } func presentError(errorDescription: String?) { - if (errorDescription != nil) { + 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/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 f5160e3f7..a858e2f03 100644 --- a/iosApp/iosApp/Views/Navigation.swift +++ b/iosApp/iosApp/Views/Navigation.swift @@ -83,7 +83,7 @@ struct NavigationWithDestinations: View { } } } - + @ViewBuilder private func viewForScreen(_ screen: NavigationScreen) -> some View { MoreMainBackgroundView(contentPadding: navigationModalState.horizontalContentPadding) { @@ -143,17 +143,17 @@ enum Capitalization { extension View { @ViewBuilder func customNavigationTitle(with text: String, displayMode: NavigationBarItem.TitleDisplayMode = .inline) -> some View { - self.modifier(NavigationTitleViewModifier(text: text, displayMode: displayMode)) + modifier(NavigationTitleViewModifier(text: text, displayMode: displayMode)) } @ViewBuilder func textFieldAutoCapitalizataion(capitalization: Capitalization) -> some View { if capitalization == .uppercase { - self.modifier(TextFieldViewModifier(capitalization: .characters)) + modifier(TextFieldViewModifier(capitalization: .characters)) } else if capitalization == .lowercase { - self.modifier(TextFieldViewModifier(capitalization: .never)) + modifier(TextFieldViewModifier(capitalization: .never)) } else { - self.modifier(TextFieldViewModifier(capitalization: .sentences)) + modifier(TextFieldViewModifier(capitalization: .sentences)) } } diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift index 4052247e4..bbb6e377d 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift @@ -7,31 +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 NotificationFilterView: View { @StateObject private var viewModel: NotificationFilterViewModel @State var filtersChanged = false - + init(coreVM: CoreNotificationFilterViewModel) { _viewModel = StateObject(wrappedValue: NotificationFilterViewModel(coreViewModel: coreVM)) } - + var body: some View { VStack(alignment: .leading) { ScrollView { SectionHeading(sectionTitle: "Select Filter") .padding(15) Divider() - - ForEach(viewModel.allFilters.keys.sorted{$0.sortIndex < $1.sortIndex}, id: \.self) { filter in + + ForEach(viewModel.allFilters.keys.sorted { + $0.sortIndex < $1.sortIndex + }, id: \.self) { filter in if let selected = viewModel.allFilters[filter] { Button { viewModel.toggleFilters(filter: filter) @@ -42,14 +44,13 @@ struct NotificationFilterView: View { .buttonStyle(.borderless) .frame(maxWidth: .infinity) Divider() - } } - }.padding(.vertical, 20) + } + .padding(.vertical, 20) Spacer() } .customNavigationTitle(with: NavigationScreen.notificationFilter.localize()) .navigationBarTitleDisplayMode(.inline) } } - diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift index aaea6a717..4829e9f8b 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift @@ -7,35 +7,37 @@ // 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 Combine import KMPNativeCoroutinesCombine +import shared class NotificationFilterViewModel: ObservableObject { private let stringTable = "NotificationFilter" let coreViewModel: CoreNotificationFilterViewModel - + @Published var allFilters: [NotificationFilterTypeModel: Bool] = [:] - + private var cancellables: Set = [] - + init(coreViewModel: CoreNotificationFilterViewModel) { self.coreViewModel = coreViewModel 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) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] filters in + self?.allFilters = filters.mapValues { + $0.boolValue + } + }) + .store(in: &cancellables) } - + 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 ecaf74962..552477204 100644 --- a/iosApp/iosApp/Views/Notification/NotificationView.swift +++ b/iosApp/iosApp/Views/Notification/NotificationView.swift @@ -20,7 +20,7 @@ struct NotificationView: View { @StateObject private var notificationViewModel: NotificationViewModel @EnvironmentObject private var navigationModalState: NavigationModalState - + init(coreFilterVM: CoreNotificationFilterViewModel) { _notificationViewModel = StateObject(wrappedValue: NotificationViewModel(filterViewModel: coreFilterVM)) } diff --git a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift index 0e3548a32..6634a898c 100644 --- a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift +++ b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift @@ -7,56 +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 shared import Combine import KMPNativeCoroutinesCombine - +import shared class NotificationViewModel: ObservableObject { private let filterViewModel: CoreNotificationFilterViewModel private let coreModel: CoreNotificationViewModel - + @Published var notificationList: [NotificationModel] = [] - + @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 = CoreNotificationViewModel(coreFilterModel: filterViewModel, notificationManager: AppDelegate.shared.notificationManager, protocolReplacement: nil, hostReplacement: nil) + createPublisher(for: coreModel.notificationList) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: {_ in}) { [weak self] notifications in - self?.notificationList = notifications - } - .store(in: &cancellables) - + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] notifications in + self?.notificationList = notifications + } + .store(in: &cancellables) + createPublisher(for: filterViewModel.activeTypes) - .map { (types: Set) -> String in - guard !types.isEmpty else { return "" } - return types.sorted().joined(separator: ", ") - } - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] text in - self?.filterText = text + .map { (types: Set) -> String in + guard !types.isEmpty else { + return "" } - .store(in: &cancellables) + return types.sorted().joined(separator: ", ") + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] text in + self?.filterText = text + } + .store(in: &cancellables) } - + func handleNotificationAction(notification: NotificationModel, navigationModalState: NavigationModalState) { - coreModel.handleNotificationAction(notification: notification) { (actionHandler, data) in - switch(actionHandler) { + 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) @@ -67,4 +67,3 @@ class NotificationViewModel: ObservableObject { } } } - diff --git a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsView.swift index fe4b07bdb..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/). // @@ -18,12 +18,11 @@ import SwiftUI struct ObservationDetailsView: View { @StateObject private var viewModel: ObservationDetailsViewModel - + init(observationId: String) { _viewModel = StateObject(wrappedValue: ObservationDetailsViewModel(observationId: observationId)) } - - + var body: some View { VStack( spacing: 20 @@ -32,7 +31,6 @@ struct ObservationDetailsView: View { HStack { Title2(titleText: viewModel.observationDetailModel?.observationTitle ?? "") .padding(0.5) - } .frame(height: 40) HStack( @@ -41,20 +39,19 @@ 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: "Participant Information", info: viewModel.observationDetailModel?.participantInformation ?? "", isOpen: true) } .padding(.top, 10) - + Spacer() } .customNavigationTitle(with: NavigationScreen.observationDetails.localize()) diff --git a/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift b/iosApp/iosApp/Views/ObservationDetails/ObservationDetailsViewModel.swift index b61a3196c..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(repository: AppDelegate.shared.repositories, 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 784941bf3..a8bfa45cb 100644 --- a/iosApp/iosApp/Views/ObservationErrorListView.swift +++ b/iosApp/iosApp/Views/ObservationErrorListView.swift @@ -19,7 +19,7 @@ struct ObservationErrorListView: View { if !taskObservationErrors.isEmpty || !taskObservationErrorActions.isEmpty { VStack { if !taskObservationErrors.isEmpty { - ScrollView { + ScrollView { VStack { ForEach(taskObservationErrors, id: \.self) { error in HStack { @@ -35,7 +35,7 @@ struct ObservationErrorListView: View { } .frame(maxHeight: 100) } - + if !taskObservationErrorActions.isEmpty { if taskObservationErrorActions .contains(Observation_.companion.ERROR_DEVICE_NOT_CONNECTED) { @@ -58,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 b5a3dbaf9..ce513857d 100644 --- a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift +++ b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift @@ -10,7 +10,7 @@ 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) 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/SimpleQuestionObservationViewModel.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift index e14219ed9..c1944132d 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift +++ b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.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,11 +21,11 @@ protocol SimpleQuestionObservationListener { class SimpleQuestionObservationViewModel: ObservableObject { private let coreModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel(repository: AppDelegate.shared.repositories, observationFactory: AppDelegate.shared.observationFactory) - + @Published var simpleQuestoinModel: SimpleQuestionModel? @Published var answers: [String] = [] @Published var answerSet: String = "" - + init() { coreModel.onLoadSimpleQuestionObservation { model in if let model { @@ -36,7 +36,7 @@ class SimpleQuestionObservationViewModel: ObservableObject { } } } - + func setScheduleId(navigationState: NavigationState) { if let scheduleId = navigationState.scheduleId { coreModel.setScheduleId(scheduleId: scheduleId, notificationId: navigationState.notificationId) @@ -44,24 +44,23 @@ class SimpleQuestionObservationViewModel: ObservableObject { coreModel.setScheduleViaObservationId(observationId: observationId, notificationId: navigationState.notificationId) } } - + func viewDidAppear() { coreModel.viewDidAppear() } - + func viewDidDisappear() { coreModel.viewDidDisappear() answerSet = "" } - + func setAnswer(answer: String) { - self.answerSet = answer + answerSet = answer } - + func finish() { - if !self.answerSet.isEmpty { - coreModel.finishQuestion(data: self.answerSet, setObservationToDone: true) + if !answerSet.isEmpty { + coreModel.finishQuestion(data: answerSet, setObservationToDone: true) } } - } diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift index 881954d25..d91ae4a1d 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift +++ b/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.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,9 +19,9 @@ struct SimpleQuestionThankYouView: View { @EnvironmentObject var questionModelState: NavigationModalState private let navigationStrings = "Navigation" private let simpleQuestionStrings = "SimpleQuestionObservation" - + var body: some View { - MoreMainBackgroundView { + MoreMainBackgroundView { VStack( alignment: .leading, spacing: 10 @@ -33,7 +33,7 @@ struct SimpleQuestionThankYouView: View { .padding(.bottom, 8) BasicText(text: "thank_you_participation", color: .more.secondary) Spacer() - + MoreActionButton(disabled: .constant(false)) { questionModelState.closeView(screen: .questionObservationThanks) } label: { @@ -46,5 +46,4 @@ struct SimpleQuestionThankYouView: View { .padding(.horizontal, 40) .customNavigationTitle(with: NavigationScreen.questionObservation.localize(), displayMode: .inline) } - } diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift index 86a0991b9..c80729a58 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift +++ b/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.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,43 +21,42 @@ struct SimpleQuetionObservationView: View { @EnvironmentObject var navigationModalState: NavigationModalState - 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("Answer") + 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) } - .padding(.top, 30) + } label: { + Text("Answer") } + .padding(.top, 30) } - .padding(.horizontal, 10) - .onAppear { - viewModel.viewDidAppear() - } - .onDisappear { - viewModel.viewDidDisappear() - } + } + .padding(.horizontal, 10) + .onAppear { + viewModel.viewDidAppear() + } + .onDisappear { + viewModel.viewDidDisappear() + } Spacer() } } diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift index be19dd6b6..5a94f8357 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationButton.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 ObservationButton: View { @EnvironmentObject var navigationModalState: NavigationModalState @@ -24,7 +24,7 @@ struct ObservationButton: View { var state: ScheduleState var disabled: Bool private let stringTable = "ScheduleListView" - + var body: some View { VStack { if observationType == "question-observation" { diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift b/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift index a3b624520..d193636cd 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,9 +20,9 @@ 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) @@ -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 2d495cca1..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/). // 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 5e7321f5d..fde77e65a 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleView.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleView.swift @@ -13,8 +13,8 @@ // https://commonsclause.com/). // -import SwiftUI import shared +import SwiftUI struct ScheduleView: View { @StateObject var viewModel: ScheduleViewModel @@ -22,7 +22,7 @@ struct ScheduleView: View { VStack { ScrollViewReader { _ in ScrollView(.vertical) { - if (viewModel.schedulesByDate.isEmpty) { + if viewModel.schedulesByDate.isEmpty { if viewModel.scheduleListType == ScheduleListType.running { EmptyListView(text: "No running tasks currently") } else if viewModel.scheduleListType == ScheduleListType.completed { diff --git a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift index 21f94b36e..d01900fd8 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -13,9 +13,9 @@ // https://commonsclause.com/). // -import shared import Combine import KMPNativeCoroutinesCombine +import shared class ScheduleViewModel: ObservableObject { let recorder = AppDelegate.shared.dataRecorder @@ -26,30 +26,37 @@ class ScheduleViewModel: ObservableObject { @Published var schedulesByDate: [Date: [ScheduleModel]] = [:] @Published var observationErrors: [String: Set] = [:] - + @Published var numberOfErrors: Int = 0 + private var cancellables = Set() init(scheduleListType: ScheduleListType) { self.scheduleListType = scheduleListType - coreModel = CoreScheduleViewModel(repos: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel, observationFactory: AppDelegate.shared.observationFactory) - + coreModel = CoreScheduleViewModel(repos: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) + createPublisher(for: coreModel.schedulesByDate) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] schedules in - self?.schedulesByDate = schedules.mapKeys { $0.toInt64().toDate() } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] schedules in + self?.schedulesByDate = schedules.mapKeys { + $0.toInt64().toDate() } - .store(in: &cancellables) - + } + .store(in: &cancellables) + createPublisher(for: coreModel.observationErrors) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] errors in - self?.observationErrors = errors - } - .store(in: &cancellables) - } + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] errors in + self?.observationErrors = errors + } + .store(in: &cancellables) - func numberOfObservationErrors() -> Int { - Set(observationErrors.values.flatMap { $0 }).count + createPublisher(for: coreModel.numberOfErrors) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] numberOfErrors in + self?.numberOfErrors = numberOfErrors.intValue + } + .store(in: &cancellables) } } @@ -66,4 +73,3 @@ extension ScheduleViewModel: ObservationActionDelegate { coreModel.stop(scheduleId: scheduleId) } } - diff --git a/iosApp/iosApp/Views/Settings/SettingsView.swift b/iosApp/iosApp/Views/Settings/SettingsView.swift index 4bf2bfec6..de642dee5 100644 --- a/iosApp/iosApp/Views/Settings/SettingsView.swift +++ b/iosApp/iosApp/Views/Settings/SettingsView.swift @@ -7,22 +7,22 @@ // 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 SettingsView: View { @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() @State private var exitButton = Color.more.important - + private let stringTable = "SettingsView" private let navigationStrings = "Navigation" - + var body: some View { VStack(alignment: .leading) { Text("settings_text") @@ -32,7 +32,7 @@ struct SettingsView: View { ConsentList(permissionModel: permissions) .padding(.top) } - + Spacer() } .customNavigationTitle(with: NavigationScreen.settings.localize()) diff --git a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift index 3e1c82c69..fd9b05a17 100644 --- a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift +++ b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift @@ -7,42 +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 -import shared import Combine +import Foundation import KMPNativeCoroutinesCombine +import shared class SettingsViewModel: ObservableObject { private let coreSettingsViewModel = CoreSettingsViewModel(shared: AppDelegate.shared) - + @Published var studyTitle: String? @Published var permissionModel: PermissionModel? @Published var showSettings = false - + private var cancellables: Set = [] + init() { createPublisher(for: coreSettingsViewModel.study) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: {_ in}) { [weak self] studyEntity in - self?.studyTitle = studyEntity?.studyTitle - } - .store(in: &cancellables) - + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] studyEntity in + self?.studyTitle = studyEntity?.studyTitle + } + .store(in: &cancellables) + createPublisher(for: coreSettingsViewModel.permissionModel) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: {_ in}) { [weak self] permissions in - self?.permissionModel = permissions - } - .store(in: &cancellables) - + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] permissions in + self?.permissionModel = permissions + } + .store(in: &cancellables) } - + func leaveStudy() { coreSettingsViewModel.exitStudy() } diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift index b70731734..88131d9ea 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsView.swift @@ -33,22 +33,22 @@ struct StudyDetailsView: View { Title2(titleText: viewModel.studyDetailsModel?.study.studyTitle ?? "") .padding(.top) .padding(.bottom) - + TaskCompletionBarView(viewModel: TaskCompletionBarViewModel(), progressViewTitle: "tasks_completed") .padding(.bottom, 0.2) - + HStack(alignment: .center) { BasicText(text: "study_duration") - + Spacer() BasicText(text: (viewModel.studyStart.formattedString()) + " - " + (viewModel.studyEnd.formattedString()), color: Color.more.secondary ) }.padding(.bottom) - + ExpandableText(viewModel.studyDetailsModel?.study.participantInfo ?? "", "participant_info", lineLimit: 4) .padding(.bottom, 35) - + ExpandableContentWithLink( content: { ScrollView { @@ -66,7 +66,7 @@ struct StudyDetailsView: View { }, title: { String(localized: "obs_modules") }, expanded: $isObservationListOpen ).padding(.top, 0.5) - + Spacer() } } @@ -85,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 2a916f09d..4122b6912 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift @@ -7,44 +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 shared import Combine import KMPNativeCoroutinesCombine +import shared class StudyDetailsViewModel: ObservableObject { private let coreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) @Published var studyDetailsModel: StudyDetailsModel? var studyStart: Date = Date() var studyEnd: Date = Date() - + private var cancellables = Set() + init() { 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 = studyDetails?.study.start?.toInt64().toDate() { - self?.studyStart = start - } - if let end = studyDetails?.study.end?.toInt64().toDate() { - self?.studyEnd = end - } + .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 = studyDetailsModel.study.end?.toInt64().toDate() { + self?.studyEnd = end } } - .store(in: &cancellables) + } + .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 7f05fb8e9..5f14a8c2a 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/). // @@ -32,7 +32,6 @@ struct StudyClosedView: View { BasicText(text: finishText) } } - } MoreActionButton(disabled: .constant(false)) { AppDelegate.shared.exitStudy(onDeletion: {}) diff --git a/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift b/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift index 5ea7fc5c7..5c6324597 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/). // diff --git a/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift b/iosApp/iosApp/Views/StudyStates/StudyUpdateView.swift index e4333b0ba..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/). // diff --git a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarView.swift index 6a0d5235f..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)) diff --git a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift index 00670ef8f..3fd670e4c 100644 --- a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift +++ b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift @@ -7,30 +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 shared 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(repository: AppDelegate.shared.repositories) - + private var cancellables: Set = [] - + init() { 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) + .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 be2b3a54f..4489c715f 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift @@ -27,7 +27,7 @@ struct TaskDetailsView: View { private let scheduleStringTable = "ScheduleListView" private let navigationStrings = "Navigation" private let errorStrings = "Errors" - + init(scheduleId: String) { _viewModel = StateObject(wrappedValue: TaskDetailsViewModel(scheduleId: scheduleId)) } @@ -65,7 +65,7 @@ struct TaskDetailsView: View { DatapointsCollection(datapoints: $viewModel.dataCount, running: detailsModel.state == .running) } Spacer() - + VStack { ObservationErrorListView(taskObservationErrors: viewModel.taskObservationErrors, taskObservationErrorActions: viewModel.taskObservationErrorAction) .background( @@ -84,7 +84,7 @@ struct TaskDetailsView: View { } ) .frame(maxWidth: .infinity, maxHeight: 100) - + if !detailsModel.hidden { if let scheduleId = navigationModalState.navigationState(for: .taskDetails)?.scheduleId { Divider() @@ -94,13 +94,11 @@ struct TaskDetailsView: View { observationType: detailsModel.observationType, state: detailsModel.state, disabled: !detailsModel.state.active() || !viewModel.taskObservationErrors.isEmpty) - .padding(.bottom) + .padding(.bottom) } } } - } - } .customNavigationTitle(with: NavigationScreen.taskDetails.localize()) .onAppear { diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift index 8ed404e10..cfebaf53a 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift @@ -7,38 +7,43 @@ // 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 - + + private var cancellables = Set() + init(scheduleId: String) { - self.coreModel = CoreTaskDetailsViewModel(repository: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, observationFactory: AppDelegate.shared.observationFactory, scheduleId: scheduleId) - self.simpleQuestionObservationVM = SimpleQuestionObservationViewModel() + coreModel = CoreTaskDetailsViewModel(repository: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, scheduleId: scheduleId) + simpleQuestionObservationVM = SimpleQuestionObservationViewModel() coreModel.onLoadTaskDetails { [weak self] taskDetails in if let self { if let taskDetails { @@ -46,27 +51,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 viewDidAppear() { coreModel.viewDidAppear() } - + func viewDidDisappear() { coreModel.viewDidDisappear() } @@ -74,22 +78,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 = [] } } } @@ -98,11 +106,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 2cfb75bb9..6641c2920 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,46 +1,48 @@ // + +import BackgroundTasks +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{ - } - .onChange(of: scenePhase) { newPhase in - switch newPhase { - case .background: - AppDelegate.shared.appInForeground(boolean: false) - if AppDelegate.shared.observationManager.hasRunningTasks() { - appDelegate.scheduleTasks() - } - case .inactive: - break - case .active: - AppDelegate.shared.appInForeground(boolean: true) - appDelegate.cancelBackgroundTasks() - break - default: - break + var body: some Scene { + WindowGroup { + ContentView(viewModel: contentViewModel) + .onAppear { + } + .onChange(of: scenePhase) { newPhase in + switch newPhase { + case .background: + ViewManager.shared.appIsInForeground(state: false) + if AppDelegate.shared.observationManager.hasRunningTasks() { + appDelegate.scheduleTasks() } + case .inactive: + break + case .active: + ViewManager.shared.appIsInForeground(state: true) + appDelegate.cancelBackgroundTasks() + break + default: + break } - } - } + } + } + } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt index ee938358f..2a96104de 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Platform.kt @@ -13,7 +13,7 @@ package io.redlink.more.more_app_mutliplatform 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/more_app_mutliplatform/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt index 4a8756286..c697b83b9 100644 --- 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 @@ -12,7 +12,6 @@ package io.redlink.more.more_app_mutliplatform import dev.tmapps.konnection.Konnection import io.github.aakira.napier.Napier -import io.github.aakira.napier.log import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.extensions.asClosure import io.redlink.more.more_app_mutliplatform.models.StudyState @@ -21,6 +20,7 @@ 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.observations.ObservationStates import io.redlink.more.more_app_mutliplatform.scopes.Scope import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnector @@ -34,23 +34,27 @@ 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.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class Shared( localNotificationListener: LocalNotificationListener, val repositories: MainRepository, - private val sharedStorageRepository: SharedStorageRepository, + sharedStorageRepository: SharedStorageRepository, val observationDataManager: ObservationDataManager, - val mainBluetoothConnector: BluetoothConnector, + mainBluetoothConnector: BluetoothConnector, val observationFactory: ObservationFactory, val dataRecorder: DataRecorder ) { private val viewManager = ViewManager val deeplinkManager = DeeplinkManager(repositories, observationFactory) val endpointRepository = EndpointRepository(sharedStorageRepository) - val credentialRepository = CredentialRepository(sharedStorageRepository) + val credentialRepository = CredentialRepository(sharedStorageRepository).also { + observationFactory.setCredentialsRepository(it) + } val networkService = NetworkService(endpointRepository, credentialRepository) val observationManager = ObservationManager( @@ -59,7 +63,11 @@ class Shared( dataRecorder ) val bluetoothController = - BluetoothController(repositories.bluetoothDevice, mainBluetoothConnector) + BluetoothController( + repositories.bluetoothDevice, + mainBluetoothConnector, + observationFactory = observationFactory + ) val notificationManager = NotificationManager( repositories, @@ -68,105 +76,55 @@ class Shared( deeplinkManager, sharedStorageRepository ) - - var appIsInForeGround = false + .also { observationFactory.setNotificationManager(it) } val unreadNotificationCount = notificationManager.unreadUserCount - private var bluetoothListener: Job? = null - private val mutex = Mutex() private val konnection = Konnection.instance init { - onApplicationStart() - observationFactory.setCredentialsRepository(credentialRepository) - observationFactory.setNotificationManager(notificationManager) - } - - private fun onApplicationStart() { - if (credentialRepository.hasCredentials.value) { - activateObservationWatcher() - } - } - - fun appInForeground(boolean: Boolean) { - if (appIsInForeGround == boolean) { - return - } - Napier.i { "App is in foreground: $boolean" } - appIsInForeGround = boolean - if (appIsInForeGround) { - if (credentialRepository.hasCredentials.value) { - updateStudyBlocking() - notificationManager.createNewFCMIfNecessary() - bluetoothListener?.cancel() - - bluetoothListener = StudyScope.launch(Dispatchers.IO) { - bluetoothController.listenToConnectionChanges( - observationFactory - ) - }.second - if (repositories.study.studyState.value == StudyState.ACTIVE) { - observationFactory.updateObservationErrors() - updateTaskStates() - } - } - notificationManager.clearAllNotifications() - } else { - ViewManager.showBLEView(false) - } - } - - fun updateTaskStates() { - if (appIsInForeGround && credentialRepository.hasCredentials.value && repositories.study.studyState.value == StudyState.ACTIVE) { - observationManager.updateTaskStates() - notificationManager.downloadMissedNotifications() - bluetoothController.startScanningForDevices(observationFactory.bleDevicesNeeded()) - } - } - - private fun activateObservationWatcher() { Scope.launch { - repositories.study.studyState.collect { state -> - if (state == StudyState.ACTIVE) { - observationDataManager.listenToDatapointCountChanges() - updateTaskStates() - observationManager.activateScheduleUpdate() - } else { - stopObservations() + var prevFg: Boolean? = null + var prevState: Boolean? = null + combine( + ViewManager.appInForeground, + credentialRepository.hasCredentials, + repositories.study.studyState + ) { fg, cred, state -> Pair(fg, cred && state.isActive()) } + .distinctUntilChanged() + .collectLatest { (fg, state) -> + ViewManager.currentStudyActive(state) + if (fg != prevFg && (state == prevState || prevState == null && state)) { + Napier.d(tag = "Shared::init") { "App went to foreground: $fg, study state: $state" } + if (fg && state) { + updateStudy() + observationManager.updateTaskStates() + observationFactory.updateObservationErrors() + notificationManager.createNewFCMIfNecessary() + notificationManager.clearAllNotifications() + notificationManager.downloadMissedNotifications() + } else { + ViewManager.showBLEView(false) + } + } else if (fg == prevFg && prevState != null && state != prevState) { + Napier.d(tag = "Shared::init") { "Study state changed: $prevState -> $state" } + if (state) { + observationDataManager.listenToDatapointCountChanges() + observationManager.activateScheduleUpdate() + Scope.launch { + observationManager.updateTaskStates() + observationFactory.updateObservationErrors() + } + } else { + stopObservations() + ViewManager.showBLEView(false) + ObservationStates.resetAll() + } + } + prevFg = fg + prevState = state } - } - } - } - - 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.Main) { - updateStudy(oldStudyState, newStudyState) } } @@ -264,10 +222,6 @@ class Shared( repositories.notification.deleteAll() ViewManager.studyError(false) repositories.study.upsert(study) - if (study.studyState?.let { StudyState.getState(it) } == StudyState.ACTIVE) { - resetFirstStartUp() - observationFactory.updateObservationErrors() - } viewManager.studyIsUpdating(false) } else { Napier.d(tag = "Shared::updateStudy") { "No study update needed - study data is unchanged" } @@ -276,15 +230,9 @@ class Shared( } } - fun newLogin() { + suspend fun newLogin() { notificationManager.newFCMToken() observationFactory.updateObservationErrors() - bluetoothListener?.cancel() - bluetoothListener = StudyScope.launch { - bluetoothController.listenToConnectionChanges( - observationFactory - ) - }.second } fun exitStudy(onDeletion: () -> Unit) { @@ -303,17 +251,6 @@ class Shared( } } - fun clearRemainingData() { - Napier.i { "Clearing remaining data..." } - bluetoothController.resetAll() - Scope.launch { - removeStudyData() - observationFactory.clearNeededObservationTypes() - clearSharedStorage() - notificationManager.clearAllNotifications() - } - } - private fun stopObservations() { dataRecorder.stopAll() observationDataManager.stopListeningToCountChanges() @@ -330,8 +267,4 @@ class Shared( fun unreadNotificationCountAsClosure(state: (Int) -> Unit) = unreadNotificationCount.asClosure(state) - - companion object { - const val FIRST_OPEN_AFTER_LOGIN_KEY = "first_open_after_login_key" - } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt index c004b35c4..a2f04e4dd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt @@ -59,7 +59,7 @@ interface StudyDao : BaseDao { @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/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt index 17b2df113..462a6b2ed 100644 --- 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 @@ -13,69 +13,29 @@ package io.redlink.more.more_app_mutliplatform.database.repository import io.redlink.more.more_app_mutliplatform.database.AppDatabase import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.map class BluetoothDeviceRepository( private val database: AppDatabase ) { - - private val deviceManager = BluetoothDeviceManager - - init { - Scope.launch(Dispatchers.IO) { - pairedDevices().cancellable().collect { - deviceManager.addPairedDeviceIds(it.toSet()) - } - } - } - - fun count(): Flow = database.bluetoothDeviceDao().getAllFlow().map { it.size.toLong() } - fun storePairedDevice(bluetoothDevice: BluetoothDeviceEntity) { if (bluetoothDevice.address != null) { - StudyScope.launch { - val existingDevice = - database.bluetoothDeviceDao().getByAddress(bluetoothDevice.address!!) - if (existingDevice == null) { - val entity = bluetoothDevice.toEntity() - database.bluetoothDeviceDao().insert(entity) - } + Scope.launch(Dispatchers.IO) { + database.bluetoothDeviceDao().insert(bluetoothDevice) } } } fun unpairDevice(bluetoothDevice: BluetoothDeviceEntity) { bluetoothDevice.address?.let { - StudyScope.launch(Dispatchers.IO) { + Scope.launch(Dispatchers.IO) { database.bluetoothDeviceDao().deleteByAddress(it) } } } fun pairedDevices(): Flow> = - database.bluetoothDeviceDao().getAllFlow().map { entities -> - entities.map { it.toBluetoothDevice() } - } - - private fun BluetoothDeviceEntity.toEntity(): BluetoothDeviceEntity { - return BluetoothDeviceEntity( - deviceId = this.deviceId, - deviceName = this.deviceName, - address = this.address - ) - } - - private fun BluetoothDeviceEntity.toBluetoothDevice(): BluetoothDeviceEntity { - return BluetoothDeviceEntity( - this@toBluetoothDevice.deviceId, - this@toBluetoothDevice.deviceName, - this@toBluetoothDevice.address - ) - } + database.bluetoothDeviceDao().getAllFlow() } \ 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 index 960346c33..c89a33c94 100644 --- 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 @@ -19,19 +19,21 @@ 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.scopes.StudyScope -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime class ScheduleRepository(private val appDatabase: AppDatabase) { + private val mutex = Mutex() + fun count() = appDatabase.scheduleDao().countAsFlow() fun allSchedulesWithStatus(done: Boolean = false): Flow> { @@ -43,6 +45,7 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { fun firstScheduleAvailableForObservationId(observationId: String): Flow { return appDatabase.scheduleDao().getByObservationIdFlow(observationId) + .distinctUntilChanged() .transform { scheduleList -> val observation = appDatabase.observationDao().getByObservationId(observationId) if (observation?.scheduleLess == true) { @@ -151,101 +154,58 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { return appDatabase.scheduleDao().getById(id) } - fun updateTaskStates(observationFactory: ObservationFactory, dataRecorder: DataRecorder) { - StudyScope.launch { - updateTaskStatesSync(observationFactory, dataRecorder) - } - } - - suspend fun updateTaskStatesSync( + suspend fun updateTaskStates( observationFactory: ObservationFactory, dataRecorder: DataRecorder ) { - val autoStartingObservations = observationFactory.autoStartableObservations() - Napier.i { "Updating Schedule states..." } - - try { - val schedules = appDatabase.scheduleDao().getByDone(false) - - val stateUpdates = mutableListOf>() - val activeIds = 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 - || (autoStartingObservations.isNotEmpty() - && scheduleEntity.hidden - && newState.active() - && scheduleEntity.observationType in autoStartingObservations - && observationFactory.observation(scheduleEntity.observationType) - ?.bleDevicesNeeded() - ?.let { needed -> - BluetoothDeviceManager.connectedDevices.value.map { it.deviceName } - .containsAll(needed) - } != false) - ) { - activeIds.add(scheduleEntity.scheduleId) - } - } - - StudyScope.launch(Dispatchers.IO) { - stateUpdates.forEach { (scheduleId, newState) -> - appDatabase.scheduleDao().updateState(scheduleId, newState.name) - } - } - - if (activeIds.isNotEmpty()) { - dataRecorder.startMultiple(activeIds) - } - } catch (e: Exception) { - Napier.e("Error updating schedule states", e) + if (mutex.isLocked) { + return } - } - - suspend fun updateTaskStatesWithBLEDevices( - observationFactory: ObservationFactory, - dataRecorder: DataRecorder - ) { - val autoStartingObservations = observationFactory.autoStartableObservations() - if (autoStartingObservations.isNotEmpty()) { - Napier.i { "Updating Schedule states using Bluetooth devices..." } + mutex.withLock { + val autoStartingObservations = observationFactory.autoStartableObservations() + Napier.i { "Updating Schedule states..." } try { val schedules = appDatabase.scheduleDao().getByDone(false) - val activeIds = schedules.filter { scheduleEntity -> - observationFactory.observation(scheduleEntity.observationType) - ?.bleDevicesNeeded() - ?.isNotEmpty() == true && scheduleEntity.observationType in autoStartingObservations - }.mapNotNull { scheduleEntity -> + val stateUpdates = mutableListOf>() + val activeIds = mutableSetOf() + + schedules.forEach { scheduleEntity -> val newState = scheduleEntity.updateState() - if (newState.active() && scheduleEntity.hidden && observationFactory.observation( - scheduleEntity.observationType - )?.bleDevicesNeeded() + if (scheduleEntity.getState() != newState) { + stateUpdates.add(scheduleEntity.scheduleId to newState) + Napier.i { "State update for Entity: $scheduleEntity; ${scheduleEntity.getState()} -> $newState" } + } + + if (newState == ScheduleState.RUNNING + || (autoStartingObservations.isNotEmpty() + && scheduleEntity.hidden + && newState.active() + && scheduleEntity.observationType in autoStartingObservations + && observationFactory.observation(scheduleEntity.observationType) + ?.bleDevicesNeeded() ?.let { needed -> - BluetoothDeviceManager.connectedDevices.value.map { it.deviceName } + BluetoothStateManagement.connectedDevices.value.map { it.deviceName } .containsAll(needed) - } != false + } != false) ) { - scheduleEntity.scheduleId - } else { - null + activeIds.add(scheduleEntity.scheduleId) } - }.toSet() + } + + stateUpdates.forEach { (scheduleId, newState) -> + appDatabase.scheduleDao().updateState(scheduleId, newState.name) + } if (activeIds.isNotEmpty()) { dataRecorder.startMultiple(activeIds) } } catch (e: Exception) { - Napier.e("Error updating schedule states with BLE devices", e) + Napier.e("Error updating schedule states", e) } } + } } \ 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/more_app_mutliplatform/extensions/AnyExtension.kt index 5814bb8b7..9456be3a8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AnyExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/AnyExtension.kt @@ -10,5 +10,4 @@ */ package io.redlink.more.more_app_mutliplatform.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/CoroutineExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt index 82a20f3a0..f0e2e060d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/CoroutineExtension.kt @@ -20,10 +20,14 @@ import kotlin.coroutines.CoroutineContext 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/FlowExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt index 8b9a83a34..2c195c579 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FlowExtension.kt @@ -50,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 { @@ -74,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 { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/DateFilterModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/DateFilterModel.kt index fac56d1ce..84e65b74c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/DateFilterModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/DateFilterModel.kt @@ -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,7 +35,7 @@ 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() } 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 index 2937bc8af..8c797710e 100644 --- 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 @@ -17,11 +17,13 @@ enum class NotificationFilterTypeModel(val type: String, val sortIndex: Int) { companion object { fun createModel(type: String): NotificationFilterTypeModel? { - return when(type) { + return when (type) { ALL.type -> ALL UNREAD.type -> UNREAD IMPORTANT.type -> IMPORTANT - else -> { null } + else -> { + null + } } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SettingsModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SettingsModel.kt deleted file mode 100644 index a74b7ed8a..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/SettingsModel.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.more_app_mutliplatform.models - - -data class SettingsModel ( - val studyTitle: String, - val observationConsent: List, - val settingsTitle: String, - val settingsDescription: String, -) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt index ca55b4539..9da942456 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/StudyState.kt @@ -16,6 +16,8 @@ enum class StudyState(val descr: String) { PAUSED("paused"), CLOSED("closed"); + fun isActive() = this == ACTIVE + companion object { fun getState(name: String) = entries.firstOrNull { it.descr == name } ?: NONE } 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 index 1be5e2931..3305025db 100644 --- 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 @@ -15,15 +15,13 @@ import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEnti import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.models.StudyState import io.redlink.more.more_app_mutliplatform.observations.observationTypes.ObservationType +import io.redlink.more.more_app_mutliplatform.scopes.Scope import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager 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 @@ -49,14 +47,6 @@ abstract class Observation( 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() @@ -99,7 +89,9 @@ abstract class Observation( if (removeNotification) { handleNotification(scheduleId) } - updateObservationErrors() + Scope.launch { + updateObservationErrors() + } } fun observationDataManagerAdded() = dataManager != null @@ -149,29 +141,25 @@ abstract class Observation( fun observerAccessible(): Boolean { val errors = observerErrors() Napier.d(tag = "Observation::observerAccessible") { errors.toString() } - updateObservationErrors() + Scope.launch { + updateObservationErrors() + } return errors.isEmpty() + } protected open fun observerErrors(): Set = emptySet() - fun updateObservationErrors() { - StudyScope.launch(Dispatchers.IO) { - repos.schedule.allSchedulesToday(observationType).firstOrNull()?.let { - if (it.isNotEmpty()) { - Napier.d(tag = "Observation::updateObservationErrors") { "ObservationErrors for ${observationType.observationType}" } - - if (repos.study.studyState.value == StudyState.ACTIVE) { - _observationErrors.value = Pair( - observationType.observationType, - observerErrors() - ) - } else { - _observationErrors.value = Pair( - observationType.observationType, - emptySet() - ) - } + 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() + ) } } } @@ -207,7 +195,9 @@ abstract class Observation( saveAndSend() observationShutdown(scheduleId) } - updateObservationErrors() + Scope.launch { + updateObservationErrors() + } } fun stopAndSetState(state: ScheduleState = ScheduleState.ACTIVE, scheduleId: String?) { @@ -224,7 +214,9 @@ abstract class Observation( observationShutdown(it) } } - updateObservationErrors() + Scope.launch { + updateObservationErrors() + } } fun stopAndSetDone(scheduleId: String) { @@ -240,7 +232,9 @@ abstract class Observation( observationShutdown(scheduleId) removeDataCount() handleNotification(scheduleId) - updateObservationErrors() + Scope.launch { + updateObservationErrors() + } } } 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 index 6fa44f165..1b7bd5bcc 100644 --- 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 @@ -80,7 +80,7 @@ abstract class ObservationDataManager(private val repository: MainRepository) { } fun stopListeningToCountChanges() { - Napier.d(tag = "ObservationDataManager::stopListeningToCountChanges") { "Stopped listening for changes in datapoint counts" } + Napier.d(tag = "ObservationDataManager::stopListeningToCAndroiduntChanges") { "Stopped listening for changes in datapoint counts" } countJob?.cancel() countJob = null } 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 index f093529b9..74c6c0508 100644 --- 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 @@ -12,10 +12,6 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -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.scopes.Scope @@ -23,12 +19,8 @@ import io.redlink.more.more_app_mutliplatform.services.notification.Notification import io.redlink.more.more_app_mutliplatform.services.store.CredentialRepository 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( repository: MainRepository, @@ -39,11 +31,6 @@ abstract class ObservationFactory( 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(repository)) @@ -51,33 +38,19 @@ abstract class ObservationFactory( Scope.launch(Dispatchers.IO) { repository.observation.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() } - } + _studyObservationTypes.value = it } } } fun addNeededObservationTypes(observationTypes: Set) { Napier.i(tag = "ObservationFactory::addNeededObservationTypes") { "Adding observation types to studyObservationTypes: $observationTypes" } - _studyObservationTypes.appendAll(observationTypes) + _studyObservationTypes.value += observationTypes } fun clearNeededObservationTypes() { - _studyObservationTypes.clear() - observationErrorWatcher?.cancel() - observationErrorWatcher = null - _observationErrors.update { emptyMap() } + _studyObservationTypes.value = setOf() + ObservationStates.resetAll() } fun setCredentialsRepository(credentialRepository: CredentialRepository) { @@ -113,22 +86,7 @@ abstract class ObservationFactory( return autoStartTypes } - private fun listenToObservationErrors() { - val flowList = studyObservations().map { it.observationErrors } - val combinedFlow = combine(flowList) { values -> - values.toMap() - } - observationErrorWatcher?.cancel() - observationErrorWatcher = Scope.launch(Dispatchers.IO) { - 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() { + suspend fun updateObservationErrors() { if (this.credentialRepository?.hasCredentials?.value == true) { studyObservations().forEach { it.updateObservationErrors() } } @@ -147,6 +105,4 @@ abstract class ObservationFactory( 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/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt index 6735fb361..74f1f545e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationManager.kt @@ -17,8 +17,8 @@ import io.redlink.more.more_app_mutliplatform.models.ScheduleState import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.datetime.Clock import kotlin.math.ceil @@ -39,22 +39,21 @@ class ObservationManager( fun activateScheduleUpdate() { Napier.i(tag = "ObservationManager::activateScheduleUpdate") { "ObservationManager: ScheduleUpdater activating..." } StudyScope.launch(Dispatchers.IO) { - repositories.schedule.allSchedulesWithStatus(true).cancellable().collect { list -> - if (runningObservations.isNotEmpty()) { - list.filter { it.scheduleId in runningObservations.keys } - .map { it.scheduleId }.forEach { - stop(it) - repositories.dataPointCount.delete(it) - } + 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, Dispatchers.IO) { - updateTaskStates() - } + val initialDelay = firstCall - Clock.System.now().toEpochMilliseconds() + StudyScope.repeatedLaunch(30000L, Dispatchers.IO, initialDelay) { + updateTaskStates() } StudyScope.launch(Dispatchers.IO) { repositories.observation.collectAllTimestamps().cancellable().collect { @@ -162,21 +161,19 @@ class ObservationManager( } } - fun startObservationType(type: String) { - StudyScope.launch(Dispatchers.IO) { - 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" } - } + 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" } } - } + } } fun stop(scheduleId: String) { @@ -202,18 +199,12 @@ class ObservationManager( stopAllInList() } - fun updateTaskStates() { + suspend fun updateTaskStates() { repositories.schedule.updateTaskStates(observationFactory, dataRecorder) } - suspend fun updateTaskStatesWithBLEDevices() { - repositories.schedule.updateTaskStatesWithBLEDevices(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() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationStates.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationStates.kt new file mode 100644 index 000000000..0731313c4 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationStates.kt @@ -0,0 +1,24 @@ +package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/observations/observationTypes/LimeSurveyType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/LimeSurveyType.kt index f82705a96..7c58abfc2 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/LimeSurveyType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/LimeSurveyType.kt @@ -10,5 +10,5 @@ */ package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt index 247f968e0..1ac63aa81 100644 --- 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 @@ -10,5 +10,6 @@ */ package io.redlink.more.more_app_mutliplatform.observations.observationTypes -class PolarVerityHeartRateType(sensorPermissions: Set): ObservationType("polar-verity-observation", sensorPermissions) { +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/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt index 40f16be44..957adc2ec 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt @@ -10,6 +10,6 @@ */ package io.redlink.more.more_app_mutliplatform.observations.observationTypes -class SimpleQuestionType () : - ObservationType("question-observation", setOf()){ +class SimpleQuestionType() : + ObservationType("question-observation", setOf()) { } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt index 3fb2bac2d..9f1170dbc 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt @@ -128,7 +128,6 @@ class RegistrationService( study?.let { study -> shared.observationFactory.clearNeededObservationTypes() shared.repositories.study.upsert(study) - shared.resetFirstStartUp() shared.newLogin() } ?: run { if (_error.value == null) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt index 228350261..b96c1558c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt @@ -108,10 +108,11 @@ object Scope { fun repeatedLaunch( intervalMillis: Long, coroutineContext: CoroutineContext = Dispatchers.Default, + initalDelay: Long = 0, block: suspend CoroutineScope.() -> Unit ): Pair { val uuid = createUUID() - val job = scope.repeatEveryFewSeconds(intervalMillis, coroutineContext, block) + val job = scope.repeatEveryFewSeconds(intervalMillis, initalDelay, coroutineContext, block) scope.launch { mutex.withLock { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt index 8e4b492ab..56e2f295b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt @@ -39,9 +39,10 @@ object StudyScope { fun repeatedLaunch( intervalMillis: Long, coroutineContext: CoroutineContext = Dispatchers.Default, + initalDelay: Long = 0, block: suspend CoroutineScope.() -> Unit ): Pair { - val result = Scope.repeatedLaunch(intervalMillis, coroutineContext, block) + val result = Scope.repeatedLaunch(intervalMillis, coroutineContext, initalDelay, block) Scope.launch { mutex.withLock { 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/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt index 47ce0c0d5..3141e654e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnector.kt @@ -17,10 +17,6 @@ interface BluetoothConnector : BluetoothConnectorObserver, Closeable { var observer: MutableSet - var bluetoothState: BluetoothState - - var scanning: Boolean - val specificBluetoothConnectors: MutableMap fun addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) @@ -31,8 +27,6 @@ interface BluetoothConnector : BluetoothConnectorObserver, Closeable { fun updateObserver(action: (BluetoothConnectorObserver) -> Unit) - fun replayStates() - fun scan() fun connect(device: BluetoothDeviceEntity): Error? 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/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt index 69bf671a0..dab60826e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothConnectorObserver.kt @@ -26,7 +26,5 @@ interface BluetoothConnectorObserver { fun removeDiscoveredDevice(device: BluetoothDeviceEntity) - fun isScanning(boolean: Boolean) - - 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/BluetoothDeviceManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDeviceManager.kt deleted file mode 100644 index 6aca0b062..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothDeviceManager.kt +++ /dev/null @@ -1,95 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.services.bluetooth - -import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity -import io.redlink.more.more_app_mutliplatform.extensions.appendAll -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()) - - @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 - - 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 foreachConnectedDevice(handler: (BluetoothDeviceEntity) -> Unit) { - this.connectedDevices.value.forEach(handler) - } - - fun foreachDiscoveredDevice(handler: (BluetoothDeviceEntity) -> 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/bluetooth/BluetoothStateManagement.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt new file mode 100644 index 000000000..28578c23c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt @@ -0,0 +1,161 @@ +package io.redlink.more.more_app_mutliplatform.services.bluetooth + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.redlink.more.more_app_mutliplatform.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 Override" } + } + + fun enableBgScanning(): Boolean { + if (bgScanningActive.value) { + return false + } + _bgScanningActive.value = true + Napier.i { "BLE Background Scan enabled" } + return true + } + + fun disableBgScanning() { + _bgScanningActive.value = false + Napier.i { "BLE Background Scan disabled" } + } + + 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/more_app_mutliplatform/services/bluetooth/ScanMode.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/ScanMode.kt new file mode 100644 index 000000000..7bebf89d6 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/ScanMode.kt @@ -0,0 +1,7 @@ +package io.redlink.more.more_app_mutliplatform.services.bluetooth + +enum class ScanMode { + Stopped, + Foreground, + Background +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt new file mode 100644 index 000000000..b01a33673 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt @@ -0,0 +1,20 @@ +package io.redlink.more.more_app_mutliplatform.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/openapi/model/ApiKey.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ApiKey.kt index 3742de8fd..f586c6385 100644 --- 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 @@ -17,20 +17,19 @@ 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 + * Credentials for the App for interacting with the backends * - * @param apiId - * @param apiKey + * @param apiId + * @param apiKey */ @Serializable -data class ApiKey ( +data class ApiKey( @SerialName(value = "apiId") @Required val apiId: 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 index 7e2566425..43f9af8ae 100644 --- 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 @@ -22,14 +22,14 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * The configuration settings for the App while participating on a study + * 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. + * @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 ( +data class AppConfiguration( @SerialName(value = "credentials") @Required val credentials: ApiKey, 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 index b26d87675..5b8f9f66a 100644 --- 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 @@ -24,12 +24,12 @@ 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 + * @param bulkId + * @param dataPoints */ @Serializable -data class DataBulk ( +data class DataBulk( @SerialName(value = "bulkId") @Required val bulkId: kotlin.String, 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 index 22c31ab79..754794b55 100644 --- 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 @@ -17,19 +17,18 @@ 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 + * @param code + * @param msg */ @Serializable -data class Error ( +data class Error( @SerialName(value = "code") val code: 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 index d44e274c8..5caf17e4b 100644 --- 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 @@ -24,17 +24,17 @@ import kotlinx.serialization.Serializable /** * configuration-settings for Firebase Cloud Messaging * - * @param service + * @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 apiKey + * @param databaseUrl * @param gcmSenderId The Project Number from the Google Developer's console - * @param storageBucket + * @param storageBucket */ @Serializable -data class FcmNotificationConfig ( +data class FcmNotificationConfig( @SerialName(value = "service") @Required override val service: PushNotificationServiceType, 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 index 0e0a90f9b..19db3021d 100644 --- 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 @@ -17,23 +17,22 @@ 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 apiKey + * @param databaseUrl * @param gcmSenderId The Project Number from the Google Developer's console - * @param storageBucket + * @param storageBucket */ @Serializable -data class FcmNotificationConfigAllOf ( +data class FcmNotificationConfigAllOf( /* The Google Cloud project ID */ @SerialName(value = "projectId") val projectId: 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 index 05f96c876..437406fc5 100644 --- 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 @@ -23,7 +23,7 @@ data class Log( val user: User? = null, val tag: String? = null, val throwable: LogThrowable? = null, -){ +) { val timestamp: String = Clock.System.now().toString() val platform: Platform = getPlatform() } 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 index b2c77df71..64860030d 100644 --- 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 @@ -25,19 +25,19 @@ 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 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 + * @param configuration + * @param hidden */ @Serializable -data class Observation ( +data class Observation( @SerialName(value = "observationId") @Required val observationId: kotlin.String, 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 index 5ba230ca9..96b1440ef 100644 --- 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 @@ -17,20 +17,19 @@ 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 + * + * @param observationId + * @param active */ @Serializable -data class ObservationConsent ( +data class ObservationConsent( @SerialName(value = "observationId") @Required val observationId: kotlin.String, 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 index 4361bb850..34189a101 100644 --- 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 @@ -24,17 +24,17 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject /** - * * - * @param dataId - * @param observationId - * @param observationType - * @param dataValue - * @param timestamp + * + * @param dataId + * @param observationId + * @param observationType + * @param dataValue + * @param timestamp */ @Serializable -data class ObservationData ( +data class ObservationData( @SerialName(value = "dataId") @Required val dataId: kotlin.String, 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 index 5e192f803..b68b463d0 100644 --- 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 @@ -17,20 +17,19 @@ 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 + * + * @param start + * @param end */ @Serializable -data class ObservationSchedule ( +data class ObservationSchedule( @SerialName(value = "start") val start: 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 index 7c65d26ac..54382df2a 100644 --- 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 @@ -17,7 +17,6 @@ package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -29,11 +28,11 @@ import kotlinx.serialization.Serializable */ @Serializable -data class Participant ( +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/PushNotificationConfig.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationConfig.kt index 333bd454a..bf6cb76f0 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/more_app_mutliplatform/services/network/openapi/model/PushNotificationConfig.kt @@ -17,20 +17,19 @@ package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - -import kotlinx.serialization.* -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName /** - * * - * @param service + * + * @param service */ - interface PushNotificationConfig { - @SerialName(value = "service") @Required val service: PushNotificationServiceType + @SerialName(value = "service") + @Required + val service: PushNotificationServiceType } 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 index 737107aa0..73f4ad8b9 100644 --- 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 @@ -17,7 +17,6 @@ package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -45,16 +44,17 @@ enum class PushNotificationServiceType(val value: kotlin.String) { /** * 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 + 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() - } + 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 index 25d943fa5..88998544a 100644 --- 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 @@ -17,18 +17,17 @@ package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * * - * @param token + * + * @param token */ @Serializable -data class PushNotificationToken ( +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 index c02a0dd74..8ecf3c66b 100644 --- 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 @@ -17,7 +17,6 @@ package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - import kotlinx.datetime.LocalDate import kotlinx.serialization.Required import kotlinx.serialization.SerialName @@ -26,11 +25,11 @@ 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 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. 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 index 4a68c4c12..fda06a434 100644 --- 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 @@ -17,7 +17,6 @@ package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - import kotlinx.serialization.Required import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -27,12 +26,12 @@ import kotlinx.serialization.Serializable * * @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 + * @param consentInfoMD5 MD5-Hash of the `consentInfo` (text) the participant actually gave consent. + * @param observations */ @Serializable -data class StudyConsent ( +data class StudyConsent( /* Explicitly state the consent of the Participant */ @SerialName(value = "consent") @Required val consent: kotlin.Boolean = false, 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 index 07da56278..e42fec7cd 100644 --- 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 @@ -17,7 +17,6 @@ package io.redlink.more.more_app_mutliplatform.services.network.openapi.model - import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -31,7 +30,7 @@ import kotlinx.serialization.Serializable */ @Serializable -data class StudyContact ( +data class StudyContact( @SerialName(value = "institute") val institute: kotlin.String? = null, @@ -41,5 +40,5 @@ data class StudyContact ( @SerialName(value = "phoneNumber") val phoneNumber: kotlin.String? = null, -) + ) 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 index 2adcc7415..3ea5f9def 100644 --- 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 @@ -130,11 +130,9 @@ class NotificationManager( } } - fun downloadMissedNotifications() { - Scope.launch(Dispatchers.IO) { - Napier.d { "Updating notifications" } - storeNotifications(NotificationEntity.toEntityList(networkService.downloadMissedNotifications())) - } + suspend fun downloadMissedNotifications() { + Napier.d { "Updating notifications" } + storeNotifications(NotificationEntity.toEntityList(networkService.downloadMissedNotifications())) } fun deleteNotificationFromRepository(notificationId: String) { 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 index 192694570..a5fedcc51 100644 --- 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 @@ -7,10 +7,11 @@ import kotlinx.coroutines.flow.StateFlow object ViewManager { private val _studyIsUpdating = MutableStateFlow(false) private val _showBluetoothView = MutableStateFlow(false) - private val _checkingForNewStudyData = MutableStateFlow(false) private val _studyLoadingError = MutableStateFlow(false) + private val _appInForeground = MutableStateFlow(false) + @NativeCoroutines val studyLoadingError: StateFlow = _studyLoadingError @@ -18,38 +19,45 @@ object ViewManager { val studyIsUpdating: StateFlow = _studyIsUpdating @NativeCoroutines - val checkingForNewStudyData: StateFlow = _checkingForNewStudyData + val bleViewActive: StateFlow = _showBluetoothView @NativeCoroutines - val showBluetoothView: StateFlow = _showBluetoothView + val appInForeground: StateFlow = _appInForeground + + private val _bleViewOpen = MutableStateFlow(false) - private var bleViewOpen = false + private val _activeStudy = MutableStateFlow(false) + + fun currentStudyActive(state: Boolean) { + _activeStudy.value = state + } fun studyIsUpdating(state: Boolean) { _studyIsUpdating.value = state _showBluetoothView.value = false } - fun checkingForUpdate(state: Boolean) { - _checkingForNewStudyData.value = state - } - fun showBLEView(state: Boolean): Boolean { - if (!state || !_showBluetoothView.value || !bleViewOpen || !_studyIsUpdating.value) { + if (_activeStudy.value && !bleViewActive.value && !_bleViewOpen.value && !_studyIsUpdating.value) { _showBluetoothView.value = state return state } + _showBluetoothView.value = false return false } fun bleViewOpen(state: Boolean) { - bleViewOpen = state + _bleViewOpen.value = state } fun studyError(hasError: Boolean) { _studyLoadingError.value = hasError } + fun appIsInForeground(state: Boolean) { + _appInForeground.value = state + } + fun resetAll() { _studyIsUpdating.value = false _showBluetoothView.value = false 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 index 2c678cb8a..f3588b916 100644 --- 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 @@ -10,282 +10,201 @@ */ package io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection -import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier import io.ktor.utils.io.core.Closeable import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity 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.set import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope 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.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothState +import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement +import io.redlink.more.more_app_mutliplatform.services.bluetooth.ScanMode import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import io.redlink.more.more_app_mutliplatform.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.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull +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 + private val scanInterval: Long = 10000, + observationFactory: ObservationFactory ) : CoreViewModel(), BluetoothConnectorObserver, Closeable { - private val deviceManager = BluetoothDeviceManager + private val bleManager = BluetoothStateManagement + private val supervisor = SupervisorJob() - private val _isScanning = MutableStateFlow(false) + private var periodicScanJob: Job? = null - @NativeCoroutines - val isScanning: StateFlow = _isScanning - - private var backgroundScanningEnabled = false - private var viewActive = false - - private var scanJob: String? = null - - private val _bluetoothPower = MutableStateFlow(BluetoothState.ON) + init { + bluetoothConnector.addObserver(this) - @NativeCoroutines - val bluetoothPower: StateFlow = _bluetoothPower + Scope.launch(Dispatchers.IO) { + bluetoothDeviceRepository.pairedDevices().distinctUntilChanged().cancellable().collect { + bleManager.addPairedDeviceIds(it.toSet()) + } + } - private var bleViewHasBeenOpened = false + 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 + ) + } + } + } - init { - bluetoothConnector.addObserver(this) - bluetoothConnector.replayStates() + Scope.launch(supervisor + Dispatchers.IO) { + bleManager.connectedDevices.collect { + observationFactory.updateObservationErrors() + } + } } fun observerDeviceAccessible(bleDevices: Set): Boolean { - val pairedDevices = deviceManager.pairedDevices.value - val connectedDevices = deviceManager.connectedDevices.value; - if (bleDevices.anyNameIn(pairedDevices)) { - if (bleDevices.anyNameIn(connectedDevices)) { + if (bleDevices.anyNameIn(bleManager.pairedDevices.value)) { + if (bleDevices.anyNameIn(bleManager.connectedDevices.value)) { disableBackgroundScanner() return true } else { enableBackgroundScanner() } } else { - if (!bleViewHasBeenOpened) { - if (ViewManager.showBLEView(true)) { - bleViewHasBeenOpened = true - } + if (!bleManager.uiOverride.value && !ViewManager.bleViewActive.value) { + ViewManager.showBLEView(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) - } - } - } + bleManager.enableBgScanning() } private fun disableBackgroundScanner() { - backgroundScanningEnabled = false - if (!viewActive) { - stopPeriodicScan() - } + bleManager.disableBgScanning() } 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() - } - } - } + bleManager.uiOverrides(true) } 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() - } + bleManager.uiOverrides(false) } - 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, Dispatchers.IO) { - Napier.i(tag = "BluetoothController::startPeriodicScan") { "Scanning..." } - scanForDevices() - delay(customScanDuration) - Napier.i(tag = "BluetoothController::startPeriodicScan") { "Stop Scanning..." } - stopScanning() - }.first + private fun startPeriodicScan(duration: Long, interval: Long) { + if (periodicScanJob?.isActive == true || bleManager.scanning.value || bleManager.devicesCurrentlyConnecting.value.isNotEmpty()) { + return } + periodicScanJob = Scope.repeatedLaunch(interval, supervisor) { + Napier.d { "Scanning for Bluetooth Devices..." } + bluetoothConnector.scan() + delay(duration) + Napier.d { "Stopping BLE Scan" } + bluetoothConnector.stopScanning() + }.second } 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() { + periodicScanJob?.cancel() + periodicScanJob = null bluetoothConnector.stopScanning() } - fun connectToDevice(device: BluetoothDeviceEntity): Boolean { - if (!deviceManager.connectedDevices.value.contains(device)) { - Napier.i(tag = "BluetoothController::connectToDevice") { "Connecting to $device" } - deviceManager.addConnectingDevices(setOf(device)) - return bluetoothConnector.connect(device) == null + 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 } - return true } fun unpairFromDevice(device: BluetoothDeviceEntity) { Napier.i(tag = "BluetoothController::disconnectFromDevice") { "Disconnecting from $device" } bluetoothConnector.disconnect(device) bluetoothDeviceRepository.unpairDevice(device) - deviceManager.removePairedDeviceIds(setOf(device)) + bleManager.removePairedDeviceIds(setOf(device)) } override fun isConnectingToDevice(bluetoothDevice: BluetoothDeviceEntity) { - deviceManager.addConnectingDevices(setOf(bluetoothDevice)) + bleManager.addConnectingDevices(setOf(bluetoothDevice)) } override fun didConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { - deviceManager.addConnectedDevices(setOf(bluetoothDevice)) + bleManager.addConnectedDevices(setOf(bluetoothDevice)) + bluetoothDeviceRepository.storePairedDevice(bluetoothDevice) } override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDeviceEntity) { Napier.i(tag = "BluetoothController::didDisconnectFromDevice") { "Disconnected from $bluetoothDevice" } - deviceManager.removeConnectedDevices(setOf(bluetoothDevice)) + bleManager.removeConnectedDevices(setOf(bluetoothDevice)) } override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDeviceEntity) { 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.value = bluetoothState - if (bluetoothState == BluetoothState.OFF) { - deviceManager.clearDiscovered() - deviceManager.clearConnected() - deviceManager.clearConnectingDevices() - } + bleManager.removeConnectingDevices(setOf(bluetoothDevice)) } override fun didDiscoverDevice(device: BluetoothDeviceEntity) { - if (!deviceManager.connectedDevices.value.contains(device)) { + if (!bleManager.connectedDevices.value.contains(device)) { Napier.i(tag = "BluetoothController::didDiscoverDevice") { "Discovered device: $device" } - deviceManager.addDiscoveredDevices(setOf(device)) - if (deviceManager.pairedDevices.value.contains(device)) { - connectToDevice(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" } - deviceManager.removeDiscoveredDevices(setOf(device)) + bleManager.removeDiscoveredDevices(setOf(device)) } - override fun isScanning(boolean: Boolean) { - Napier.i(tag = "BluetoothController::isScanning") { "Scanning status changed to: $boolean" } - this._isScanning.set(boolean) - } - - suspend fun listenToConnectionChanges( - observationFactory: ObservationFactory - ) { - deviceManager.connectedDevices.collect { - observationFactory.updateObservationErrors() - } - } - - fun resetAll() { + override fun resetAll() { Napier.i(tag = "BluetoothController::resetAll") { "Resetting Bluetooth data!" } + disableBackgroundScanner() + bleManager.uiOverrides(false) stopPeriodicScan() - close() - _isScanning.set(false) - backgroundScanningEnabled = false - viewActive = false - deviceManager.resetAll() - scanJob = null + bleManager.clearDiscovered() + bluetoothConnector.resetAll() } override fun close() { - scanJob?.let { Scope.cancel(it) } - scanJob = null - bluetoothConnector.stopScanning() + resetAll() + supervisor.cancel() } companion object { @@ -296,3 +215,4 @@ class BluetoothController( private const val MAX_BACKGROUND_SCAN_INTERVAL = 300000L // Max 5 minutes between scans } } + diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt new file mode 100644 index 000000000..935cd328f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt @@ -0,0 +1,22 @@ +package io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository +import io.redlink.more.more_app_mutliplatform.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/schedules/CoreScheduleViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/schedules/CoreScheduleViewModel.kt index c853ae6a3..42b79a8c7 100644 --- 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 @@ -13,22 +13,23 @@ package io.redlink.more.more_app_mutliplatform.viewModels.schedules import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository +import io.redlink.more.more_app_mutliplatform.extensions.mapState import io.redlink.more.more_app_mutliplatform.extensions.time 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.observations.DataRecorder 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.ObservationStates import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel -import kotlinx.coroutines.Dispatchers +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.combine import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -38,8 +39,7 @@ class CoreScheduleViewModel( private val repos: MainRepository, private val dataRecorder: DataRecorder, private val scheduleListType: ScheduleListType, - val coreFilterModel: CoreDashboardFilterViewModel, - private val observationFactory: ObservationFactory, + val coreFilterModel: CoreDashboardFilterViewModel ) : CoreViewModel() { private var originalScheduleList = emptySet() @@ -48,26 +48,27 @@ class CoreScheduleViewModel( @NativeCoroutines val schedulesByDate: StateFlow>> = _schedulesByDate - private val _observationErrors = MutableStateFlow>>(emptyMap()) + private val parentJob = SupervisorJob() @NativeCoroutines - val observationErrors: StateFlow>> = _observationErrors + 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 { - launchScope { - observationFactory.observationErrors.collect { - val errors = it.mapValues { entry -> - entry.value.filter { it != Observation.ERROR_DEVICE_NOT_CONNECTED }.toSet() - } - withContext(Dispatchers.Main) { - _observationErrors.update { errors } - } - } - } - launchScope { coreFilterModel.currentTypeFilter .combine(coreFilterModel.currentDateFilter) { typeFilter, dateFilter -> @@ -137,8 +138,6 @@ class CoreScheduleViewModel( return createModels(scheduleList.filter { !it.hidden }) } - fun numberOfObservationErrors(): Int = _observationErrors.value.values.flatten().toSet().count() - private fun updateSchedulesFromSnapshot(newSchedules: Collection) { val newMap: Map> = newSchedules 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 index eee11bc5c..de84f4e87 100644 --- 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 @@ -52,7 +52,7 @@ class CoreSettingsViewModel( } } } - + fun exitStudy() { shared.exitStudy { _dataDeleted.value = true 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/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt index 21713eafb..a75a2b9f8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/startupConnection/CoreBluetoothViewModel.kt @@ -32,7 +32,7 @@ class CoreBluetoothViewModel( coreBluetooth.viewDidDisappear() } - fun connectToDevice(device: BluetoothDeviceEntity): Boolean { + suspend fun connectToDevice(device: BluetoothDeviceEntity): Boolean { return coreBluetooth.connectToDevice(device) } 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 index f37ae8a43..07c13044b 100644 --- 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 @@ -16,7 +16,7 @@ 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.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory +import io.redlink.more.more_app_mutliplatform.observations.ObservationStates import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.firstOrNull class CoreTaskDetailsViewModel( private val repository: MainRepository, private val dataRecorder: DataRecorder, - private val observationFactory: ObservationFactory, private var scheduleId: String ) : CoreViewModel() { @@ -65,7 +64,7 @@ class CoreTaskDetailsViewModel( } } launchScope { - observationFactory.observationErrors.collect { errors -> + ObservationStates.observationErrors.collect { errors -> _observationErrors.value = errors taskDetailsModel.value?.let { taskDetails -> if (taskDetails.observationType != "") { diff --git a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/toString.kt b/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/toString.kt index 2f659bd74..14ec254b6 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/toString.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/toString.kt @@ -10,15 +10,18 @@ */ package io.redlink.more.more_app_mutliplatform.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) From 1434cbc3d3d48d2afbe48685b3e4db14d7de704e Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:02:20 +0200 Subject: [PATCH 40/59] =?UTF-8?q?#349:=20Added=20a=20fallback=20method=20t?= =?UTF-8?q?o=20upload=20databulks,=20if=20the=20Workmanager=E2=80=A6=20(#3?= =?UTF-8?q?51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #349: Added a fallback method to upload databulks, if the Workmanager is not available or does not run it in a feasable time * Changed Xcode Version in pipeline * Changed Xcode Version in pipeline * Changed Xcode Version in pipeline * Fixed old Xcode Beta issue in pipeline --- .github/workflows/build.yml | 7 +- .github/workflows/deploy-beta.yml | 5 + .../AndroidObservationDataManager.kt | 111 ++++++++++++++++-- .../observations/ObservationDataManager.kt | 14 ++- 4 files changed, 120 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94da39cf5..6537061d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ on: jobs: ios: name: iOS - runs-on: macos-15 + runs-on: macos-26 environment: AppStore env: FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} @@ -35,6 +35,11 @@ jobs: - name: Checkout repo uses: actions/checkout@v3 + - name: Set Xcode Version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.0.1' + - name: Build iOS app run: fastlane build diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index c70a07f47..9fb0d9620 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -38,6 +38,11 @@ jobs: - name: Checkout repo uses: actions/checkout@v3 + - name: Set Xcode Version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.0.1' + - name: Set FASTLANE_BUILD_NUMBER from tag run: echo "FASTLANE_BUILD_NUMBER=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 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 c9a40e16b..6c3a8b77f 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,33 +11,124 @@ 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.database.repository.MainRepository import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManager +import io.redlink.more.more_app_mutliplatform.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, repository: MainRepository) : ObservationDataManager(repository) { - private val workManager = WorkManager.getInstance(context) + 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) + .setRequiresBatteryNotLow(true) .build() override fun sendData(onCompletion: (Boolean) -> Unit) { - val dataWorker = OneTimeWorkRequestBuilder() - .setConstraints(workerConstraints) - .build() - workManager.enqueueUniqueWork( - DataUploadWorker.WORKER_TAG, - ExistingWorkPolicy.KEEP, - dataWorker - ) - onCompletion(true) + Scope.launch { + if (workManager != null) { + onCompletion(tryWorkManagerThenFallback()) + } else { + Napier.w { "WorkManager not available, falling back to 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/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 index 1b7bd5bcc..f622a72ce 100644 --- 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 @@ -24,7 +24,7 @@ abstract class ObservationDataManager(private val repository: MainRepository) { private var countJob: Job? = null private var scheduleCount = mutableMapOf() - private val konnection = Konnection.instance + protected val konnection = Konnection.instance init { Napier.i(tag = "ObservationDataManager::init") { "ObservationDataManager init!" } @@ -63,7 +63,7 @@ abstract class ObservationDataManager(private val repository: MainRepository) { if (countJob == null) { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Starting to listen for changes in datapoint counts" } countJob = Scope.repeatedLaunch(60000, Dispatchers.IO) { - if (konnection.isConnected()) { + if (isConnected()) { val count = repository.observationData.getCount() if (count > 0) { Napier.d(tag = "ObservationDataManager::listenToDatapointCountChanges") { "Observation data count: $count! Sending data..." } @@ -85,9 +85,11 @@ abstract class ObservationDataManager(private val repository: MainRepository) { countJob = null } - private fun deleteAll(idSet: Set) { - Scope.launch(Dispatchers.IO) { - repository.observationData.deleteAllWithId(idSet) - } + protected fun isConnected() = konnection.isConnected() + + protected suspend fun dataBulk() = repository.observationData.allAsBulk() + + protected suspend fun deleteAll(idSet: Set) { + repository.observationData.deleteAllWithId(idSet) } } From fa7a64bec097d696f94594bfb2d7e59066aaa056 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Mon, 6 Oct 2025 11:03:32 +0200 Subject: [PATCH 41/59] Version update 4.1.6 --- androidApp/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index ce4052a3f..f41e9570a 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -49,8 +49,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 33 - versionName = "4.1.5" + versionCode = 34 + versionName = "4.1.6" } buildFeatures { compose = true From bdfe5c8ca13b727ab50aef843819a600070590d7 Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:57:55 +0200 Subject: [PATCH 42/59] #336: (#352) - Fixed a bug which led the Polar observation to start automatically, even if it is supposed to start by hand. - Fixed an issue where all observation with the same observation type would pause, if two observation with the same type would be started in series - Fixed an issue where an observation would still record data after pausing or stopping, if another observation with the same type would still be running - Fixed an issue where the Polar HR feature "ready" state would be deleted, after exiting a study, even if the device is still connected and ready - Upgraded a package on Android, that was not supporting the upcoming 16-KB page size requirement --- androidApp/build.gradle.kts | 2 +- .../HR/PolarHeartRateObservation.kt | 35 +++++++++---------- .../services/bluetooth/PolarConnector.kt | 1 - .../PolarVerityHeartRateObservation.swift | 2 +- .../Services/Bluetooth/PolarConnector.swift | 1 - .../more/more_app_mutliplatform/Shared.kt | 1 + .../database/entities/ScheduleEntity.kt | 10 ++---- .../database/repository/ScheduleRepository.kt | 29 ++++++++------- .../observations/Observation.kt | 6 ++-- .../bluetooth/BluetoothStateManagement.kt | 6 ++-- .../BluetoothController.kt | 21 +++++++---- 11 files changed, 61 insertions(+), 53 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f41e9570a..64e5d1ec8 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -188,7 +188,7 @@ dependencies { implementation("com.github.acsbendi:Android-Request-Inspector-WebView:1.0.3") implementation("androidx.lifecycle:lifecycle-process:2.9.3") //Google ML Kit for QR Scanning - implementation("com.google.mlkit:barcode-scanning:17.2.0") + 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") 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 219526e18..c00f7dc77 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 @@ -75,9 +75,7 @@ class PolarHeartRateObservation(repos: MainRepository) : polarController.hrFeatureChange.collect { (studyActive, hrReady) -> if (studyActive) { if (hrReady) { - MoreApplication.shared!!.observationManager.startObservationType( - super.observationType.observationType - ) + MoreApplication.shared!!.observationManager.updateTaskStates() } else { Observation.pauseObservation( super.observationType @@ -97,21 +95,22 @@ class PolarHeartRateObservation(repos: MainRepository) : 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) { 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 eb95f799b..4c58c0b00 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 @@ -177,7 +177,6 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe } override fun resetAll() { - PolarStates.resetAll() polarApi.cleanup() } diff --git a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift index eb6e5f716..a1663aaad 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -53,7 +53,7 @@ class PolarVerityHeartRateObservation: Observation_ { print("HR Ready: \(hrReady)") Task { do { - try await AppDelegate.shared.observationManager.startObservationType(type: self.observationType.observationType) + try await AppDelegate.shared.observationManager.updateTaskStates() } catch { print("Cannot start polar observation: \(error)") } diff --git a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift index 6503e532f..27b8c14f5 100644 --- a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift @@ -201,7 +201,6 @@ class PolarConnector: NSObject, BluetoothConnector { } func resetAll() { - PolarStates.shared.resetAll() polarApi.cleanup() } } 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 index c697b83b9..45c21172b 100644 --- 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 @@ -104,6 +104,7 @@ class Shared( notificationManager.createNewFCMIfNecessary() notificationManager.clearAllNotifications() notificationManager.downloadMissedNotifications() + dataRecorder.restartAll() } else { ViewManager.showBLEView(false) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt index e3b164e9b..2c437dcec 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt @@ -26,7 +26,7 @@ data class ScheduleEntity( fun endInstant() = end?.let { Instant.fromEpochSeconds(it) } fun updateState(specificState: ScheduleState? = null): ScheduleState { - val newState = if (specificState != null) { + return if (specificState != null) { specificState } else { val now = Clock.System.now().epochSeconds @@ -42,18 +42,12 @@ data class ScheduleEntity( } now < startTime -> ScheduleState.DEACTIVATED - startTime <= now && !getState().running() -> ScheduleState.ACTIVE + startTime <= now && !getState().active() -> ScheduleState.ACTIVE else -> ScheduleState.getState(state) } } } ?: ScheduleState.getState(state) } - - return newState - } - - fun equalsSchedule(other: ScheduleEntity): Boolean { - return scheduleId == other.scheduleId } override fun toString(): String { 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 index c89a33c94..c4fbdf4d1 100644 --- 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 @@ -19,7 +19,6 @@ 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.BluetoothStateManagement import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull @@ -170,6 +169,7 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { val stateUpdates = mutableListOf>() val activeIds = mutableSetOf() + val pausingIds = mutableSetOf() schedules.forEach { scheduleEntity -> val newState = scheduleEntity.updateState() @@ -180,18 +180,18 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { } if (newState == ScheduleState.RUNNING - || (autoStartingObservations.isNotEmpty() - && scheduleEntity.hidden - && newState.active() - && scheduleEntity.observationType in autoStartingObservations - && observationFactory.observation(scheduleEntity.observationType) - ?.bleDevicesNeeded() - ?.let { needed -> - BluetoothStateManagement.connectedDevices.value.map { it.deviceName } - .containsAll(needed) - } != false) + || scheduleEntity.hidden + && newState.active() + && scheduleEntity.observationType in autoStartingObservations ) { - activeIds.add(scheduleEntity.scheduleId) + observationFactory.observation(scheduleEntity.observationType) + ?.let { observation -> + if (observation.observerAccessible()) { + activeIds.add(scheduleEntity.scheduleId) + } else { + pausingIds.add(scheduleEntity.scheduleId) + } + } } } @@ -202,6 +202,11 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { 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/more_app_mutliplatform/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt index 3305025db..1551c94a3 100644 --- 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 @@ -64,7 +64,8 @@ abstract class Observation( notificationIds[scheduleId] = notificationId } if (running && configChanged) { - stopAndFinish(scheduleId) + stop {} + running = false } configChanged = false return if (!running) { @@ -81,11 +82,11 @@ abstract class Observation( stop { timestampCollectionJob?.cancel() saveAndSend() - observationShutdown(scheduleId) } } else { saveAndSend() } + observationShutdown(scheduleId) if (removeNotification) { handleNotification(scheduleId) } @@ -200,6 +201,7 @@ abstract class Observation( } } + // 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." } stop { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt index 28578c23c..b40c5bead 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt @@ -67,7 +67,7 @@ object BluetoothStateManagement { fun uiOverrides(override: Boolean) { _uiOverride.value = override - Napier.i { "BLE UI Override" } + Napier.i { "BLE UI overrides: $override" } } fun enableBgScanning(): Boolean { @@ -80,8 +80,10 @@ object BluetoothStateManagement { } fun disableBgScanning() { + if (bgScanningActive.value) { + Napier.i { "BLE Background Scan disabled" } + } _bgScanningActive.value = false - Napier.i { "BLE Background Scan disabled" } } fun addConnectedDevices(devices: Set) { 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 index f3588b916..b10d49cfb 100644 --- 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 @@ -49,7 +49,7 @@ class BluetoothController( init { bluetoothConnector.addObserver(this) - Scope.launch(Dispatchers.IO) { + Scope.launch { bluetoothDeviceRepository.pairedDevices().distinctUntilChanged().cancellable().collect { bleManager.addPairedDeviceIds(it.toSet()) } @@ -80,8 +80,8 @@ class BluetoothController( } } - Scope.launch(supervisor + Dispatchers.IO) { - bleManager.connectedDevices.collect { + Scope.launch { + bleManager.connectedDevices.collectLatest { observationFactory.updateObservationErrors() } } @@ -124,6 +124,14 @@ class BluetoothController( 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) @@ -196,8 +204,8 @@ class BluetoothController( override fun resetAll() { Napier.i(tag = "BluetoothController::resetAll") { "Resetting Bluetooth data!" } disableBackgroundScanner() - bleManager.uiOverrides(false) stopPeriodicScan() + bleManager.uiOverrides(false) bleManager.clearDiscovered() bluetoothConnector.resetAll() } @@ -209,9 +217,8 @@ class BluetoothController( companion object { // Energy-optimized scanning intervals - private const val BACKGROUND_SCAN_DURATION = 1000L // Reduced from 2s to 1s - private const val BACKGROUND_SCAN_INTERVAL = - 30000L // Increased from 10s to 30s for better battery life + 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 } } From 81b497f8446636b3da9c990a4ddcef0f17a08199 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 7 Oct 2025 10:59:10 +0200 Subject: [PATCH 43/59] V4.1.7 --- androidApp/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 64e5d1ec8..6b1e6e3bc 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -49,8 +49,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 34 - versionName = "4.1.6" + versionCode = 35 + versionName = "4.1.7" } buildFeatures { compose = true From 7bb59f2f04bc8722323c43781fc48f8f567916f1 Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:52:56 +0100 Subject: [PATCH 44/59] #356: Implemented a Garmin Connect OAuth Workflow in iOS and Android (#357) * #356: Implemented a Garmin Connect OAuth Workflow in iOS and Android #312: Removed the old EncryptedSharedPreferences.kt * #356: Cleanup * #356: Polar SDK Update to fix build issue in pipeline * #356: Added a clear cache function in swift --- androidApp/build.gradle.kts | 2 +- androidApp/src/main/AndroidManifest.xml | 6 + .../android/activities/NavigationScreen.kt | 7 + .../android/activities/main/MainActivity.kt | 40 ++++ .../android/activities/main/MainViewModel.kt | 11 + .../garmin/GarminConnectActivity.kt | 193 ++++++++++++++++++ .../garmin/GarminConnectViewModel.kt | 107 ++++++++++ .../limeSurvey/LimeSurveyActivity.kt | 5 +- .../limeSurvey/LimeSurveyViewModel.kt | 1 + .../WebClient.kt} | 27 +-- .../activities/web/WebClientListener.kt | 10 + .../src/main/res/values-de/error-strings.xml | 1 + .../src/main/res/values/error-strings.xml | 1 + .../main/res/values/navigation-strings.xml | 1 + iosApp/iosApp.xcodeproj/project.pbxproj | 16 ++ .../iosApp/Extensions/NavigationScreen.swift | 3 + .../Resources/Strings/Localizable.xcstrings | 3 + .../Views/Components/WebView/WebView.swift | 10 +- .../Components/WebView/WebViewViewModel.swift | 7 +- .../GarminConnect/GarminConnectView.swift | 48 +++++ .../GarminConnectViewModel.swift | 156 ++++++++++++++ .../Views/LimeSurvey/LimeSurveyView.swift | 5 +- .../LimeSurvey/LimeSurveyViewModel.swift | 7 +- iosApp/iosApp/Views/MainTabView.swift | 10 + .../iosApp/Views/NavigationModalState.swift | 28 ++- .../store/EncryptedSharedPreferences.kt | 111 ---------- .../store/PrivateSharedPreferences.kt | 85 +------- .../more/more_app_mutliplatform/Shared.kt | 27 ++- .../extensions/StringExtension.kt | 3 + .../models/CredentialModel.kt | 10 +- .../observations/ObservationFactory.kt | 2 + .../observations/garmin/GarminObservation.kt | 20 ++ .../observationTypes/GarminType.kt | 3 + .../services/network/NetworkService.kt | 63 +++++- .../viewModels/ViewManager.kt | 97 ++++++++- .../CoreGarminConnectViewModel.kt | 82 ++++++++ 36 files changed, 954 insertions(+), 254 deletions(-) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt rename androidApp/src/main/java/io/redlink/more/app/android/activities/{observations/limeSurvey/LimeSurveyWebClient.kt => web/WebClient.kt} (66%) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt create mode 100644 iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift create mode 100644 iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift delete mode 100644 shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EncryptedSharedPreferences.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/garmin/GarminObservation.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GarminType.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 6b1e6e3bc..e456923e6 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -152,7 +152,7 @@ android { val composeVersion = "1.6.0" val workVersion = "2.10.3" val navVersion = "2.9.3" -val polarSDKVersion = "6.6.0" +val polarSDKVersion = "6.7.0" val ktorVersion = "3.2.3" val roomVersion = "2.7.2" val koinVersion = "4.1.1" diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index be5cc5b6d..fd4d15499 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -42,6 +42,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_more_logo_blue_round" android:supportsRtl="true" + android:usesCleartextTraffic="false" android:theme="@style/Theme.MorePlatform"> + ? = null 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 4b47ad1ad..52b7ebb59 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,7 +26,10 @@ 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 @@ -62,6 +65,10 @@ import io.redlink.more.app.android.util.ActivityProvider 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { private lateinit var navHostController: NavHostController @@ -133,6 +140,22 @@ class MainActivity : ComponentActivity() { ) } } + + 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()) + } + } + } + } + } } } @@ -418,6 +441,23 @@ fun MainView( } } + 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(), 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 339018203..c3b7c01ac 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 @@ -21,6 +21,7 @@ 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.schedule.ScheduleViewModel +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.studyDetails.observationDetails.ObservationDetailsViewModel @@ -102,6 +103,16 @@ class MainViewModel(context: Context) : ViewModel() { } } + fun openGarminActivity( + context: Context, + activityResultLauncher: ActivityResultLauncher + ) { + (context as? Activity)?.let { activity -> + val intent = Intent(activity, GarminConnectActivity::class.java) + activityResultLauncher.launch(intent) + } + } + fun creteNewSimpleQuestionViewModel( scheduleId: String? = null, observationId: String? = null, 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..44f249fb7 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectActivity.kt @@ -0,0 +1,193 @@ +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.ui.theme.MoreColors +import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel +import io.redlink.more.more_app_mutliplatform.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( + 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..47fb6da29 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/garmin/GarminConnectViewModel.kt @@ -0,0 +1,107 @@ +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.more_app_mutliplatform.AlertController +import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel +import io.redlink.more.more_app_mutliplatform.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( + 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 eec98fe81..632c81387 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 @@ -42,6 +42,7 @@ 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 @@ -51,7 +52,7 @@ import io.redlink.more.app.android.ui.theme.MoreColors class LimeSurveyActivity : ComponentActivity() { val viewModel: LimeSurveyViewModel = LimeSurveyViewModel() var webView: WebView? = null - var webClientListener: LimeSurveyWebClient? = null + var webClientListener: WebClient? = null @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { @@ -69,7 +70,7 @@ class LimeSurveyActivity : ComponentActivity() { webView = WebView(this) webView?.let { webView -> - webClientListener = LimeSurveyWebClient() + webClientListener = WebClient() webClientListener?.let { webClientListener?.setListener(viewModel) webView.apply { 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 4a0b1d005..8462a0562 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,6 +16,7 @@ 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.web.WebClientListener 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 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 66% 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..a3a4e47b0 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 @@ -1,14 +1,4 @@ -/* - * 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.limeSurvey +package io.redlink.more.app.android.activities.web import android.graphics.Bitmap import android.webkit.WebResourceRequest @@ -16,15 +6,7 @@ import android.webkit.WebView import android.webkit.WebViewClient import io.github.aakira.napier.log - -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) { @@ -52,7 +34,6 @@ class LimeSurveyWebClient : WebViewClient(){ log { "WebViewClient\$onPageCommitVisible: $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 +41,4 @@ class LimeSurveyWebClient : WebViewClient(){ } return super.shouldOverrideUrlLoading(view, request) } -} - - +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt new file mode 100644 index 000000000..4d7027788 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt @@ -0,0 +1,10 @@ +package io.redlink.more.app.android.activities.web + +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/res/values-de/error-strings.xml b/androidApp/src/main/res/values-de/error-strings.xml index 60961f0a2..cb280befb 100644 --- a/androidApp/src/main/res/values-de/error-strings.xml +++ b/androidApp/src/main/res/values-de/error-strings.xml @@ -13,4 +13,5 @@ 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! \ 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..5b258725a 100644 --- a/androidApp/src/main/res/values/error-strings.xml +++ b/androidApp/src/main/res/values/error-strings.xml @@ -13,4 +13,5 @@ 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 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..161c1fdc3 100644 --- a/androidApp/src/main/res/values/navigation-strings.xml +++ b/androidApp/src/main/res/values/navigation-strings.xml @@ -16,4 +16,5 @@ Confirm to leave the study Limesurvey Observation Errors + Garmin Connect \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 017426df6..841ed17ac 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -57,6 +57,8 @@ 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 */; }; 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 */; }; @@ -262,6 +264,8 @@ 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 = ""; }; 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 = ""; }; @@ -520,6 +524,15 @@ path = Info; sourceTree = ""; }; + 1F43DE1F2EC6136700B6F07B /* GarminConnect */ = { + isa = PBXGroup; + children = ( + 1F43DE202EC6137300B6F07B /* GarminConnectView.swift */, + 1F43DE222EC6137F00B6F07B /* GarminConnectViewModel.swift */, + ); + path = GarminConnect; + sourceTree = ""; + }; 1F6A4E3A29F6C94B00F0247F /* Bluetooth */ = { isa = PBXGroup; children = ( @@ -662,6 +675,7 @@ 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( + 1F43DE1F2EC6136700B6F07B /* GarminConnect */, 1F1E45CF2E7842E300C82016 /* Registration */, 1F8937892BFF1EB20083D20E /* ObservationErrors */, 1F2C4BDB2A6F974900C29888 /* StudyStates */, @@ -1160,6 +1174,7 @@ 1F69081329D44D900079C219 /* DateExtension.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 */, @@ -1231,6 +1246,7 @@ 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 */, diff --git a/iosApp/iosApp/Extensions/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index 4ffe94f7c..25816bd0e 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -50,6 +50,7 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { case withdrawStudyConfirm case limeSurvey case observationErrors + case garminConnect var values: NavigationScreenValues { switch self { @@ -91,6 +92,8 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { return NavigationScreenValues(screenName: "LimeSurvey", navigationLink: "/lime-survey-observation", parameters: [.observationId, .notificaitonId, .scheduleId], fullScreen: true) case .observationErrors: return NavigationScreenValues(screenName: "Observation Errors", navigationLink: "/observation-errors") + case .garminConnect: + return NavigationScreenValues(screenName: "Garmin Connect", navigationLink: "/garmin-connect", parameters: [], fullScreen: true) } } diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index c84a227c1..ff4b9d843 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -422,6 +422,9 @@ } } } + }, + "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.", diff --git a/iosApp/iosApp/Views/Components/WebView/WebView.swift b/iosApp/iosApp/Views/Components/WebView/WebView.swift index 33dbd533f..d01ec4ab1 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebView.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebView.swift @@ -17,7 +17,7 @@ import SwiftUI import WebKit struct WebView: View { - @State var url: URL? + @State var url: URLRequest? @StateObject var viewModel: WebViewViewModel var body: some View { @@ -43,10 +43,10 @@ struct WebView: View { 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 } @@ -58,7 +58,7 @@ struct SwiftUIWebView: UIViewRepresentable { func updateUIView(_ uiView: WKWebView, context: Context) { print("WebView URL: \(String(describing: url))") if let url { - viewModel.webView.load(URLRequest(url: url)) + viewModel.webView.load(url) } else { viewModel.webView.load(URLRequest(url: URL(string: "about:blank")!)) } @@ -67,6 +67,6 @@ struct SwiftUIWebView: UIViewRepresentable { 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 d3250d7e1..0b7f92c1f 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift @@ -17,7 +17,7 @@ import Foundation import WebKit protocol WebViewListener { - func onRedirect(navigationAction: WKNavigationAction) -> WKNavigationActionPolicy + func onRedirect(navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy } class WebViewViewModel: NSObject, ObservableObject { @@ -73,6 +73,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/GarminConnect/GarminConnectView.swift b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift new file mode 100644 index 000000000..807128759 --- /dev/null +++ b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift @@ -0,0 +1,48 @@ +// +// 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.clearAllWebViewData() + } + .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..f9221fbd5 --- /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(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/LimeSurvey/LimeSurveyView.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift index 337f963cd..6ad5c0ef2 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift @@ -18,7 +18,6 @@ import SwiftUI struct LimeSurveyView: View { @StateObject var viewModel: LimeSurveyViewModel - private let stringsTable = "LimeSurvey" var body: some View { MoreMainBackgroundView(contentPadding: 0) { VStack { @@ -26,8 +25,8 @@ struct LimeSurveyView: View { 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) } } diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift index 8abda00e4..cedbc7f06 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift @@ -94,12 +94,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() } @@ -108,3 +108,4 @@ extension LimeSurveyViewModel: WebViewListener { return .allow } } + diff --git a/iosApp/iosApp/Views/MainTabView.swift b/iosApp/iosApp/Views/MainTabView.swift index 673ba5fe5..ce735ac8e 100644 --- a/iosApp/iosApp/Views/MainTabView.swift +++ b/iosApp/iosApp/Views/MainTabView.swift @@ -99,6 +99,15 @@ struct MainTabView: View { .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .withdrawStudy)) { LeaveStudyView() } + .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .garminConnect)) { + Navigation { + GarminConnectView() + .navigationBarTitleDisplayMode(.inline) + } + .onDisappear { + navigationModalState.removeNavigationAction() + } + } } } @@ -107,3 +116,4 @@ struct MainTabView_Previews: PreviewProvider { MainTabView() } } + diff --git a/iosApp/iosApp/Views/NavigationModalState.swift b/iosApp/iosApp/Views/NavigationModalState.swift index 717d45eca..8550e3b6a 100644 --- a/iosApp/iosApp/Views/NavigationModalState.swift +++ b/iosApp/iosApp/Views/NavigationModalState.swift @@ -75,6 +75,30 @@ class NavigationModalState: ObservableObject { 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 + if show { + self?.openView(screen: .garminConnect) + } else { + self?.closeView(screen: .garminConnect) + } + } + .store(in: &cancellables) } func screenBinding(for screen: NavigationScreen) -> Binding { @@ -201,7 +225,9 @@ class NavigationModalState: ObservableObject { } func mayChangeViewStructure() -> Bool { - !studyIsUpdating && !studyLoadingError && 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) { 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 36347bbed..000000000 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/EncryptedSharedPreferences.kt +++ /dev/null @@ -1,111 +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 -import io.github.aakira.napier.Napier -import java.io.File -import java.security.GeneralSecurityException -import javax.crypto.AEADBadTagException - -private const val ENCRYPT_SHARED_PREF_FILENAME = "credentials_file" -private const val TAG = "EncryptedSharedPrefs" - -// TODO: Remove encrypted shared preferences after all active devices updated to this version -class EncryptedSharedPreferences { - companion object { - fun create(context: Context): SharedPreferences { - return try { - createEncryptedSharedPreferences(context) - } catch (exception: AEADBadTagException) { - Napier.w( - exception, - tag = TAG - ) { "Failed to create EncryptedSharedPreferences, attempting recovery" } - handleEncryptionFailure(context, exception) - } - } - - private fun createEncryptedSharedPreferences(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 - ) - } - - private fun handleEncryptionFailure( - context: Context, - exception: Exception - ): SharedPreferences = when (exception) { - is AEADBadTagException, is GeneralSecurityException -> { - Napier.w(tag = TAG) { "Encryption/decryption error detected, clearing corrupted data" } - clearCorruptedData(context) - - try { - createEncryptedSharedPreferences(context) - } catch (retryException: Exception) { - Napier.e( - retryException, - tag = TAG - ) { "Failed to create EncryptedSharedPreferences after recovery, falling back to regular SharedPreferences" } - createFallbackSharedPreferences(context) - } - } - - else -> { - Napier.e( - exception, - tag = TAG - ) { "Unexpected error creating EncryptedSharedPreferences, falling back to regular SharedPreferences" } - createFallbackSharedPreferences(context) - } - } - - private fun clearCorruptedData(context: Context) { - try { - val sharedPrefsFile = - context.getSharedPreferences(ENCRYPT_SHARED_PREF_FILENAME, Context.MODE_PRIVATE) - sharedPrefsFile.edit().clear().apply() - - val prefsDir = context.filesDir.parent?.let { "$it/shared_prefs/" } - ?: throw IllegalStateException("Failed to determine shared_prefs directory") - val prefsFile = File(prefsDir, "$ENCRYPT_SHARED_PREF_FILENAME.xml") - if (prefsFile.exists()) { - prefsFile.delete() - } - - Napier.i(tag = TAG) { "Successfully cleared corrupted encrypted preferences data" } - } catch (clearException: Exception) { - Napier.e(clearException, tag = TAG) { "Failed to clear corrupted data" } - } - } - - private fun createFallbackSharedPreferences(context: Context): SharedPreferences { - Napier.w(tag = TAG) { "Using fallback unencrypted SharedPreferences - consider implementing additional security measures" } - return context.getSharedPreferences( - "${ENCRYPT_SHARED_PREF_FILENAME}_fallback", - Context.MODE_PRIVATE - ) - } - } -} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt index 1c1725c4d..054eed9cb 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt @@ -2,25 +2,13 @@ package io.redlink.more.more_app_mutliplatform.services.store import android.content.Context import android.content.SharedPreferences -import io.github.aakira.napier.Napier private const val PRIVATE_SHARED_PREF_FILENAME = "more_app_preferences" -private const val ENCRYPT_SHARED_PREF_FILENAME = "credentials_file" -private const val ENCRYPT_SHARED_PREF_FALLBACK_FILENAME = "${ENCRYPT_SHARED_PREF_FILENAME}_fallback" -private const val TAG = "PrivateSharedPrefs" -// This is a replacement key value store for the encrypted shared preferences, as those would crash the app if the key was not right anymore, like after an app reinstall, phone restore or phone transfer where the data were backed up, but the key did not. -// TODO: Remove migration script after all active devices updated to this version class PrivateSharedPreferences { companion object { fun create(context: Context): SharedPreferences { - val privatePrefs = createPrivateSharedPreferences(context) - - if (shouldMigrateFromEncrypted(context)) { - migrateFromEncryptedPreferences(context, privatePrefs) - } - - return privatePrefs + return createPrivateSharedPreferences(context) } private fun createPrivateSharedPreferences(context: Context): SharedPreferences { @@ -29,76 +17,5 @@ class PrivateSharedPreferences { Context.MODE_PRIVATE ) } - - private fun shouldMigrateFromEncrypted(context: Context): Boolean { - val encryptedPrefsFile = - context.getSharedPreferences(ENCRYPT_SHARED_PREF_FILENAME, Context.MODE_PRIVATE) - val fallbackPrefsFile = context.getSharedPreferences( - ENCRYPT_SHARED_PREF_FALLBACK_FILENAME, - Context.MODE_PRIVATE - ) - - return encryptedPrefsFile.all.isNotEmpty() || fallbackPrefsFile.all.isNotEmpty() - } - - private fun migrateFromEncryptedPreferences( - context: Context, - privatePrefs: SharedPreferences - ) { - try { - Napier.i(tag = TAG) { "Starting migration from encrypted preferences" } - - val encryptedPrefs = try { - EncryptedSharedPreferences.create(context) - } catch (e: Exception) { - Napier.w( - e, - tag = TAG - ) { "Failed to access encrypted preferences, trying fallback" } - context.getSharedPreferences( - ENCRYPT_SHARED_PREF_FALLBACK_FILENAME, - Context.MODE_PRIVATE - ) - } - - val editor = privatePrefs.edit() - - for ((key, value) in encryptedPrefs.all) { - when (value) { - is String -> editor.putString(key, value) - is Boolean -> editor.putBoolean(key, value) - is Int -> editor.putInt(key, value) - is Float -> editor.putFloat(key, value) - is Long -> editor.putLong(key, value) - else -> Napier.w(tag = TAG) { "Unknown type for key: $key, value: $value" } - } - } - - editor.apply() - - Napier.i(tag = TAG) { "Migration completed successfully" } - - clearOldPreferences(context) - } catch (e: Exception) { - Napier.e(e, tag = TAG) { "Error during migration from encrypted preferences" } - } - } - - private fun clearOldPreferences(context: Context) { - try { - context.getSharedPreferences(ENCRYPT_SHARED_PREF_FILENAME, Context.MODE_PRIVATE) - .edit().clear().apply() - - context.getSharedPreferences( - ENCRYPT_SHARED_PREF_FALLBACK_FILENAME, - Context.MODE_PRIVATE - ) - .edit().clear().apply() - - Napier.i(tag = TAG) { "Old preferences cleared successfully" } - } catch (e: Exception) { - Napier.w(e, tag = TAG) { "Failed to clear old preferences" } - } - } } } \ 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 index 45c21172b..585fa222a 100644 --- 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 @@ -21,6 +21,7 @@ import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManage 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.observations.ObservationStates +import io.redlink.more.more_app_mutliplatform.observations.observationTypes.GarminType import io.redlink.more.more_app_mutliplatform.scopes.Scope import io.redlink.more.more_app_mutliplatform.scopes.StudyScope import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnector @@ -32,8 +33,7 @@ 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.viewModels.ViewManager import io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection.BluetoothController -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import io.redlink.more.more_app_mutliplatform.viewModels.garminConnectOAuth.CoreGarminConnectViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -43,7 +43,7 @@ import kotlinx.coroutines.sync.withLock class Shared( localNotificationListener: LocalNotificationListener, val repositories: MainRepository, - sharedStorageRepository: SharedStorageRepository, + val sharedStorageRepository: SharedStorageRepository, val observationDataManager: ObservationDataManager, mainBluetoothConnector: BluetoothConnector, val observationFactory: ObservationFactory, @@ -105,6 +105,7 @@ class Shared( notificationManager.clearAllNotifications() notificationManager.downloadMissedNotifications() dataRecorder.restartAll() + garminLogin() } else { ViewManager.showBLEView(false) } @@ -117,6 +118,7 @@ class Shared( observationManager.updateTaskStates() observationFactory.updateObservationErrors() } + garminLogin() } else { stopObservations() ViewManager.showBLEView(false) @@ -234,12 +236,28 @@ class Shared( suspend fun newLogin() { notificationManager.newFCMToken() observationFactory.updateObservationErrors() + garminLogin() + } + + private fun garminLogin() { + Scope.launch { + Napier.d(tag = "Shared::garminLogin") { "Checking Garmin login" } + if (observationFactory.observationTypes() + .contains(GarminType().observationType) + && !sharedStorageRepository.load( + CoreGarminConnectViewModel.GARMIN_CONNECT_SUCCESSFUL_LOGIN, + false + ) + ) { + ViewManager.requestGarminConnectView(true) + } + } } fun exitStudy(onDeletion: () -> Unit) { StudyScope.cancel() bluetoothController.resetAll() - Scope.launch(Dispatchers.IO) { + Scope.launch { networkService.deleteParticipation() notificationManager.clearAllNotifications() notificationManager.deleteFCMToken() @@ -259,6 +277,7 @@ class Shared( private fun clearSharedStorage() { credentialRepository.remove() + sharedStorageRepository.remove(CoreGarminConnectViewModel.GARMIN_CONNECT_SUCCESSFUL_LOGIN) } suspend fun removeStudyData() { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/StringExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/StringExtension.kt index 5dcf015fb..9f629b1ea 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/StringExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/StringExtension.kt @@ -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/more_app_mutliplatform/models/CredentialModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/CredentialModel.kt index 6ed478439..02ceab24e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/CredentialModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/CredentialModel.kt @@ -10,4 +10,12 @@ */ package io.redlink.more.more_app_mutliplatform.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/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt index 74c6c0508..9c8bfb567 100644 --- 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 @@ -12,6 +12,7 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository +import io.redlink.more.more_app_mutliplatform.observations.garmin.GarminObservation 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.scopes.Scope @@ -35,6 +36,7 @@ abstract class ObservationFactory( init { observations.add(SimpleQuestionObservation(repository)) observations.add(LimeSurveyObservation(repository)) + observations.add(GarminObservation(repository)) Scope.launch(Dispatchers.IO) { repository.observation.observationTypes().collect { Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/garmin/GarminObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/garmin/GarminObservation.kt new file mode 100644 index 000000000..df3ab6f88 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/garmin/GarminObservation.kt @@ -0,0 +1,20 @@ +package io.redlink.more.more_app_mutliplatform.observations.garmin + +import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository +import io.redlink.more.more_app_mutliplatform.observations.Observation +import io.redlink.more.more_app_mutliplatform.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/observationTypes/GarminType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GarminType.kt new file mode 100644 index 000000000..00d734477 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GarminType.kt @@ -0,0 +1,3 @@ +package io.redlink.more.more_app_mutliplatform.observations.observationTypes + +class GarminType : ObservationType("garmin-observation", emptySet()) \ 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 index 6fe1dd4f5..442a749aa 100644 --- 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 @@ -26,7 +26,9 @@ import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType +import io.ktor.http.Url import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json @@ -52,8 +54,6 @@ class NetworkService( ) : Closeable { private var httpClient: HttpClient? = null - private var engineUseCounter = 10 - private fun initHttpClientWithAuth(): HttpClient? { initHttpClient() val creds = credentialRepository.credentials.value @@ -86,6 +86,8 @@ class NetworkService( } } + fun baseUrl(): String = endpointRepository.endpoint() + suspend fun deleteParticipation(): Pair { try { credentialRepository.credentials.value?.let { credentials -> @@ -290,9 +292,63 @@ class NetworkService( } } + fun getBasicAuthHeader(): String? { + return credentialRepository.credentials.value?.basicAuthHeader() + } + + fun getGarminSSOUrl(): Url? { + try { + credentialRepository.credentials.value?.let { + val baseUrl = endpointRepository.endpoint() + return Url("$baseUrl/registration/garmin") + } + } catch (e: Exception) { + Napier.e(tag = "NetworkService::getGarminSSOUrl") { "Error getting Garmin SSO Url: $e" } + } + return null + } + + suspend fun garminSSOCallback(code: String, status: String): Boolean { + return garminSSOCallbackUrl()?.let { callbackUrl -> + try { + credentialRepository.credentials.value?.let { + val client = initHttpClientWithAuth() ?: return@let false + val result = client.get(callbackUrl) { + url { + parameters.append("code", code) + parameters.append("state", status) + } + } + Napier.d(tag = "NetworkService::garminSSOCallback") { + "Received callback response: ${result.status}" + } + if (!result.status.isSuccess()) { + try { + val bodyText = result.bodyAsText() + Napier.e(tag = "NetworkService::garminSSOCallback") { + "Garmin SSO callback failed with status ${result.status.value}, body: $bodyText" + } + } catch (_: Exception) { + } + } + result.status.isSuccess() + } ?: false + } catch (e: Exception) { + Napier.e(tag = "NetworkService::garminSSOCallback") { "Network error: ${e.message}" } + false + } + } ?: false + } + + fun garminSSOCallbackUrl(): Url? { + return getGarminSSOUrl()?.let { + Url("$it/callback") + } + } + suspend fun deletePushNotification(msgId: String) { try { - credentialRepository.credentials.value?.let { credentials -> + credentialRepository.credentials.value?.let { _ -> val client = initHttpClientWithAuth() ?: return val baseUrl = endpointRepository.endpoint() @@ -340,7 +396,6 @@ class NetworkService( override fun close() { Napier.d(tag = "NetworkService::close") { "Clearing the Http engine..." } - engineUseCounter = 10 httpClient?.close() httpClient = null } 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 index a5fedcc51..4d3e15990 100644 --- 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 @@ -1,16 +1,26 @@ package io.redlink.more.more_app_mutliplatform.viewModels import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier 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 _studyLoadingError = MutableStateFlow(false) - private val _appInForeground = MutableStateFlow(false) + private val _bleViewOpen = MutableStateFlow(false) + private val _activeStudy = MutableStateFlow(false) @NativeCoroutines val studyLoadingError: StateFlow = _studyLoadingError @@ -24,30 +34,76 @@ object ViewManager { @NativeCoroutines val appInForeground: StateFlow = _appInForeground - private val _bleViewOpen = MutableStateFlow(false) + @NativeCoroutines + val showGarminConnectView: StateFlow = _showGarminConnectView + + private fun canOpenNewView(): Boolean { + return _activeStudy.value && + !_studyIsUpdating.value && + !_bleViewOpen.value && + !_showBluetoothView.value && + !_showGarminConnectView.value + } - private val _activeStudy = MutableStateFlow(false) + 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 - _showBluetoothView.value = false + + if (state) { + _showBluetoothView.value = false + _showGarminConnectView.value = false + } else { + tryProcessNextInQueue() + } } fun showBLEView(state: Boolean): Boolean { - if (_activeStudy.value && !bleViewActive.value && !_bleViewOpen.value && !_studyIsUpdating.value) { - _showBluetoothView.value = state - return state + if (!state) { + _showBluetoothView.value = false + return false } - _showBluetoothView.value = 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) { @@ -58,9 +114,32 @@ object ViewManager { _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 resetAll() { _studyIsUpdating.value = false _showBluetoothView.value = false + _showGarminConnectView.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/more_app_mutliplatform/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt new file mode 100644 index 000000000..0c0bd6a79 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt @@ -0,0 +1,82 @@ +package io.redlink.more.more_app_mutliplatform.viewModels.garminConnectOAuth + +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import io.github.aakira.napier.Napier +import io.ktor.http.Url +import io.redlink.more.more_app_mutliplatform.extensions.overlaps +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.viewModels.CoreViewModel +import io.redlink.more.more_app_mutliplatform.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) + } + + 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 From 02773506d0e0af7a2395814f1817f9e02f83d82e Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Fri, 14 Nov 2025 11:02:56 +0100 Subject: [PATCH 45/59] Updated to versio 4.1.7 --- androidApp/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index e456923e6..f4fa483f8 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -49,8 +49,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 35 - versionName = "4.1.7" + versionCode = 36 + versionName = "4.1.8" } buildFeatures { compose = true From bc7b9adba97f18e340f2a3dff6bbca0b9284a9fa Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:22:12 +0100 Subject: [PATCH 46/59] Merge umm to more 25 02 (#367) * Initial commit * #117, #118, #119: New App Store Setup, PlayStore Setup, Firebase Setup, renamed the apps and refactored the package name * #120: Fastlane Adaptions * #120: Fastlane Adaptions * #120: Fastlane Adaptions * #156: Fixing a login bug * #120: Fixing a fastlane deployment issue * #130: Implemented the OpenAPI Generator, implemented all models into the apps, all api clients into the NetworkService.kt and moved the clients into its dedicated NetworkClients.kt class * #130: Pipeline adaption to necessary openapi generator task * #130: Pipeline adaption to necessary openapi generator task * Critical bug fix * Critical bug fix * Critical bug fix * #178:Fixed a bug where Garmin Connect Login would open, even if there is no garmin observation set * Android Version Upgrade * p2r: #360: Fixed these issues: - Past schedules are now correctly removed from the dashboard - ViewModels are now completely reloaded and use the new improved coroutines concept to load data - Small cleanup of unused functions in ScheduleRepository.kt * Android Version update * #201: Fixed an issue, where the answers were listed wrong * #230: Fixed a bug, that kept the app from reloading the study once in paused state * Fixed leaked Google Keys * Fixed a study login issue and added buttons to escape paused study states * #228: Fixeda bug where LimeSurvey would crash the phone or would not close after completing it * #230: Fixed an issue where the data collection process would not run * #230: Github build failure inspection * #230: Github build failure inspection * #230: Github build failure inspection * Fixing a pipeline * #236: Implemented Local Notification Reminder when observation are starting. This schedules and stores notifications in advance, and displays them on the certain time. It also only schedules 10 notifications at a time and reschedules them as soon as the current notification is displayed, the app goes into foreground or once a day using the Background Task. It also checks to cancel all scheduled notifications when necessary. I also implemented multiple bug fixes and improvements: - Improved a workflow issue, that crashed execution of current study updates and tasks running when the app goes into foreground - Fixed an issue that crashed the foreground service on Android - Implemented a shared localization framework, so we can localize text in the shared module * #236: PR fixes * #236: Small addition which should keep the app from scheduling multiple reminders for one schedule * #236: Fixed multiple issues, regarding the cancellation of notifications, setting the right app badge and reduced scheduling complexity. * #184: Implemented a part matching observation type system to accept all kinds of observations that include a given prefix, suffix or includes, starting with the garmin observation * #184: Small code cleanup * #236: Small change * #236: Small change * #264: Added the new observation schema, so the app knows, which schedules it should create a reminder for Also upgraded a few kotlin versions * #264: Changed * #265: Fixed a deeplink issues that hindered the reminder to start observations directly * #265: Small change in handling Deeplinks * #265: Small change in handling Deeplinks * #274: Extended the question observation type so, that different types can be answerd with the same observtion * #274: Enum Name change * #274: Fixed a bug where data would not be sent directly if a study state changes * #217: Implemented a publishing process to extract the shared module * #217: Fixed publishing pipeline and upgraded android jvm target to 17 * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Merged everything back to More and checked to keep all necessary identifier from More * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Pipeline changes * #217: Fixes * #217: Fixes * Small changes * Small changes * #217: PR fixes and implemented, that data are immediately sent when the app is closed, so they don't hang behind * #285: Fixed the deeplink issue * #285: Fixed notification storage issues and improved deeplink handling * #285: Fixed a small update issue * #285: Fixed a small update issue * #217: PR fix * Small cleanup * Small cleanup * Path change * Path change * iOS Google Service API Key removal * #290: Removed the Google API key and implemented it into the pipeline * Readme Update * Small changes * #285: Fixed an issue, where the deeplink would not forward to a task, when the app was completely, due to the schedule update process not being finished * #282: Fixed an issue where the wrong observation type would be set, if one observation type would use another like multiple-choice-question-observation uses question-observation * #291: PR fix * #291: Copyright update --- .github/workflows/build.yml | 4 +- .github/workflows/deploy-beta.yml | 4 +- .gitignore | 13 +- LICENSE.txt | 2 +- README.md | 36 +- androidApp/Gemfile.lock | 67 +-- androidApp/build.gradle.kts | 51 ++- androidApp/fastlane/Fastfile | 128 ++++++ androidApp/fastlane/README.md | 8 + androidApp/fastlane/report.xml | 18 - androidApp/google-services.json | 82 ---- androidApp/src/main/AndroidManifest.xml | 28 +- .../more/app/android/MoreApplication.kt | 40 +- .../app/android/activities/ContentActivity.kt | 14 +- .../android/activities/ContentViewModel.kt | 69 ++- .../android/activities/NavigationScreen.kt | 114 +++-- .../activities/bluetooth/BluetoothActivity.kt | 8 +- .../bluetooth/BluetoothViewModel.kt | 14 +- .../android/activities/consent/ConsentView.kt | 4 +- .../activities/consent/ConsentViewModel.kt | 8 +- .../consent/composables/ConsentButtons.kt | 8 +- .../dashboard/composables/FilterView.kt | 11 +- .../dashboard/filter/DashboardFilterView.kt | 4 +- .../filter/DashboardFilterViewModel.kt | 6 +- .../dashboard/schedule/ScheduleViewModel.kt | 8 +- .../schedule/list/ScheduleListItem.kt | 74 ++-- .../app/android/activities/info/InfoItem.kt | 2 +- .../app/android/activities/info/InfoView.kt | 2 +- .../android/activities/info/InfoViewModel.kt | 2 +- .../leaveStudy/LeaveStudyConfirmView.kt | 6 +- .../activities/leaveStudy/LeaveStudyView.kt | 6 +- .../leaveStudy/LeaveStudyViewModel.kt | 9 +- .../app/android/activities/login/LoginView.kt | 2 +- .../activities/login/LoginViewModel.kt | 13 +- .../login/composables/EndpointView.kt | 2 +- .../login/composables/ErrorMessage.kt | 16 +- .../login/composables/ParticipantKeyInput.kt | 2 +- .../login/composables/QRCodeButton.kt | 5 +- .../login/composables/ValidationButton.kt | 4 +- .../android/activities/main/MainActivity.kt | 46 +- .../android/activities/main/MainViewModel.kt | 46 +- .../activities/main/QRScannerActivity.kt | 21 +- .../activities/main/composables/TabIcon.kt | 2 +- .../notification/NotificationView.kt | 2 +- .../notification/NotificationViewModel.kt | 20 +- .../NotificationFilterViewButton.kt | 2 +- .../composables/NotificationItem.kt | 14 +- .../filter/NotificationFilterView.kt | 4 +- .../filter/NotificationFilterViewModel.kt | 4 +- .../ObservationErrorListView.kt | 15 +- .../observationErrors/ObservationErrorView.kt | 11 + .../ObservationErrorViewModel.kt | 15 +- .../garmin/GarminConnectActivity.kt | 17 +- .../garmin/GarminConnectViewModel.kt | 17 +- .../limeSurvey/LimeSurveyActivity.kt | 31 +- .../limeSurvey/LimeSurveyViewModel.kt | 47 +- .../questionnaire/QuestionViewModel.kt | 62 +++ .../questionnaire/QuestionnaireButtons.kt | 26 +- .../questionnaire/QuestionnaireHeader.kt | 13 +- .../QuestionnaireRadioButtons.kt | 34 +- .../QuestionnaireResponseView.kt | 2 +- .../questionnaire/QuestionnaireView.kt | 75 +++- .../questionnaire/QuestionnaireViewModel.kt | 79 ---- .../questionType/QuestionnaireCheckboxes.kt | 93 ++++ .../QuestionnaireQuestionAnswer.kt | 43 +- .../activities/setting/SettingsView.kt | 2 +- .../activities/setting/SettingsViewModel.kt | 9 +- .../studyDetails/StudyDetailsView.kt | 2 +- .../studyDetails/StudyDetailsViewModel.kt | 2 +- .../composables/AccordionWithList.kt | 4 +- .../composables/ObservationList.kt | 16 +- .../ObservationDetailsView.kt | 7 +- .../ObservationDetailsViewModel.kt | 9 +- .../activities/studyStates/StudyClosedView.kt | 2 +- .../studyStates/StudyLoadingErrorView.kt | 56 +-- .../studyStates/StudyLoadingView.kt | 2 +- .../activities/studyStates/StudyPausedView.kt | 33 +- .../activities/studyStates/StudyUpdateView.kt | 2 +- .../activities/subcomponents/ExitButton.kt | 65 +++ .../activities/subcomponents/ReloadButton.kt | 40 ++ .../taskCompletion/TaskCompletionBarView.kt | 5 +- .../TaskCompletionBarViewModel.kt | 4 +- .../tasks/ObservationActionButton.kt | 67 +++ .../activities/tasks/TaskDetailsView.kt | 46 +- .../activities/tasks/TaskDetailsViewModel.kt | 8 +- .../app/android/activities/web/WebClient.kt | 11 + .../activities/web/WebClientListener.kt | 11 + .../NotificationBroadcastReceiver.kt | 97 ++++- .../android/extensions/ResourceExtension.kt | 14 +- .../extensions/ResourceMappingExtension.kt | 14 +- .../app/android/extensions/StringExtension.kt | 2 +- .../more/app/android/firebase/FCMService.kt | 2 +- .../observations/AndroidDataRecorder.kt | 2 +- .../AndroidObservationDataManager.kt | 11 +- .../observations/AndroidObservationFactory.kt | 8 +- .../observations/GPS/GPSObservation.kt | 8 +- .../android/observations/GPS/GPSService.kt | 20 +- .../HR/PolarHeartRateObservation.kt | 25 +- .../observations/HR/PolarObserverCallback.kt | 2 +- .../observations/ObservationExtension.kt | 21 +- .../ObservationManagerExtension.kt | 17 +- .../ObservationServiceExtension.kt | 14 +- .../android/observations/PermissionUtils.kt | 22 +- .../accelerometer/AccelerometerObservation.kt | 8 +- .../services/LocalPushNotificationService.kt | 206 +++++---- .../services/ObservationRecordingService.kt | 126 +++--- .../services/bluetooth/PolarConnector.kt | 14 +- .../sensorsListener/BluetoothStateListener.kt | 13 +- .../sensorsListener/GPSStateListener.kt | 13 +- .../android/shared_composables/Accordion.kt | 2 +- .../shared_composables/AccordionReadMore.kt | 2 +- .../ActivityProgressView.kt | 2 +- .../android/shared_composables/AppVersion.kt | 2 +- .../android/shared_composables/BasicText.kt | 2 +- .../DatapointCollectionView.kt | 4 +- .../shared_composables/HeaderDescription.kt | 2 +- .../android/shared_composables/HeaderTitle.kt | 2 +- .../app/android/shared_composables/Heading.kt | 2 +- .../android/shared_composables/IconInline.kt | 2 +- .../android/shared_composables/MediumTitle.kt | 2 +- .../shared_composables/MessageAlertDialog.kt | 4 +- .../MoreBackgroundComposable.kt | 6 +- .../android/shared_composables/MoreDivider.kt | 2 +- .../shared_composables/NavigationBarTitle.kt | 2 +- .../shared_composables/NavigationText.kt | 2 +- .../shared_composables/ScheduleList.kt | 5 +- .../shared_composables/ScheduleListHeader.kt | 4 +- .../shared_composables/SmallTextButton.kt | 4 +- .../shared_composables/SmallTextIconButton.kt | 4 +- .../android/shared_composables/SmallTitle.kt | 2 +- .../android/shared_composables/SwipeButton.kt | 2 +- .../shared_composables/TimeFrameDays.kt | 2 +- .../shared_composables/TimeframeHours.kt | 2 +- .../app/android/shared_composables/Title.kt | 2 +- .../more/app/android/{ui => }/theme/Color.kt | 2 +- .../more/app/android/{ui => }/theme/Theme.kt | 2 +- .../more/app/android/{ui => }/theme/Type.kt | 2 +- .../more/app/android/util/ActivityProvider.kt | 11 + .../more/app/android/util/AlarmUtils.kt | 138 ++++++ .../app/android/workers/DataUploadWorker.kt | 25 +- .../workers/NotificationDataHandlerWorker.kt | 2 +- .../android/workers/ScheduleUpdateWorker.kt | 7 +- .../main/res/values-de/navigation-strings.xml | 2 +- androidApp/src/main/res/values-de/strings.xml | 1 + .../main/res/values/navigation-strings.xml | 2 +- .../main/res/values/notification-strings.xml | 6 +- androidApp/src/main/res/values/strings.xml | 3 +- build.gradle.kts | 21 +- gradle/gradle-daemon-jvm.properties | 13 + ...tification-Service-Extension.entitlements} | 0 .../Info.plist | 0 .../NotificationService.swift | 45 +- iosApp/Gemfile.lock | 56 ++- iosApp/fastlane/Fastfile | 146 ++++--- iosApp/fastlane/README.md | 8 + iosApp/fastlane/report.xml | 38 -- iosApp/iosApp.xcodeproj/project.pbxproj | 132 ++++-- iosApp/iosApp/AppDelegate.swift | 22 +- .../BackgroundTasks/DailyBackgroundTask.swift | 67 +++ .../DataUploadBackgroundTask.swift | 37 +- .../ObservationReminderBackgroundTask.swift | 73 ++++ iosApp/iosApp/ContentViewModel.swift | 24 +- .../Extensions/BluetoothDeviceExtension.swift | 2 +- .../CMSensorDataListExtension.swift | 2 +- .../iosApp/Extensions/NavigationScreen.swift | 105 +++-- iosApp/iosApp/GoogleService-Info.plist | 34 -- iosApp/iosApp/Info.plist | 15 +- .../AccelerometerBackgroundObservation.swift | 9 +- .../iOSObservationDataManager.swift | 2 +- .../Resources/Strings/Localizable.xcstrings | 11 + .../Strings/NotificationFilter.strings | 17 - .../Strings/ObservationTypes.strings | 12 - .../Bluetooth/IOSBluetoothConnector.swift | 14 +- iosApp/iosApp/Services/FCMService.swift | 35 +- .../LocalPushNotificationService.swift | 98 ++++- iosApp/iosApp/Utils/Napier.swift | 44 ++ .../Views/Components/CheckboxField.swift | 49 +++ .../iosApp/Views/Components/ExitButton.swift | 40 ++ .../Views/Components/ModuleListItem.swift | 2 +- .../Views/Components/NotificationItem.swift | 2 +- .../Views/Components/RadioButtonField.swift | 10 +- .../Views/Components/ReloadButton.swift | 32 ++ iosApp/iosApp/Views/Consent/ConsentView.swift | 2 +- .../Views/LimeSurvey/LimeSurveyView.swift | 14 +- .../LimeSurvey/LimeSurveyViewModel.swift | 61 ++- iosApp/iosApp/Views/Login/LoginView.swift | 2 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 2 +- iosApp/iosApp/Views/MainTabView.swift | 45 +- .../iosApp/Views/NavigationModalState.swift | 154 ++++--- .../Notification/NotificationViewModel.swift | 14 +- .../QuestionObservationView.swift | 115 +++++ ...uView.swift => QuestionThankYouView.swift} | 4 +- .../QuestionTypeViews/MultiChoiceView.swift | 25 ++ .../QuestionTypeViews/SingleChoiceView.swift | 25 ++ .../QuestionViewModel.swift | 53 +++ .../SimpleQuestionObservationViewModel.swift | 66 --- .../SimpleQuetionObservationView.swift | 75 ---- .../Schedule/List/ObservationButton.swift | 37 +- .../StudyStates/StudyLoadingErrorView.swift | 30 +- .../Views/StudyStates/StudyPausedView.swift | 2 + .../TaskDetails/TaskDetailsViewModel.swift | 2 - iosApp/iosApp/iOSApp.swift | 6 +- iosApp/iosApp/iosApp.entitlements | 1 + openapi/MobileAppAPI.yaml | 197 ++++++++- settings.gradle.kts | 5 +- shared/build.gradle.kts | 85 +++- .../{more_app_mutliplatform => }/Platform.kt | 8 +- .../more/database/DatabaseManager.android.kt | 25 ++ .../extensions/asString.kt | 2 +- .../models/NotificationTextLocalization.kt | 30 ++ .../database/DatabaseManager.android.kt | 14 - .../services/network/HttpClientReceiver.kt | 2 +- .../services/store/AndroidContext.kt | 2 +- .../store/PrivateSharedPreferences.kt | 2 +- .../store/SharedPreferencesRepository.kt | 2 +- .../{more_app_mutliplatform => }/util/UUID.kt | 2 +- .../AlertController.kt | 17 +- .../NapierProxy.kt | 2 +- .../{more_app_mutliplatform => }/Platform.kt | 2 +- .../kotlin/io/redlink/more/Shared.kt | 407 ++++++++++++++++++ .../io/redlink/more/database/AppDatabase.kt | 53 +++ .../database/DatabaseManager.kt | 4 +- .../database/dao/BaseDao.kt | 2 +- .../database/dao/BluetoothDeviceDao.kt | 15 +- .../database/dao/DataPointDao.kt | 15 +- .../database/dao/NotificationDao.kt | 18 +- .../database/dao/ObservationDao.kt | 4 +- .../database/dao/ObservationDataDao.kt | 4 +- .../database/dao/ScheduleDao.kt | 27 +- .../database/dao/StudyDao.kt | 12 +- .../entities/BluetoothDeviceEntity.kt | 15 +- .../more/database/entities/DataPointEntity.kt | 24 ++ .../database/entities/NotificationEntity.kt | 17 +- .../entities/ObservationDataEntity.kt | 10 +- .../database/entities/ObservationEntity.kt | 8 +- .../database/entities/ScheduleEntity.kt | 24 +- .../database/entities/StudyEntity.kt | 22 +- .../migrations/Migration_1_2.kt} | 32 +- .../repository/BluetoothDeviceRepository.kt | 8 +- .../repository/DataPointCountRepository.kt | 27 +- .../database/repository/MainRepository.kt | 30 ++ .../repository/NotificationRepository.kt | 65 ++- .../repository/ObservationDataRepository.kt | 15 +- .../repository/ObservationRepository.kt | 10 +- .../database/repository/ScheduleRepository.kt | 96 ++--- .../database/repository/StudyRepository.kt | 60 +-- .../extensions/AnyExtension.kt | 2 +- .../extensions/CollectionExtension.kt | 10 +- .../extensions/CoroutineExtension.kt | 2 +- .../extensions/DateTimeConverter.kt | 2 +- .../extensions/FlowExtension.kt | 4 +- .../more/extensions/FunctionExtension.kt | 14 + .../extensions/ScheduleEntityExtenstion.kt | 29 ++ .../extensions/StringExtension.kt | 13 +- .../more/extensions/StudyStateExtension.kt | 18 + .../models/AlertDialogModel.kt | 13 +- .../models/CredentialModel.kt | 2 +- .../models/DateFilterModel.kt | 4 +- .../models/FilterModel.kt | 2 +- .../io/redlink/more/models/LoginModel.kt | 24 ++ .../models/NotificationFilterTypeModel.kt | 2 +- .../redlink/more/models/NotificationModel.kt | 56 +++ .../NotificationStatusType.kt} | 8 +- .../NotificationTextLocalization.kt} | 30 +- .../more/models/NotificationTextType.kt | 32 ++ .../models/ObservationDetailsModel.kt | 6 +- .../models/PermissionConsentModel.kt | 2 +- .../models/PermissionModel.kt | 8 +- .../QuestionModel.kt} | 21 +- .../QuestionType.kt} | 16 +- .../models/ScheduleListType.kt | 2 +- .../models/ScheduleModel.kt | 4 +- .../models/ScheduleState.kt | 2 +- .../models/StudyDetailsModel.kt | 6 +- .../models/StudyState.kt | 5 +- .../models/TaskCompletion.kt | 2 +- .../models/TaskDetailsModel.kt | 6 +- .../more/more_app_mutliplatform/Shared.kt | 290 ------------- .../database/AppDatabase.kt | 42 -- .../database/entities/DataPointEntity.kt | 13 - .../database/repository/MainRepository.kt | 20 - .../extensions/FunctionExtension.kt | 3 - .../models/LoginModel.kt | 13 - .../models/NotificationModel.kt | 55 --- .../models/NotificationStatusType.kt | 6 - .../navigation/DeeplinkManager.kt | 141 ------ .../observations/garmin/GarminObservation.kt | 20 - .../observationTypes/GarminType.kt | 3 - .../services/bluetooth/ScanMode.kt | 7 - .../services/bluetooth/polar/PolarStates.kt | 20 - .../services/network/NetworkService.kt | 403 ----------------- .../services/network/openapi/model/ApiKey.kt | 39 -- .../network/openapi/model/AppConfiguration.kt | 40 -- .../network/openapi/model/DataBulk.kt | 39 -- .../services/network/openapi/model/Error.kt | 38 -- .../openapi/model/FcmNotificationConfig.kt | 57 --- .../model/FcmNotificationConfigAllOf.kt | 53 --- .../services/network/openapi/model/Log.kt | 49 --- .../network/openapi/model/Observation.kt | 64 --- .../openapi/model/ObservationConsent.kt | 39 -- .../network/openapi/model/ObservationData.kt | 50 --- .../openapi/model/ObservationSchedule.kt | 39 -- .../network/openapi/model/Participant.kt | 38 -- .../network/openapi/model/PushNotification.kt | 28 -- .../model/PushNotificationServiceType.kt | 61 --- .../services/network/openapi/model/Study.kt | 69 --- .../network/openapi/model/StudyConsent.kt | 48 --- .../network/openapi/model/StudyContact.kt | 44 -- .../notification/NotificationActionHandler.kt | 5 - .../more_app_mutliplatform/util/RegexData.kt | 8 - .../bluetoothConnection/PolarController.kt | 22 - .../limeSurvey/CoreLimeSurveyViewModel.kt | 116 ----- .../SimpleQuestionCoreViewModel.kt | 85 ---- .../more/navigation/DeeplinkManager.kt | 262 +++++++++++ .../more/navigation/model/DeepLinkData.kt | 17 + .../more/navigation/model/NavigationRoute.kt | 39 ++ .../model/NavigationRouteParameter.kt | 23 + .../observations/DataConfig.kt | 2 +- .../observations/DataRecorder.kt | 2 +- .../observations/Observation.kt | 46 +- .../observations/ObservationBulkModel.kt | 2 +- .../observations/ObservationDataManager.kt | 26 +- .../observations/ObservationFactory.kt | 37 +- .../observations/ObservationManager.kt | 17 +- .../observations/ObservationStates.kt | 13 +- .../observations/garmin/GarminObservation.kt | 31 ++ .../limesurvey/LimeSurveyObservation.kt | 27 +- .../observationTypes/AccelerometerType.kt | 2 +- .../observations/observationTypes/GPSType.kt | 2 +- .../observationTypes/GarminType.kt | 18 + .../observationTypes/LimeSurveyType.kt | 2 +- .../observationTypes/ObservationType.kt | 30 ++ .../PolarVerityHeartRateType.kt | 2 +- .../observationTypes/QuestionType.kt | 15 + .../QuestionObservation.kt} | 12 +- .../registration/RegistrationService.kt | 65 +-- .../scopes/Scope.kt | 6 +- .../scopes/StudyScope.kt | 13 +- .../more/services/ObservationService.kt | 70 +++ .../services/bluetooth/BluetoothConnector.kt | 4 +- .../bluetooth/BluetoothConnectorObserver.kt | 4 +- .../services/bluetooth/BluetoothState.kt | 2 +- .../bluetooth/BluetoothStateManagement.kt | 15 +- .../more/services/bluetooth/ScanMode.kt | 18 + .../services/bluetooth/polar/PolarStates.kt | 31 ++ .../services/network/HttpClientReceiver.kt | 2 +- .../more/services/network/NetworkClients.kt | 231 ++++++++++ .../more/services/network/NetworkService.kt | 287 ++++++++++++ .../network/errors/NetworkServiceError.kt | 0 .../notification/NotificationActionHandler.kt | 17 + .../notification/NotificationManager.kt | 186 +++++--- .../services/store/CredentialRepository.kt | 6 +- .../services/store/EndpointRepository.kt | 4 +- .../services/store/SharedStorageRepository.kt | 2 +- .../kotlin/io/redlink/more/util/FlowuUtils.kt | 41 ++ .../kotlin/io/redlink/more/util/KMMLogger.kt | 33 ++ .../kotlin/io/redlink/more/util/RegexData.kt | 19 + .../util/StringUtils.kt | 14 +- .../{more_app_mutliplatform => }/util/UUID.kt | 2 +- .../viewModels/CoreViewModel.kt | 2 +- .../viewModels/ViewManager.kt | 16 +- .../BluetoothController.kt | 24 +- .../bluetoothConnection/PolarController.kt | 33 ++ .../dashboard/CoreDashboardFilterViewModel.kt | 20 +- .../CoreGarminConnectViewModel.kt | 23 +- .../limeSurvey/CoreLimeSurveyViewModel.kt | 106 +++++ .../viewModels/login/CoreLoginViewModel.kt | 8 +- .../CoreNotificationFilterViewModel.kt | 12 +- .../CoreNotificationViewModel.kt | 22 +- .../CoreObservationDetailsViewModel.kt | 21 +- .../permission/CoreConsentViewModel.kt | 10 +- .../schedules/CoreScheduleViewModel.kt | 53 ++- .../settings/CoreSettingsViewModel.kt | 10 +- .../simpleQuestion/QuestionCoreViewModel.kt | 82 ++++ .../CoreBluetoothViewModel.kt | 10 +- .../studydetails/CoreStudyDetailsViewModel.kt | 8 +- .../CoreTaskCompletionBarViewModel.kt | 8 +- .../tasks/CoreTaskDetailsViewModel.kt | 16 +- .../moko-resources/base/strings.xml | 7 + .../commonMain/moko-resources/de/strings.xml | 4 + .../store => }/ImMemoryStorageRepository.kt | 4 +- .../{more_app_mutliplatform => }/Platform.kt | 2 +- .../database/DatabaseManager.ios.kt | 2 +- .../extensions/toString.kt | 2 +- .../models/NotificationTextLocalization.kt | 13 + .../services/network/HttpClientReceiver.kt | 2 +- .../services/store/UserDefaultsRepository.kt | 2 +- .../{more_app_mutliplatform => }/util/UUID.kt | 2 +- 388 files changed, 6541 insertions(+), 4436 deletions(-) delete mode 100644 androidApp/fastlane/report.xml delete mode 100644 androidApp/google-services.json create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionViewModel.kt delete mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionnaireViewModel.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireCheckboxes.kt rename androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/{ => questionType}/QuestionnaireQuestionAnswer.kt (53%) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ReloadButton.kt create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt rename androidApp/src/main/java/io/redlink/more/app/android/{ui => }/theme/Color.kt (98%) rename androidApp/src/main/java/io/redlink/more/app/android/{ui => }/theme/Theme.kt (97%) rename androidApp/src/main/java/io/redlink/more/app/android/{ui => }/theme/Type.kt (96%) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/util/AlarmUtils.kt create mode 100644 gradle/gradle-daemon-jvm.properties rename iosApp/{More-Notification-Service-Extension/More-Notification-Service-Extension.entitlements => BlendedCare-Notification-Service-Extension/BlendedCare-Notification-Service-Extension.entitlements} (100%) rename iosApp/{More-Notification-Service-Extension => BlendedCare-Notification-Service-Extension}/Info.plist (100%) rename iosApp/{More-Notification-Service-Extension => BlendedCare-Notification-Service-Extension}/NotificationService.swift (65%) delete mode 100644 iosApp/fastlane/report.xml create mode 100644 iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift create mode 100644 iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift delete mode 100644 iosApp/iosApp/GoogleService-Info.plist delete mode 100644 iosApp/iosApp/Resources/Strings/NotificationFilter.strings delete mode 100644 iosApp/iosApp/Resources/Strings/ObservationTypes.strings create mode 100644 iosApp/iosApp/Utils/Napier.swift create mode 100644 iosApp/iosApp/Views/Components/CheckboxField.swift create mode 100644 iosApp/iosApp/Views/Components/ExitButton.swift create mode 100644 iosApp/iosApp/Views/Components/ReloadButton.swift create mode 100644 iosApp/iosApp/Views/QuestionObservation/QuestionObservationView.swift rename iosApp/iosApp/Views/QuestionObservation/{SimpleQuestionThankYouView.swift => QuestionThankYouView.swift} (90%) create mode 100644 iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/MultiChoiceView.swift create mode 100644 iosApp/iosApp/Views/QuestionObservation/QuestionTypeViews/SingleChoiceView.swift create mode 100644 iosApp/iosApp/Views/QuestionObservation/QuestionViewModel.swift delete mode 100644 iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift delete mode 100644 iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift rename shared/src/androidMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/Platform.kt (78%) create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/database/DatabaseManager.android.kt rename shared/src/androidMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/asString.kt (90%) create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt delete mode 100644 shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.android.kt rename shared/src/androidMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/network/HttpClientReceiver.kt (95%) rename shared/src/androidMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/store/AndroidContext.kt (90%) rename shared/src/androidMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/store/PrivateSharedPreferences.kt (90%) rename shared/src/androidMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/store/SharedPreferencesRepository.kt (97%) rename shared/src/androidMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/util/UUID.kt (91%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/AlertController.kt (65%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/NapierProxy.kt (93%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/Platform.kt (93%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/Shared.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/DatabaseManager.kt (89%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/BaseDao.kt (96%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/BluetoothDeviceDao.kt (67%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/DataPointDao.kt (60%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/NotificationDao.kt (90%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/ObservationDao.kt (96%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/ObservationDataDao.kt (98%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/ScheduleDao.kt (85%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/dao/StudyDao.kt (87%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/entities/BluetoothDeviceEntity.kt (53%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/entities/DataPointEntity.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/entities/NotificationEntity.kt (90%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/entities/ObservationDataEntity.kt (90%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/entities/ObservationEntity.kt (89%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/entities/ScheduleEntity.kt (78%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/entities/StudyEntity.kt (74%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform/services/network/openapi/model/PushNotificationConfig.kt => database/migrations/Migration_1_2.kt} (50%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/repository/BluetoothDeviceRepository.kt (82%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/repository/DataPointCountRepository.kt (82%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/repository/NotificationRepository.kt (76%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/repository/ObservationDataRepository.kt (80%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/repository/ObservationRepository.kt (91%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/repository/ScheduleRepository.kt (71%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/repository/StudyRepository.kt (63%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/AnyExtension.kt (89%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/CollectionExtension.kt (79%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/CoroutineExtension.kt (95%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/DateTimeConverter.kt (95%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/FlowExtension.kt (96%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/StringExtension.kt (65%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/AlertDialogModel.kt (64%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/CredentialModel.kt (93%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/DateFilterModel.kt (90%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/FilterModel.kt (92%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/NotificationFilterTypeModel.kt (94%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/models/NotificationModel.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt => models/NotificationStatusType.kt} (74%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform/services/network/openapi/model/PushNotificationToken.kt => models/NotificationTextLocalization.kt} (50%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/ObservationDetailsModel.kt (85%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/PermissionConsentModel.kt (91%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/PermissionModel.kt (88%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform/models/SimpleQuestionModel.kt => models/QuestionModel.kt} (76%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform/observations/observationTypes/ObservationType.kt => models/QuestionType.kt} (52%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/ScheduleListType.kt (91%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/ScheduleModel.kt (93%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/ScheduleState.kt (94%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/StudyDetailsModel.kt (83%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/StudyState.kt (93%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/TaskCompletion.kt (91%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/models/TaskDetailsModel.kt (87%) delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/AppDatabase.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/DataPointEntity.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/MainRepository.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/LoginModel.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationModel.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/navigation/DeeplinkManager.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/garmin/GarminObservation.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GarminType.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/ScanMode.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ApiKey.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/AppConfiguration.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/DataBulk.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Error.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfig.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfigAllOf.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Log.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Observation.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationConsent.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationData.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationSchedule.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Participant.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotification.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationServiceType.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Study.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyConsent.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyContact.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationActionHandler.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/simpleQuestion/SimpleQuestionCoreViewModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/navigation/model/DeepLinkData.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRouteParameter.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/DataConfig.kt (90%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/DataRecorder.kt (92%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/Observation.kt (89%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/ObservationBulkModel.kt (90%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/ObservationDataManager.kt (80%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/ObservationFactory.kt (74%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/ObservationManager.kt (95%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/ObservationStates.kt (60%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/garmin/GarminObservation.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/limesurvey/LimeSurveyObservation.kt (74%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/observationTypes/AccelerometerType.kt (88%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/observationTypes/GPSType.kt (88%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GarminType.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/observationTypes/LimeSurveyType.kt (87%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/ObservationType.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/observations/observationTypes/PolarVerityHeartRateType.kt (88%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/QuestionType.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform/observations/simpleQuestionObservation/SimpleQuestionObservation.kt => observations/questionObservation/QuestionObservation.kt} (63%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/registration/RegistrationService.kt (68%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/scopes/Scope.kt (96%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/scopes/StudyScope.kt (83%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/bluetooth/BluetoothConnector.kt (88%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/bluetooth/BluetoothConnectorObserver.kt (86%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/bluetooth/BluetoothState.kt (88%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/bluetooth/BluetoothStateManagement.kt (89%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/ScanMode.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/polar/PolarStates.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/network/HttpClientReceiver.kt (91%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkClients.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkService.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/network/errors/NetworkServiceError.kt (100%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/notification/NotificationManager.kt (62%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/store/CredentialRepository.kt (92%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/store/EndpointRepository.kt (91%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/store/SharedStorageRepository.kt (94%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/util/StringUtils.kt (83%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/util/UUID.kt (90%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/CoreViewModel.kt (95%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/ViewManager.kt (87%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/bluetoothConnection/BluetoothController.kt (89%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/PolarController.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/dashboard/CoreDashboardFilterViewModel.kt (86%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt (77%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/login/CoreLoginViewModel.kt (75%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/notifications/CoreNotificationFilterViewModel.kt (87%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/notifications/CoreNotificationViewModel.kt (77%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/observationDetails/CoreObservationDetailsViewModel.kt (73%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/permission/CoreConsentViewModel.kt (72%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/schedules/CoreScheduleViewModel.kt (78%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/settings/CoreSettingsViewModel.kt (85%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/startupConnection/CoreBluetoothViewModel.kt (75%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/studydetails/CoreStudyDetailsViewModel.kt (88%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt (83%) rename shared/src/commonMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/viewModels/tasks/CoreTaskDetailsViewModel.kt (87%) create mode 100644 shared/src/commonMain/moko-resources/base/strings.xml create mode 100644 shared/src/commonMain/moko-resources/de/strings.xml rename shared/src/commonTest/kotlin/io/redlink/more/{more_app_mutliplatform/services/store => }/ImMemoryStorageRepository.kt (95%) rename shared/src/iosMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/Platform.kt (93%) rename shared/src/iosMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/database/DatabaseManager.ios.kt (93%) rename shared/src/iosMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/extensions/toString.kt (94%) create mode 100644 shared/src/iosMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt rename shared/src/iosMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/network/HttpClientReceiver.kt (96%) rename shared/src/iosMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/services/store/UserDefaultsRepository.kt (97%) rename shared/src/iosMain/kotlin/io/redlink/more/{more_app_mutliplatform => }/util/UUID.kt (91%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6537061d0..802fd0590 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,7 @@ jobs: 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 }} @@ -38,7 +39,7 @@ jobs: - name: Set Xcode Version uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.0.1' + xcode-version: '26.2' - name: Build iOS app run: fastlane build @@ -55,6 +56,7 @@ jobs: 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 diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 9fb0d9620..bdeb3643b 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -11,7 +11,7 @@ on: jobs: ios: name: iOS - runs-on: macos-15 + runs-on: macos-26 environment: AppStore if: startsWith(github.ref, 'refs/tags/') env: @@ -23,6 +23,7 @@ jobs: 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 }} @@ -63,6 +64,7 @@ jobs: 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 diff --git a/.gitignore b/.gitignore index 6aae8c35a..f95c9d147 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ captures .cxx local.properties xcuserdata -shared/src/commonMain/kotlin/generated +/shared/src/commonMain/kotlin/generated /androidApp/release/ *.ipa .env @@ -18,4 +18,13 @@ shared/src/commonMain/kotlin/generated *.zip *.p8 .kotlin -*.db \ No newline at end of file +*.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/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 4d8087e1f..a828c120b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ It's recommended that you install the latest stable versions for compatibility a 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 16.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 21](https://www.oracle.com/java/technologies/downloads/) * [Gradle 8.14.3](https://gradle.org) @@ -61,21 +61,32 @@ The following is an instruction on how to install and configure the project on y 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** +3. To successfully build the iOS and Android apps, you need to integrate the Google API Key files + for each platform. + - 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. -4. This project requires **JDK 11** or later. To build the project you need to set your **runtime to +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**. -5. After being upgraded to JDK 11 or a later version, the Settings dialog in Android Studio can be +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. -6. Now we can build the project. Go to the terminal and perform the following command in the root +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 @@ -198,16 +209,17 @@ To run the pipeline, you need to set up the following environment variables: - `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) -- `APPLE_CERTIFICATE`: Apple certificate for signing - `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., " - ac.at.lbg.dhp.more,ac.at.lbg.dhp.more.More-Notification-Service-Extension") + 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., " - More,More-Notification-Service-Extension") + 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 ( @@ -225,6 +237,7 @@ To run the pipeline, you need to set up the following environment variables: - `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:** @@ -283,7 +296,8 @@ 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. + *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. @@ -409,7 +423,7 @@ The source code of the shared module is organized in three source sets according 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/more/more_app_mutliplatform/database`. +`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!* diff --git a/androidApp/Gemfile.lock b/androidApp/Gemfile.lock index 56fc04f3b..a935bfc9d 100644 --- a/androidApp/Gemfile.lock +++ b/androidApp/Gemfile.lock @@ -1,39 +1,40 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + 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.3.2) - aws-partitions (1.1107.0) - aws-sdk-core (3.224.0) + 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.101.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.186.1) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.205.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + 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) @@ -53,14 +54,14 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) + faraday-multipart (1.1.1) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) @@ -70,8 +71,9 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.227.2) + 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) @@ -79,6 +81,7 @@ GEM 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) @@ -98,6 +101,7 @@ GEM 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) @@ -157,37 +161,36 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.12.2) - jwt (2.10.1) + 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.15.0) + multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) - naturally (2.2.1) - nkf (0.2.0) - optparse (0.6.0) + naturally (2.3.0) + optparse (0.8.0) os (1.1.4) plist (3.7.2) - public_suffix (6.0.2) - rake (13.2.1) + 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.1) + rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.20.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList @@ -224,4 +227,4 @@ DEPENDENCIES fastlane BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f4fa483f8..9813b7380 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Base64 import java.util.Properties @@ -33,6 +34,20 @@ fun loadEnvFromFile(): Properties { } } + 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 } @@ -49,8 +64,8 @@ android { applicationId = "ac.at.lbg.dhp.more" minSdk = 29 targetSdk = 36 - versionCode = 36 - versionName = "4.1.8" + versionCode = 37 + versionName = "5.0.0" } buildFeatures { compose = true @@ -106,11 +121,11 @@ android { storeFile?.let { this.storeFile = it } ?: run { - println("Keystore file not found, falling back to debug keystore") - this.storeFile = File(System.getProperty("user.home"), ".android/debug.keystore") - this.storePassword = "android" - this.keyAlias = "androiddebugkey" - this.keyPassword = "android" + println("Keystore file not found for release signing. Leaving release signing unconfigured.") + this.storeFile = null + this.storePassword = null + this.keyAlias = null + this.keyPassword = null } } } @@ -125,10 +140,11 @@ android { buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") val releaseSigningConfig = signingConfigs.getByName("release") - if (releaseSigningConfig.storeFile != null) { - signingConfig = releaseSigningConfig + signingConfig = if (releaseSigningConfig.storeFile != null) { + releaseSigningConfig } else { - println("Warning: No signing configuration available. Using debug signing.") + println("Warning: No release keystore configured. Falling back to default debug signing.") + signingConfigs.getByName("debug") } isMinifyEnabled = true @@ -139,11 +155,14 @@ android { } } 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) } } @@ -153,8 +172,8 @@ val composeVersion = "1.6.0" val workVersion = "2.10.3" val navVersion = "2.9.3" val polarSDKVersion = "6.7.0" -val ktorVersion = "3.2.3" -val roomVersion = "2.7.2" +val ktorVersion = "3.4.0" +val roomVersion = "2.8.4" val koinVersion = "4.1.1" val cameraVersion = "1.4.2" diff --git a/androidApp/fastlane/Fastfile b/androidApp/fastlane/Fastfile index f096a6d1f..f1c54b1ba 100644 --- a/androidApp/fastlane/Fastfile +++ b/androidApp/fastlane/Fastfile @@ -84,6 +84,20 @@ platform :android do return apk_path end + def setup_google_services + 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: "test", project_dir: "..") @@ -92,8 +106,10 @@ platform :android do desc "Build the Android app (debug)" lane :build do begin + setup_google_services setup_android_signing + generate_openapi gradle( task: "assemble", build_type: "Debug", @@ -132,10 +148,21 @@ platform :android do 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") @@ -189,6 +216,7 @@ platform :android do UI.message("Fallback version code: #{code}") end + generate_openapi gradle( task: "bundle", @@ -228,4 +256,104 @@ platform :android do 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") + + 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 index 50bcb041b..7dfa0638a 100644 --- a/androidApp/fastlane/README.md +++ b/androidApp/fastlane/README.md @@ -39,6 +39,14 @@ Build the Android app (debug) 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. diff --git a/androidApp/fastlane/report.xml b/androidApp/fastlane/report.xml deleted file mode 100644 index 8ed4e9fc6..000000000 --- a/androidApp/fastlane/report.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - 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/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index fd4d15499..b29ccc59a 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ + + @@ -84,28 +86,28 @@ + android:foregroundServiceType="dataSync" /> @@ -118,10 +120,12 @@ android:value="@string/default_channel_id" /> + android:name="io.redlink.more.app.android.broadcasts.NotificationBroadcastReceiver" + android:exported="true"> - + + + 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 4a17d5097..adaab2560 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,20 +18,21 @@ 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.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.getDatabaseBuilder -import io.redlink.more.more_app_mutliplatform.database.getRoomDatabase -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.napierDebugBuild -import io.redlink.more.more_app_mutliplatform.services.store.SharedPreferencesRepository -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager +import io.redlink.more.database.AppDatabase +import io.redlink.more.database.getDatabaseBuilder +import io.redlink.more.database.getRoomDatabase +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.models.NotificationTextLocalization +import io.redlink.more.napierDebugBuild +import io.redlink.more.services.store.SharedPreferencesRepository /** * Main Application class of the project. @@ -42,6 +43,10 @@ class MoreApplication : Application(), DefaultLifecycleObserver { napierDebugBuild(FirebaseCrashlyticsAntilog()) napierDebugBuild() 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) @@ -55,20 +60,28 @@ class MoreApplication : Application(), DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { super.onResume(owner) Napier.i { "App is in the foreground..." } - ViewManager.appIsInForeground(true) - shared?.notificationManager?.updateNotificationBadgeCount() + shared?.updateData(true) } override fun onPause(owner: LifecycleOwner) { super.onPause(owner) Napier.i { "App is in the background..." } - 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 @@ -96,8 +109,11 @@ class MoreApplication : Application(), DefaultLifecycleObserver { AndroidObservationFactory(context, dataManager, repositories), AndroidDataRecorder() ) + shared?.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 c58234262..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 @@ -17,9 +17,9 @@ import androidx.compose.runtime.Composable 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 @@ -28,8 +28,9 @@ 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.more_app_mutliplatform.viewModels.ViewManager +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 @@ -39,12 +40,13 @@ 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" } } } 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 9b94e968f..1cf36fb03 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 @@ -20,17 +20,15 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import io.github.aakira.napier.Napier +import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication -import io.redlink.more.app.android.R 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.registration.RegistrationService -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager +import io.redlink.more.models.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 @@ -38,7 +36,8 @@ import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit class ContentViewModel : ViewModel() { - val registrationService: RegistrationService = RegistrationService(MoreApplication.shared!!) + val registrationService: RegistrationService = + RegistrationService(MoreApplication.shared!!) val hasCredentials = mutableStateOf(false) @@ -60,7 +59,7 @@ class ContentViewModel : ViewModel() { } } - fun openMainActivity(context: Context) { + suspend fun openMainActivity(context: Context) { (context as? Activity)?.let { activity -> schedulePeriodicWorker(activity) handleDeepLinkAndOpenMain(activity) @@ -77,44 +76,34 @@ class ContentViewModel : ViewModel() { ) } - private fun handleDeepLinkAndOpenMain(activity: Activity) { + 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() + } - viewModelScope.launch(Dispatchers.IO) { - val modifiedDeepLink = rawDeepLink?.let { link -> - val sharedInstance = MoreApplication.shared - ?: throw IllegalStateException("MoreApplication.shared is not initialized") - sharedInstance.deeplinkManager - .modifyDeepLink(link, stringResource(R.string.app_scheme), applicationId) - .firstOrNull() + notificationId?.let { + Scope.launch { + sharedInstance.notificationManager.markNotificationAsRead(it) } + } - val finalUri = when { - modifiedDeepLink != null -> { - notificationId?.let { - val sharedInstance = MoreApplication.shared - ?: throw IllegalStateException("MoreApplication.shared is not initialized") - sharedInstance.notificationManager.handleNotificationInteraction( - it, modifiedDeepLink - ) - } - modifiedDeepLink.toUri() - } - - notificationId != null -> { - (ContentActivity.DEEPLINK + NavigationScreen.NOTIFICATIONS.routeWithParameters()).toUri() - } - - else -> null - } + Napier.d { "Modified deeplink: $modifiedDeepLink" } - Napier.d { finalUri.toString() } - withContext(Dispatchers.Main) { - activity.intent.data = finalUri - openMain(activity) - } + withContext(Dispatchers.Main) { + activity.intent.data = modifiedDeepLink?.route?.toUri() + openMain(activity) } } 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 d3b99c49d..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,9 +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.GarminType -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<*>, @@ -31,65 +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 ), - 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), + RUNNING_SCHEDULES( + NavigationRoute.RUNNING_SCHEDULES, + stringResource = R.string.nav_running_schedules + ), + 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 ), GARMIN_CONNECT( - GarminType().observationType, + NavigationRoute.GARMIN_CONNECT, parameters = mapOf(), stringResource = R.string.nav_garmin_connect ), - OBSERVATION_ERRORS("observation-errors", stringResource = R.string.nav_observation_errors); + OBSERVATION_ERRORS( + NavigationRoute.OBSERVATION_ERRORS, + stringResource = R.string.nav_observation_errors + ); private var cachedNavArguments: List? = null private var cachedRoute: String? = null @@ -102,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 += "?" @@ -122,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() @@ -136,7 +167,7 @@ enum class NavigationScreen( } notificationId?.let { - queryParams.add("$NavigationNotificationIDKey=$it") + queryParams.add("${NavigationRouteParameter.NOTIFICATION_ID.key}=$it") } if (queryParams.isNotEmpty()) { @@ -172,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/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 7392cd914..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 @@ -51,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() @@ -117,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 { 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 5588fcf59..e4426f63b 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 @@ -14,16 +14,16 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.redlink.more.AlertController 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.database.entities.BluetoothDeviceEntity -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement -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.models.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 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 6b469baad..57c354a9e 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 @@ -34,8 +34,8 @@ 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.more_app_mutliplatform.registration.RegistrationService +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.registration.RegistrationService @Composable fun ConsentView(registrationService: RegistrationService) { 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 d29984024..021f17fda 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 @@ -12,14 +12,14 @@ package io.redlink.more.app.android.activities.consent import android.content.Context import androidx.lifecycle.ViewModel +import io.redlink.more.AlertController 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.registration.RegistrationService -import io.redlink.more.more_app_mutliplatform.viewModels.permission.CoreConsentViewModel +import io.redlink.more.models.AlertDialogModel +import io.redlink.more.registration.RegistrationService +import io.redlink.more.viewModels.permission.CoreConsentViewModel class ConsentViewModel( val registrationService: RegistrationService 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 8d408aa72..1bff25f02 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 @@ -39,7 +39,7 @@ 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.observations.PermissionUtils -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun ConsentButtons(model: ConsentViewModel) { @@ -134,7 +134,8 @@ fun checkAndRequestPermissions( extraPermissions: Set = emptySet() ) { val permissions = - MoreApplication.shared!!.observationFactory.studySensorPermissions().toMutableSet() + MoreApplication.shared!!.observationFactory.studySensorPermissions() + .toMutableSet() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissions.add(Manifest.permission.POST_NOTIFICATIONS) @@ -143,7 +144,8 @@ fun checkAndRequestPermissions( permissions.addAll(extraPermissions) permissions.addAll( - MoreApplication.shared?.observationFactory?.studySensorPermissions() ?: emptySet() + MoreApplication.shared?.observationFactory?.studySensorPermissions() + ?: emptySet() ) val hasBackgroundLocationPermission = 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 de35d7d1c..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 @@ -31,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( @@ -49,7 +50,11 @@ fun FilterView( .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { - navController.navigate(NavigationScreen.OBSERVATION_FILTER.navigationRoute("scheduleListType" to scheduleListType)) + navController.navigate( + NavigationScreen.OBSERVATION_FILTER.navigationRoute( + NavigationRouteParameter.SCHEDULE_LIST_TYPE.key to scheduleListType + ) + ) }) ) { Text( 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 c89163506..33a1765d7 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 @@ -35,8 +35,8 @@ 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.more_app_mutliplatform.viewModels.schedules.CoreScheduleViewModel +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.viewModels.schedules.CoreScheduleViewModel @Composable fun DashboardFilterView(coreScheduleViewModel: CoreScheduleViewModel) { 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 d2d0a5ec5..3cbc0f6df 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 @@ -18,9 +18,9 @@ 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.scopes.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 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 7bc491b04..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 @@ -15,10 +15,10 @@ 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.models.ScheduleListType -import io.redlink.more.more_app_mutliplatform.services.bluetooth.polar.PolarStates -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.launch import kotlinx.coroutines.withContext 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 cfec056aa..73bb33a56 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 @@ -30,17 +30,17 @@ 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.jvmLocalDateTime 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( @@ -61,7 +61,10 @@ fun ScheduleListItem( horizontalArrangement = Arrangement.Start, modifier = Modifier.fillMaxWidth() ) { - SmallTitle(text = scheduleModel().observationTitle, color = MoreColors.Primary) + SmallTitle( + text = scheduleModel().observationTitle, + color = MoreColors.Primary + ) if (scheduleModel().scheduleState == ScheduleState.RUNNING) { CircularProgressIndicator( color = MoreColors.Approved, @@ -77,7 +80,10 @@ fun ScheduleListItem( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - BasicText(text = scheduleModel().observationType, color = MoreColors.Secondary) + BasicText( + text = scheduleModel().observationType, + color = MoreColors.Secondary + ) Row(horizontalArrangement = Arrangement.End) { if ((observationErrors[scheduleModel().observationType]?.count() ?: 0) > 0 @@ -104,44 +110,22 @@ fun ScheduleListItem( 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) - } - - } + 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/info/InfoItem.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/info/InfoItem.kt index 25347df76..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 @@ -29,7 +29,7 @@ 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( 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 6f2450797..9d10eaa02 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 @@ -47,7 +47,7 @@ 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) { 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 2b7af9f89..588f8cd88 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 @@ -12,7 +12,7 @@ package io.redlink.more.app.android.activities.info import androidx.lifecycle.ViewModel import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.viewModels.studydetails.CoreStudyDetailsViewModel +import io.redlink.more.viewModels.studydetails.CoreStudyDetailsViewModel class InfoViewModel : ViewModel() { val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!) 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 3dc3b5d0d..ec22db7f6 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 @@ -39,9 +39,9 @@ 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) { 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 81520b44b..e3af730e1 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 @@ -38,9 +38,9 @@ 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) { 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 8783a6b73..ac456877c 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,15 +19,16 @@ 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.entities.StudyEntity -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 LeaveStudyViewModel : ViewModel() { - private var coreSettingsViewModel = CoreSettingsViewModel(MoreApplication.shared!!) + private var coreSettingsViewModel = + CoreSettingsViewModel(MoreApplication.shared!!) val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) 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 a82700f35..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,7 +26,7 @@ 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.more_app_mutliplatform.registration.RegistrationService +import io.redlink.more.registration.RegistrationService @Composable fun LoginView(registrationService: RegistrationService) { 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 11a4b31dd..9cf31f75f 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,13 +12,13 @@ package io.redlink.more.app.android.activities.login import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import io.redlink.more.AlertController 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.models.LoginModel -import io.redlink.more.more_app_mutliplatform.registration.RegistrationService -import io.redlink.more.more_app_mutliplatform.util.validateAndNormalizeUrl +import io.redlink.more.models.AlertDialogModel +import io.redlink.more.models.LoginModel +import io.redlink.more.registration.RegistrationService +import io.redlink.more.util.validateAndNormalizeUrl class LoginViewModel( val registrationService: RegistrationService @@ -62,7 +62,8 @@ class LoginViewModel( } fun extractValuesFromQRCode(qrCodeUrl: String) { - dataEndpoint.value = qrCodeUrl.substringBefore("signup?") + dataEndpoint.value = + qrCodeUrl.substringBefore("signup?").validateAndNormalizeUrl() ?: currentEndpoint() participantKey.value = qrCodeUrl.substringAfter("token=", "").takeIf { it.isNotEmpty() } ?: "" } 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 68a0287df..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 @@ -55,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( 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 7aa814653..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,16 +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 eb14f757a..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 @@ -44,7 +44,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.MoreDivider -import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.theme.MoreColors @Composable fun ParticipationKeyInput( 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 5bd316a21..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 @@ -34,8 +34,8 @@ 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.moreSecondary +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreSecondary @Composable fun QRCodeButton(model: LoginViewModel) { @@ -44,7 +44,6 @@ fun QRCodeButton(model: LoginViewModel) { val qrScannerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> - val data = result.data val scanned = result.data?.getStringExtra("qrResult") ?: return@rememberLauncherForActivityResult model.extractValuesFromQRCode(scanned) 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 c2e309e93..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 @@ -26,8 +26,8 @@ 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) { 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 52b7ebb59..0e1a9c6e6 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 @@ -37,7 +37,6 @@ 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 @@ -45,6 +44,7 @@ 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 @@ -62,9 +62,10 @@ 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.app.android.util.ActivityProvider -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.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 @@ -169,6 +170,8 @@ fun MainView( ) { val currentContext = rememberUpdatedState(LocalContext.current) val taskCompletionBarViewModel = remember { TaskCompletionBarViewModel() } + val notificationCount = + MoreApplication.shared!!.notificationManager.unreadUserCount.collectAsStateWithLifecycle() MoreBackground( navigationTitle = navigationTitle, showBackButton = viewModel.showBackButton.value, @@ -184,7 +187,7 @@ fun MainView( 2 -> navController.navigate(NavigationScreen.INFO.routeWithParameters()) } }, - unreadNotificationCount = viewModel.unreadNotificationCount.intValue, + unreadNotificationCount = notificationCount.value, ) { NavHost( navController = navController, @@ -251,7 +254,7 @@ fun MainView( ) { val arguments = requireNotNull(it.arguments) val scheduleId by remember { - mutableStateOf(requireNotNull(arguments.getString("scheduleId"))) + mutableStateOf(requireNotNull(arguments.getString(NavigationRouteParameter.SCHEDULE_ID.key))) } viewModel.showBackButton.value = true @@ -270,7 +273,8 @@ fun MainView( ) { val arguments = requireNotNull(it.arguments) - val observationId = arguments.getString("observationId") + val observationId = + arguments.getString(NavigationRouteParameter.OBSERVATION_ID.key) viewModel.showBackButton.value = true val obsDetailsVM by remember { @@ -311,7 +315,7 @@ fun MainView( viewModel.schedulesViewModel( ScheduleListType.valueOf( requireNotNull(it.arguments).getString( - "scheduleListType", + NavigationRouteParameter.SCHEDULE_LIST_TYPE.key, "ALL" ) ) @@ -322,35 +326,29 @@ fun MainView( } } - 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.showBackButton.value = true - val viewModel by remember { - mutableStateOf( - viewModel.creteNewSimpleQuestionViewModel( - scheduleId, - observationId, - notificationId - ) - ) + val questionViewModel = remember(scheduleId, notificationId, observationId) { + QuestionViewModel(scheduleId, notificationId, observationId) } QuestionnaireView( navController, - viewModel + questionViewModel ) } } @@ -363,13 +361,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) { 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 c3b7c01ac..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,27 +18,21 @@ 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.schedule.ScheduleViewModel 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.studyDetails.observationDetails.ObservationDetailsViewModel -import io.redlink.more.more_app_mutliplatform.models.ScheduleListType -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 unreadNotificationCount = mutableIntStateOf(0) - val coreNotificationFilterViewModel = CoreNotificationFilterViewModel() val manualTasks: ScheduleViewModel = ScheduleViewModel( @@ -56,21 +50,9 @@ class MainViewModel(context: Context) : ViewModel() { ) } - private val simpleQuestionnaireViewModel by lazy { - QuestionnaireViewModel() - } - private var lastBleViewState = false init { - viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.unreadNotificationCount.collect { - withContext(Dispatchers.Main) { - unreadNotificationCount.intValue = it - } - } - } - viewModelScope.launch { ViewManager.bleViewActive.collect { if (it && !lastBleViewState) { @@ -98,7 +80,10 @@ 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) } } @@ -113,23 +98,6 @@ class MainViewModel(context: Context) : ViewModel() { } } - 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) - } - } - } - return simpleQuestionnaireViewModel - } - fun createObservationDetailView(observationId: String): ObservationDetailsViewModel { return ObservationDetailsViewModel(observationId) } 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 index edebcb283..e5613268e 100644 --- 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 @@ -10,17 +10,13 @@ */ package io.redlink.more.app.android.activities.qrScanner -import android.content.pm.PackageManager -import androidx.activity.ComponentActivity -import androidx.camera.view.PreviewView -import com.google.mlkit.vision.barcode.BarcodeScanning -import android.os.Bundle -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat 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 @@ -30,6 +26,7 @@ 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 @@ -59,13 +56,16 @@ 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.ui.theme.MoreColors -import io.redlink.more.app.android.ui.theme.moreSecondary +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 @@ -281,7 +281,8 @@ fun QrScannerScreen( if (!permissionGiven.value) { Box( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(vertical = 30.dp) .padding(horizontal = 30.dp), contentAlignment = Alignment.TopCenter 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 a1c19824e..10f11f61a 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 @@ -37,7 +37,7 @@ import io.redlink.more.app.android.activities.notification.composables.Notificat 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.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel +import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel @Composable fun NotificationView( 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 7414e1218..396ca4fd9 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 @@ -15,27 +15,27 @@ import androidx.lifecycle.ViewModel 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 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() { val coreViewModel: CoreNotificationViewModel = CoreNotificationViewModel( coreFilterViewModel, - MoreApplication.shared!!.notificationManager, - stringResource(R.string.app_scheme), - applicationId + MoreApplication.shared!!.notificationManager ) fun handleNotificationAction(notification: NotificationModel, navController: NavController) { coreViewModel.handleNotificationAction(notification) { actionType, data -> - when (actionType) { - NotificationActionHandler.DEEPLINK -> navController.navigate(data.toUri()) + data?.let { + when (actionType) { + NotificationActionHandler.DEEPLINK -> navController.navigate(data.route.toUri()) + else -> {} + } } } } 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 34cb82847..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 @@ -31,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) { 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 cb293d0f7..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 @@ -45,8 +45,9 @@ import io.redlink.more.app.android.extensions.getStringResource 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( @@ -106,8 +107,9 @@ fun NotificationItem( .fillMaxWidth() .defaultMinSize(minHeight = 50.dp) ) { - val annotatedNotificationModelBody = remember(notificationModel.notificationBody) { - notificationModel.notificationBody.trim().toAnnotatedString() + val localizedBody = remember(notificationModel.notificationBody) { + notificationModel.notificationBody.trim().localize() + .trim().toAnnotatedString() } Column( verticalArrangement = Arrangement.SpaceEvenly, @@ -115,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 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 844abe5e2..5e5fcfbcf 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 @@ -34,8 +34,8 @@ 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.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel @Composable fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { 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 b47f2e172..b44cc12a5 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,8 +13,8 @@ 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 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 d454eea33..e29e75c5e 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 @@ -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 android.app.Activity @@ -26,8 +37,8 @@ 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 @Composable fun ObservationErrorListView( 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 95f29d375..7413c0bf7 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 @@ -1,11 +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.app.android.activities.observationErrors import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.ObservationStates +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationStates import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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 index 44f249fb7..aae8131f1 100644 --- 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 @@ -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.observations.garmin import android.annotation.SuppressLint @@ -36,9 +47,9 @@ 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.ui.theme.MoreColors -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.models.AlertDialogModel +import io.redlink.more.viewModels.ViewManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch 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 index 47fb6da29..882391ef0 100644 --- 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 @@ -1,13 +1,24 @@ +/* + * 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.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.web.WebClientListener -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.garminConnectOAuth.CoreGarminConnectViewModel +import io.redlink.more.models.AlertDialogModel +import io.redlink.more.viewModels.garminConnectOAuth.CoreGarminConnectViewModel import kotlinx.coroutines.runBlocking class GarminConnectViewModel : WebClientListener { 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 632c81387..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,7 +15,6 @@ 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 @@ -33,11 +32,13 @@ 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 @@ -47,22 +48,23 @@ 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: 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() @@ -109,11 +111,6 @@ class LimeSurveyActivity : ComponentActivity() { } - override fun getOnBackInvokedDispatcher(): OnBackInvokedDispatcher { - viewModel.onFinish() - return super.getOnBackInvokedDispatcher() - } - 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" @@ -124,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() @@ -160,7 +159,7 @@ fun LimeSurveyView(viewModel: LimeSurveyViewModel, webView: WebView?) { } ) { 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 8462a0562..f8c3576db 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 @@ -15,24 +15,26 @@ import android.webkit.WebView import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.web.WebClientListener -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.models.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( +class LimeSurveyViewModel(scheduleId: String?, notificationId: String?, observationId: String?) : + ViewModel(), WebClientListener { + val coreViewModel = CoreLimeSurveyViewModel( MoreApplication.shared!!.repositories, - MoreApplication.shared!!.observationFactory + MoreApplication.shared!!.observationFactory, + scheduleId, + notificationId, + observationId ) - val limeSurveyLink = mutableStateOf(null) - val dataLoading = mutableStateOf(false) val wasAnswered = mutableStateOf(false) val networkLoading = mutableStateOf(false) val alertDialogOpen = mutableStateOf(null) @@ -45,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..a54ee7210 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/QuestionViewModel.kt @@ -0,0 +1,62 @@ +/* + * 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.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 e51dc1e61..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,14 +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( @@ -42,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 657386874..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,26 +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) { +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( @@ -60,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 cd957af41..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 @@ -15,43 +15,39 @@ 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) }, + selected = (selectedAnswer as? String) == item, + onClick = { onAnswerSelected(item) }, role = Role.RadioButton, indication = null, interactionSource = remember { MutableInteractionSource() } @@ -59,7 +55,7 @@ fun QuestionnaireRadioButtons(model: QuestionnaireViewModel) { .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 ae878e08e..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 @@ -33,7 +33,7 @@ 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) { 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..2f65f970a 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,42 +10,103 @@ */ 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.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()) + val route = backStackEntry?.arguments?.getString( + NavigationScreen.QUESTION.routeWithParameters() + ) + LaunchedEffect(route) { viewModel.viewDidAppear() } + DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } + onDispose { viewModel.viewDidDisappear() } + } + + var selectedAnswer by rememberSaveable(route, stateSaver = AnswerValueSaver) { + mutableStateOf(null) } + 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) + QuestionnaireQuestionAnswer( + model = viewModel, + selectedAnswer = selectedAnswer, + onAnswerSelected = { selectedAnswer = it } + ) } - QuestionnaireButtons(navController = navController, model = viewModel) + + 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)}!") } 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 043ed790c..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 androidx.lifecycle.viewModelScope -import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.viewModels.simpleQuestion.SimpleQuestionCoreViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class QuestionnaireViewModel : ViewModel() { - private val coreViewModel: SimpleQuestionCoreViewModel = SimpleQuestionCoreViewModel( - MoreApplication.shared!!.repositories, - MoreApplication.shared!!.observationFactory - ) - - val hasData = mutableStateOf(false) - val observationTitle = mutableStateOf("") - val question = mutableStateOf("") - var answers = mutableStateListOf("") - val answerSet = mutableStateOf("") - val observationParticipantInfo = mutableStateOf("") - - init { - viewModelScope.launch(Dispatchers.Main.immediate) { - 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..2461d8875 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observations/questionnaire/questionType/QuestionnaireCheckboxes.kt @@ -0,0 +1,93 @@ +/* + * 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.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/setting/SettingsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/setting/SettingsView.kt index 44385867f..c35de0d05 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 @@ -26,7 +26,7 @@ import io.redlink.more.app.android.R 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.theme.MoreColors @Composable fun SettingsView() { 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 09578b079..7a8ab4bcc 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,15 +14,16 @@ 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.entities.StudyEntity -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!!) + private var coreSettingsViewModel = + CoreSettingsViewModel(MoreApplication.shared!!) val study = mutableStateOf(null) val permissionModel = mutableStateOf(null) 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 98493f8ad..03d70fd65 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 @@ -36,7 +36,7 @@ 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( 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 7be289820..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 @@ -12,7 +12,7 @@ package io.redlink.more.app.android.activities.studyDetails import androidx.lifecycle.ViewModel import io.redlink.more.app.android.MoreApplication -import io.redlink.more.more_app_mutliplatform.viewModels.studydetails.CoreStudyDetailsViewModel +import io.redlink.more.viewModels.studydetails.CoreStudyDetailsViewModel class StudyDetailsViewModel : ViewModel() { val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!) 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 796a8b356..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 @@ -36,8 +36,8 @@ 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.entities.ObservationEntity +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.database.entities.ObservationEntity @Composable fun AccordionWithList( 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 6d5b5019d..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 @@ -20,7 +20,7 @@ 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 @@ -31,8 +31,9 @@ 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.entities.ObservationEntity +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) { @@ -50,7 +51,7 @@ fun ObservationList(navController: NavController, observations: List - activity.finish() - showNewActivityAndClearStack( - activity, - ContentActivity::class.java - ) - } - } - } - ) - ) + ReloadButton() + ExitButton() } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingView.kt index 9d7824580..167d64aaa 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyLoadingView.kt @@ -27,7 +27,7 @@ import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.MoreBackground 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 @Composable fun StudyLoadingView() { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyPausedView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyPausedView.kt index 3023e450e..a2e88a0e3 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyPausedView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyPausedView.kt @@ -11,14 +11,18 @@ package io.redlink.more.app.android.activities.studyStates 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.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.subcomponents.ExitButton +import io.redlink.more.app.android.activities.subcomponents.ReloadButton import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.MediumTitle import io.redlink.more.app.android.shared_composables.MoreBackground @@ -27,11 +31,34 @@ import io.redlink.more.app.android.shared_composables.Title @Composable fun StudyPausedView() { MoreBackground() { - Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) { - Title(text = "${getStringResource(id = R.string.study_paused)}!", textAlign = TextAlign.Center) - MediumTitle(text = "${getStringResource(id = R.string.study_paused_descr)}!", textAlign = TextAlign.Center) + + Box(modifier = Modifier.fillMaxSize()) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Title( + text = "${getStringResource(id = R.string.study_paused)}!", + textAlign = TextAlign.Center + ) + MediumTitle( + text = "${getStringResource(id = R.string.study_paused_descr)}!", + textAlign = TextAlign.Center + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomEnd), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ReloadButton() + ExitButton() + } } } + } @Preview diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyUpdateView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyUpdateView.kt index 2ee5a02ec..c863b1858 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyUpdateView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyStates/StudyUpdateView.kt @@ -28,7 +28,7 @@ import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.shared_composables.MediumTitle import io.redlink.more.app.android.shared_composables.MoreBackground 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 @Composable fun StudyUpdateView() { diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt new file mode 100644 index 000000000..4554f6043 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.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.app.android.activities.subcomponents + +import android.app.Activity +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.work.WorkManager +import io.redlink.more.AlertController +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.extensions.getStringResource +import io.redlink.more.app.android.extensions.showNewActivityAndClearStack +import io.redlink.more.app.android.shared_composables.SmallTextButton +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.moreImportant +import io.redlink.more.models.AlertDialogModel + +@Composable +fun ExitButton( +) { + val context = LocalContext.current + SmallTextButton( + text = getStringResource(id = R.string.more_settings_resign_confirm), + buttonColors = ButtonDefaults.moreImportant(), + borderStroke = MoreColors.borderImportant(), + modifier = Modifier + .padding(bottom = 24.dp) + ) { + AlertController.openAlertDialog( + AlertDialogModel( + title = context.getString(R.string.more_settings_withdraw_question_confirm), + message = context.getString(R.string.more_settings_withdraw_statement_long), + confirmLabel = context.getString(R.string.more_settings_resign_confirm), + cancelLabel = context.getString(R.string.more_settings_continue), + onConfirm = { + WorkManager.getInstance(context).cancelAllWork() + MoreApplication.shared!!.exitStudy { + (context as? Activity)?.let { activity -> + 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..6e6a8a6cb --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ReloadButton.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.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!!.updateStudyAsync() + 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 266af314c..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 @@ -15,8 +15,8 @@ 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.models.TaskCompletion -import io.redlink.more.more_app_mutliplatform.viewModels.taskCompletionBar.CoreTaskCompletionBarViewModel +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 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..897e8de08 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.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.activities.tasks + +import androidx.compose.runtime.Composable +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.getStringResource +import io.redlink.more.app.android.shared_composables.SmallTextButton +import io.redlink.more.models.ScheduleState +import io.redlink.more.navigation.model.NavigationRouteParameter +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 +) { + + if (QuestionType().matches(observationType)) { + SmallTextButton( + text = getStringResource(id = R.string.more_questionnaire_start), + enabled = scheduleState.active() + ) { + navController.navigate( + NavigationScreen.QUESTION.navigationRoute(NavigationRouteParameter.SCHEDULE_ID.key to scheduleId) + ) + } + } else if (LimeSurveyType().matches(observationType)) { + SmallTextButton( + text = getStringResource(id = R.string.more_limesurvey_start), + enabled = scheduleState.active() + ) { + navController.navigate( + NavigationScreen.LIMESURVEY.navigationRoute( + NavigationRouteParameter.SCHEDULE_ID.key to scheduleId + ) + ) + } + } else { + SmallTextButton( + text = if (scheduleState == ScheduleState.RUNNING) getStringResource( + id = R.string.more_observation_pause + ) else getStringResource( + id = R.string.more_observation_start + ), + enabled = scheduleState.active() && additionalEnableCondition + ) { + 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 6c3816156..bdac33b93 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 @@ -31,7 +31,6 @@ 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.observationErrors.ObservationErrorListView import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.jvmLocalDate @@ -40,16 +39,13 @@ 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( @@ -166,37 +162,17 @@ fun TaskDetailsView( ) if (!taskDetails.hidden) { - SmallTextButton( - text = if (taskDetails.state == ScheduleState.RUNNING) getStringResource( - id = R.string.more_observation_pause - ) - else if (taskDetails.observationType == SimpleQuestionType().observationType) getStringResource( - id = R.string.more_questionnaire_start - ) - else if (taskDetails.observationType == LimeSurveyType().observationType) getStringResource( - id = R.string.more_limesurvey_start - ) - else getStringResource( - id = R.string.more_observation_start - ), - enabled = taskDetails.state.active() && if (taskDetails.observationType == PolarVerityHeartRateType( + ObservationActionButton( + navController, + taskDetails.scheduleId, + taskDetails.observationType, + taskDetails.state, + if (taskDetails.observationType == PolarVerityHeartRateType( emptySet() ).observationType ) viewModel.polarHrReady.value else true ) { - if (taskDetails.observationType == SimpleQuestionType().observationType) { - navController.navigate( - NavigationScreen.SIMPLE_QUESTION.navigationRoute( - "scheduleId" to scheduleId - ) - ) - } else if (taskDetails.observationType == LimeSurveyType().observationType) { - navController.navigate( - NavigationScreen.LIMESURVEY.navigationRoute( - "scheduleId" to scheduleId - ) - ) - } else if (taskDetails.state == ScheduleState.RUNNING) { + if (taskDetails.state == ScheduleState.RUNNING) { viewModel.pauseObservation() } else { viewModel.startObservation() 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 6cb89eeb7..0d555765a 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 @@ -14,10 +14,10 @@ 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.observations.DataRecorder -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.services.bluetooth.polar.PolarStates -import io.redlink.more.more_app_mutliplatform.viewModels.tasks.CoreTaskDetailsViewModel +import io.redlink.more.observations.DataRecorder +import io.redlink.more.observations.ObservationFactory +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 diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt index a3a4e47b0..06667fd41 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.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.web import android.graphics.Bitmap diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt index 4d7027788..4959272de 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClientListener.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.web import android.webkit.WebResourceRequest 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 c87d912e3..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,25 +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 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 { - MoreApplication.shared!!.repositories.notification.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() } } } @@ -41,5 +107,12 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { companion object { 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/ResourceExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceExtension.kt index 014d799d8..126d9be36 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,23 @@ package io.redlink.more.app.android.extensions import android.content.Context +import android.os.Build import android.provider.Settings 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() = android.os.Build.PRODUCT +fun getProductName() = Build.PRODUCT 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..f43b84c54 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 @@ -11,10 +11,10 @@ package io.redlink.more.app.android.extensions import io.redlink.more.app.android.R -import io.redlink.more.more_app_mutliplatform.models.DateFilterModel +import io.redlink.more.models.DateFilterModel 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) @@ -22,13 +22,3 @@ 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" - } -} \ 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..12a6d87f8 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) 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 6f370f383..67567b18a 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.entities.NotificationEntity +import io.redlink.more.database.entities.NotificationEntity import java.util.UUID /* 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 6c3a8b77f..f3d2fe574 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 @@ -22,9 +22,9 @@ 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.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManager -import io.redlink.more.more_app_mutliplatform.scopes.Scope +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 @@ -42,12 +42,11 @@ class AndroidObservationDataManager(context: Context, repository: MainRepository private val workerConstraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) .build() - override fun sendData(onCompletion: (Boolean) -> Unit) { + override fun sendData(immediately: Boolean, onCompletion: (Boolean) -> Unit) { Scope.launch { - if (workManager != null) { + if (!immediately && workManager != null) { onCompletion(tryWorkManagerThenFallback()) } else { Napier.w { "WorkManager not available, falling back to direct upload..." } 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 52c4ef668..9af5138d1 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,10 +18,10 @@ 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.database.repository.MainRepository -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.scopes.Scope +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.ObservationDataManager +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.scopes.Scope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext 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 b9501fe75..c795619d9 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 @@ -22,9 +22,9 @@ 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.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.GPSType +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.GPSType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -93,7 +93,7 @@ class GPSObservation( settings[LOCATION_INTERVAL_MILLIS_KEY]?.toString()?.trim('\"')?.toLong()?.let { //gpsService.setIntervalMillis(it) } - } catch (e: java.lang.Exception) { + } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } } 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..173feba18 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_BALANCED_POWER_ACCURACY, 1000) private val locationCallback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { super.onLocationResult(result) @@ -90,7 +98,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/PolarHeartRateObservation.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/HR/PolarHeartRateObservation.kt index c00f7dc77..22c7b77cf 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 @@ -23,14 +23,14 @@ 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.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.extensions.anyNameIn -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.scopes.Scope -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement -import io.redlink.more.more_app_mutliplatform.services.bluetooth.polar.PolarStates -import io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection.PolarController +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 private val permissions = @@ -77,7 +77,7 @@ class PolarHeartRateObservation(repos: MainRepository) : if (hrReady) { MoreApplication.shared!!.observationManager.updateTaskStates() } else { - Observation.pauseObservation( + pauseObservation( super.observationType ) } @@ -156,7 +156,10 @@ class PolarHeartRateObservation(repos: MainRepository) : 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) @@ -198,7 +201,7 @@ class PolarHeartRateObservation(repos: MainRepository) : if (!deviceIdentifier.anyNameIn(devices)) { pauseObservation(PolarVerityHeartRateType(emptySet())) PolarStates.hrFeatureReady(false) - Napier.d(tag = "PolarHeartRateObservation::Companion::listenToDeviceConnection") { "HR Feature removed!" } + Napier.d(tag = "PolarHeartRateObservation:::listenToDeviceConnection") { "HR Feature removed!" } } } }.second 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 3b6f83069..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 @@ -16,7 +16,7 @@ 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.BluetoothStateManagement +import io.redlink.more.services.bluetooth.BluetoothStateManagement import java.util.UUID class PolarObserverCallback : PolarBleApiCallback() { 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 2f55edc5f..d1d39ee18 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,12 +1,23 @@ +/* + * 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 io.redlink.more.AlertController 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.models.AlertDialogModel +import io.redlink.more.observations.Observation +import io.redlink.more.observations.observationTypes.ObservationType fun Observation.showPermissionAlertDialog() { AlertController.openAlertDialog( @@ -21,7 +32,7 @@ fun Observation.showPermissionAlertDialog() { )) } -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 index b2a354d0b..361b0e324 100644 --- 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 @@ -1,10 +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 + * 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.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.ObservationManager -import io.redlink.more.more_app_mutliplatform.scopes.Scope +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationManager +import io.redlink.more.scopes.Scope import kotlinx.coroutines.Dispatchers /** 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 index 638d84738..d93789cb2 100644 --- 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 @@ -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.observations import android.app.Activity @@ -13,7 +24,8 @@ fun startObservationsWithPermissionCheck( scheduleIds: Set, activity: Activity ) { - val observations = MoreApplication.shared?.observationFactory?.observations ?: emptySet() + val observations = + MoreApplication.shared?.observationFactory?.observations ?: emptySet() if (observations.isEmpty()) { 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 index 3be145e28..c81bbbe55 100644 --- 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 @@ -1,11 +1,25 @@ +/* + * 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.more_app_mutliplatform.observations.Observation +import io.redlink.more.observations.Observation /** * Utility class for handling permissions for observations @@ -19,16 +33,16 @@ object PermissionUtils { private var pendingPermissions: Set? = null private val permissionLaunchers = - mutableMapOf>>() + 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: androidx.activity.ComponentActivity) { + fun initializePermissionLauncher(activity: ComponentActivity) { val permissionLauncher = activity.registerForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions() + ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> val allGranted = permissions.values.all { it } pendingCallback?.invoke(allGranted) 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 448f57ccb..4fede2b4a 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 @@ -18,10 +18,10 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Build import android.util.Log -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -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.scopes.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" 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 d07e233e9..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.entities.NotificationEntity -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: NotificationEntity) { + 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,27 @@ 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()) } @@ -110,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 e8029fd53..a0c6e5b0e 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 @@ -17,7 +17,6 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service -import android.content.Context import android.content.Intent import android.os.Handler import android.os.IBinder @@ -30,11 +29,10 @@ import io.redlink.more.app.android.activities.ContentActivity 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.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.observations.ObservationManager -import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager +import io.redlink.more.observations.ObservationFactory +import io.redlink.more.observations.ObservationManager +import io.redlink.more.scopes.Scope +import io.redlink.more.viewModels.ViewManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -62,7 +60,7 @@ class ObservationRecordingService : Service() { Napier.e("Failed to start foreground service: ${e.message}") try { val basicNotification = Notification.Builder(this, "default") - .setContentTitle("More Observation Service") + .setContentTitle("${MoreApplication.appName} Observation Service") .setContentText("Service is running") .setSmallIcon(android.R.drawable.ic_dialog_info) .build() @@ -118,7 +116,7 @@ class ObservationRecordingService : Service() { SERVICE_RECEIVER_RESTART_ALL_STATES -> { restartAll() - return START_REDELIVER_INTENT + START_REDELIVER_INTENT } else -> { @@ -144,7 +142,7 @@ class ObservationRecordingService : Service() { ) val alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + applicationContext.getSystemService(ALARM_SERVICE) as? AlarmManager alarmManager?.let { try { it.set( @@ -188,7 +186,7 @@ class ObservationRecordingService : Service() { ) val alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + applicationContext.getSystemService(ALARM_SERVICE) as? AlarmManager alarmManager?.let { try { it.set( @@ -231,7 +229,7 @@ class ObservationRecordingService : Service() { ) val alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + applicationContext.getSystemService(ALARM_SERVICE) as? AlarmManager alarmManager?.let { try { it.set( @@ -314,8 +312,11 @@ class ObservationRecordingService : Service() { private fun stopObservation(scheduleId: String) { observationManager?.stop(scheduleId) runningSchedules.remove(scheduleId) - StudyScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.repositories.schedule.setCompletionStateFor(scheduleId, true) + Scope.launch { + MoreApplication.shared!!.repositories.schedule.setCompletionStateFor( + scheduleId, + true + ) } if (observationManager?.hasRunningTasks() == false) { stopService() @@ -357,7 +358,7 @@ class ObservationRecordingService : Service() { ) val alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager alarmManager.set( AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, @@ -418,16 +419,10 @@ class ObservationRecordingService : Service() { private fun startForegroundService() { Napier.d { "Starting the foreground service..." } try { - val channelId = try { - getString(R.string.default_channel_id) - } catch (e: Exception) { - "default" - } - val notificationTitle = try { getString(R.string.more_observation_running) } catch (e: Exception) { - "More Observation Service" + "${MoreApplication.appName} Observation Service" } val notificationText = try { @@ -437,7 +432,7 @@ class ObservationRecordingService : Service() { } val notification = buildNotification( - channelId = channelId, + channelId = MoreApplication.DEFAULT_CHANNEL_ID!!, notificationTitle = notificationTitle, notificationText = notificationText ) @@ -461,7 +456,7 @@ class ObservationRecordingService : Service() { channelId, NotificationManager.IMPORTANCE_LOW ).apply { - description = "More observation service notifications" + description = "${MoreApplication.appName} observation service notifications" enableLights(false) enableVibration(false) } @@ -490,7 +485,7 @@ class ObservationRecordingService : Service() { } catch (e: Exception) { Napier.e("Failed to build notification: ${e.message}") return Notification.Builder(applicationContext, "default") - .setContentTitle("More Service") + .setContentTitle("${MoreApplication.appName} Service") .setContentText("Running") .setSmallIcon(android.R.drawable.ic_dialog_info) .setOngoing(true) @@ -500,15 +495,16 @@ class ObservationRecordingService : Service() { 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 @@ -542,7 +538,8 @@ class ObservationRecordingService : Service() { activity: Activity ) { val observations = - MoreApplication.shared?.observationFactory?.observations ?: emptySet() + MoreApplication.shared?.observationFactory?.observations + ?: emptySet() if (observations.isEmpty()) { startWithoutPermissionCheck(scheduleIds) @@ -585,31 +582,41 @@ class ObservationRecordingService : Service() { * @param scheduleIds The schedule IDs to start */ private fun startWithoutPermissionCheck(scheduleIds: Set) { - Scope.launch(Dispatchers.IO) { - if (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) - } + 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()) } + } 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) { if (ViewManager.appInForeground.value) { val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) serviceIntent.action = SERVICE_RECEIVER_PAUSE_ACTION serviceIntent.putExtra(SCHEDULE_ID, scheduleId) MoreApplication.appContext?.startService(serviceIntent) @@ -621,7 +628,10 @@ class ObservationRecordingService : Service() { fun stop(scheduleId: String) { if (ViewManager.appInForeground.value) { val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) serviceIntent.action = SERVICE_RECEIVER_STOP_ACTION serviceIntent.putExtra(SCHEDULE_ID, scheduleId) MoreApplication.appContext?.startService(serviceIntent) @@ -633,7 +643,10 @@ class ObservationRecordingService : Service() { fun stopAll() { if (ViewManager.appInForeground.value) { val serviceIntent = - Intent(MoreApplication.appContext, ObservationRecordingService::class.java) + Intent( + MoreApplication.appContext, + ObservationRecordingService::class.java + ) serviceIntent.action = SERVICE_RECEIVER_STOP_ALL_ACTION MoreApplication.appContext?.startService(serviceIntent) } else { @@ -643,11 +656,16 @@ class ObservationRecordingService : Service() { 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()) 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 4c58c0b00..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 @@ -21,12 +21,12 @@ 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.PolarObserverCallback -import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity -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.BluetoothState -import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothStateManagement -import io.redlink.more.more_app_mutliplatform.services.bluetooth.polar.PolarStates +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() @@ -119,7 +119,7 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe Napier.i(tag = "PolarConnector::onPolarFeatureReady") { "HR ready!" } PolarStates.hrFeatureReady(true) - Napier.d(tag = "PolarHeartRateObservation::Companion::setHRFeature") { "HR Feature Ready!" } + Napier.d(tag = "PolarHeartRateObservation:::setHRFeature") { "HR Feature Ready!" } } } 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 c1fc01341..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.scopes.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 d24c8bf61..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.scopes.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 470abed48..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 @@ -42,7 +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( 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 75e938522..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 @@ -40,7 +40,7 @@ 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) { 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 f588e0dd6..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,7 +26,7 @@ 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( 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 740f55e5e..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,7 +25,7 @@ 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() { 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 fd9dcfb14..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,8 +27,8 @@ 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?) { 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 c79fb60fa..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,7 +15,7 @@ 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) { 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 b48400d86..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,7 +18,7 @@ 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( 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 18f2b3be5..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,7 +17,7 @@ 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( 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 18ed5aeb7..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,7 +16,7 @@ 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( 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 54e5b17f0..6eed7d5bf 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 @@ -19,8 +19,8 @@ import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier 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.models.AlertDialogModel @Composable fun MessageAlertDialog(model: AlertDialogModel) { 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 9033701ff..684391361 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 @@ -39,11 +39,11 @@ 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.AlertController 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.AlertController +import io.redlink.more.app.android.theme.MoreColors +import io.redlink.more.app.android.theme.MorePlatformTheme @Composable fun MoreBackground( 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 8e6f17b39..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,7 +16,7 @@ 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( 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 dfb65c2e5..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 @@ -31,6 +31,7 @@ import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewMod 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) { @@ -77,8 +78,8 @@ fun ScheduleList(viewModel: ScheduleViewModel, navController: NavController, sho ) { navController.navigate( NavigationScreen.SCHEDULE_DETAILS.navigationRoute( - "scheduleId" to scheduleModel.scheduleId, - "scheduleListType" to viewModel.scheduleListType + NavigationRouteParameter.SCHEDULE_ID.key to scheduleModel.scheduleId, + NavigationRouteParameter.SCHEDULE_LIST_TYPE.key to viewModel.scheduleListType ) ) } 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 6bce4e584..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 @@ -34,8 +34,8 @@ import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewMod 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( 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 a8c28fb9d..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,7 +22,7 @@ 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 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 7d07bcfe3..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,7 +22,7 @@ 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 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 715b1a919..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,7 +18,7 @@ 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( 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 98% 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 a061ad7dd..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,7 +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 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 4820daab7..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,7 +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 7f04434a4..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,7 +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 index f02c48ced..fdd094dcb 100644 --- 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 @@ -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.util import android.app.Activity 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..3432da90e --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/util/AlarmUtils.kt @@ -0,0 +1,138 @@ +/* + * 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.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 e94443d27..cfa1f0d4a 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,11 +16,11 @@ 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.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.NetworkService +import io.redlink.more.services.network.openapi.model.DataBulk +import io.redlink.more.services.store.CredentialRepository +import io.redlink.more.services.store.EndpointRepository +import io.redlink.more.services.store.SharedPreferencesRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -36,10 +36,13 @@ class DataUploadWorker( 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 - ) + MoreApplication.shared?.credentialRepository ?: CredentialRepository( + sharedPreferences + ) + private val networkService = + MoreApplication.shared?.networkService ?: NetworkService( + EndpointRepository(sharedPreferences), credentialRepository + ) private var stopped = false override suspend fun doWork(): Result = withContext(Dispatchers.IO) { @@ -83,7 +86,9 @@ class DataUploadWorker( Result.retry() } else { Napier.i { "Deleting observation data..." } - MoreApplication.shared!!.repositories.observationData.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 6afd06290..4d3c907ed 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,8 +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.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 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 2a04ac1c0..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,8 +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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -33,10 +33,7 @@ class ScheduleUpdateWorker(context: Context, workerParameters: WorkerParameters) override suspend fun doWork() = withContext(Dispatchers.IO) { Napier.i { "Running $WORKER_TAG! Updating Schedule..." } - shared.repositories.schedule.updateTaskStates( - shared.observationFactory, - shared.dataRecorder - ) + shared.updateSchedules() return@withContext Result.success() } 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/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/navigation-strings.xml b/androidApp/src/main/res/values/navigation-strings.xml index 161c1fdc3..d917b4f3f 100644 --- a/androidApp/src/main/res/values/navigation-strings.xml +++ b/androidApp/src/main/res/values/navigation-strings.xml @@ -9,7 +9,7 @@ Study Details Observation Filter Notification Filter - Simple Question + Simple Question Running Observations Past Observations Leave Study 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/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/build.gradle.kts b/build.gradle.kts index 41fe70ec9..0e9fb93e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ buildscript { dependencies { - classpath("com.google.gms:google-services:4.4.3") + classpath("com.google.gms:google-services:4.4.4") classpath("com.google.firebase:firebase-crashlytics-gradle:3.0.6") } repositories { @@ -10,16 +10,17 @@ buildscript { } plugins { - id("com.android.application").version("8.13.0").apply(false) - id("com.android.library").version("8.13.0").apply(false) - kotlin("android").version("2.2.10").apply(false) - kotlin("multiplatform").version("2.2.10").apply(false) - kotlin("plugin.serialization").version("2.2.10").apply(false) - id("org.jetbrains.kotlin.plugin.compose").version("2.2.10").apply(false) - id("androidx.room").version("2.7.2").apply(false) - id("com.google.devtools.ksp").version("2.2.10-2.0.2").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.0-ALPHA-47").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) } 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/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.lock b/iosApp/Gemfile.lock index 56fc04f3b..57a65b578 100644 --- a/iosApp/Gemfile.lock +++ b/iosApp/Gemfile.lock @@ -1,34 +1,33 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml + 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.3.2) - aws-partitions (1.1107.0) - aws-sdk-core (3.224.0) + 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.101.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (1.117.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.186.1) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.204.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + base64 (0.3.0) + bigdecimal (3.3.1) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -53,14 +52,14 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) + faraday-multipart (1.1.1) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) @@ -70,7 +69,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.227.2) + fastlane (2.228.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -157,37 +156,36 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.12.2) - jwt (2.10.1) + 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.15.0) + multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) - naturally (2.2.1) - nkf (0.2.0) - optparse (0.6.0) + naturally (2.3.0) + optparse (0.8.0) os (1.1.4) plist (3.7.2) public_suffix (6.0.2) - rake (13.2.1) + 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.1) + rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.20.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList @@ -224,4 +222,4 @@ DEPENDENCIES fastlane BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile index e4e5bb2a9..56e124eee 100644 --- a/iosApp/fastlane/Fastfile +++ b/iosApp/fastlane/Fastfile @@ -12,21 +12,36 @@ # Uncomment the line if you want fastlane to automatically update itself if ENV["CI"] - update_fastlane + update_fastlane end default_platform(:ios) platform :ios do - before_all do - create_keychain( - name: "temp_keychain", - password: ENV["FASTLANE_KEYCHAIN_PASSWORD"], - default_keychain: true, - unlock: true, - timeout: 3600, - lock_when_sleeps: false - ) + def setup_google_services + 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'], @@ -38,14 +53,20 @@ platform :ios do 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 + 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 + 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 desc "Bump build number and version to FASTLANE_BUILD_NUMBER" lane :increment_build do @@ -57,45 +78,46 @@ platform :ios do 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 - - build_app( - scheme: ENV["FASTLANE_IOS_SCHEME"], - clean: true, - output_directory: "build", - export_method: "app-store", - export_team_id: ENV["FASTLANE_TEAM_ID"] + 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 + + 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" @@ -115,6 +137,24 @@ platform :ios do 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 diff --git a/iosApp/fastlane/README.md b/iosApp/fastlane/README.md index 59301b00d..4cb1fb99f 100644 --- a/iosApp/fastlane/README.md +++ b/iosApp/fastlane/README.md @@ -39,6 +39,14 @@ Build the app for App Store 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. diff --git a/iosApp/fastlane/report.xml b/iosApp/fastlane/report.xml deleted file mode 100644 index 01f043458..000000000 --- a/iosApp/fastlane/report.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 841ed17ac..199389b93 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -24,6 +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 */; }; + 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 */; }; @@ -59,8 +60,10 @@ 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 */; }; + 1F45E5B92F2766AB00EE8487 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1F45E5B82F2766AB00EE8487 /* GoogleService-Info.plist */; }; + 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 */; }; @@ -103,6 +106,8 @@ 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 */; }; + 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 */; }; 1F9DB1A0298CF44000DBB7DB /* MoreColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */; }; @@ -125,6 +130,9 @@ 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 */; }; @@ -137,8 +145,8 @@ 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 */; }; @@ -163,7 +171,7 @@ 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 */; }; - 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 */; }; @@ -235,6 +243,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 = ""; }; + 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 = ""; }; @@ -266,8 +275,10 @@ 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 = ""; }; + 1F45E5B82F2766AB00EE8487 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 = ""; }; @@ -311,6 +322,8 @@ 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 = ""; }; + 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 = ""; }; 1F9DB19F298CF44000DBB7DB /* MoreColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreColor.swift; sourceTree = ""; }; @@ -323,7 +336,7 @@ 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 = ""; }; + 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 = ""; }; @@ -332,6 +345,9 @@ 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; }; @@ -346,8 +362,8 @@ 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 = ""; }; @@ -372,7 +388,7 @@ 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 = ""; }; - ED474B4B29FAAC7A0077AD5C /* SimpleQuestionThankYouView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQuestionThankYouView.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 = ""; }; @@ -467,6 +483,8 @@ 1F27536929CC53FE00324417 /* BackgroundTasks */ = { isa = PBXGroup; children = ( + 1F0A11C72F333C0F00EAE237 /* ObservationReminderBackgroundTask.swift */, + 1F988B3E2F2BA0CA0094F99F /* DailyBackgroundTask.swift */, 1F0026DC29CC8F710034EF65 /* BackgroundTaskHandler.swift */, 1FC4F87329D2B86100F65026 /* DataUploadBackgroundTask.swift */, ); @@ -592,6 +610,9 @@ 1F6A4E3829F6C7C000F0247F /* EmptyListView.swift */, 1FF5B28A2A8274C10076EF8E /* AppVersion.swift */, 1FDFB9842B62A81000CB87B1 /* MoreAlertDialog.swift */, + 1F45E5BA2F288D7500EE8487 /* ExitButton.swift */, + 1F45E5BC2F288DD600EE8487 /* ReloadButton.swift */, + 1FF8D2952F48687800C57A01 /* CheckboxField.swift */, ); path = Components; sourceTree = ""; @@ -672,6 +693,14 @@ path = LimeSurvey; sourceTree = ""; }; + 1F988B3B2F2B93BF0094F99F /* Utils */ = { + isa = PBXGroup; + children = ( + 1F988B3C2F2B93E60094F99F /* Napier.swift */, + ); + path = Utils; + sourceTree = ""; + }; 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( @@ -745,21 +774,30 @@ 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 */, ); @@ -778,7 +816,6 @@ isa = PBXGroup; children = ( 1F9283C02BC512E500D459A7 /* PrivacyInfo.xcprivacy */, - 1F5F842329E67B370010C2D2 /* GoogleService-Info.plist */, EDAC149529DEF2010084CE1C /* iosApp.entitlements */, 1F8847C22991517F0023EF10 /* Resources */, 1F27536929CC53FE00324417 /* BackgroundTasks */, @@ -787,6 +824,7 @@ 1F8847CB299154650023EF10 /* Extensions */, 1F9DB1A1298CF57600DBB7DB /* Style */, 1F9C3E8B298AAC0000B9AC82 /* Views */, + 1F988B3B2F2B93BF0094F99F /* Utils */, 7555FF82242A565900829871 /* ContentView.swift */, 1F13BA4E2993A2A600938C1E /* ContentViewModel.swift */, 7555FF8C242A565B00829871 /* Info.plist */, @@ -795,6 +833,7 @@ 1F0026DA29CC8E730034EF65 /* AppDelegate.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */, + 1F45E5B82F2766AB00EE8487 /* GoogleService-Info.plist */, ); path = iosApp; sourceTree = ""; @@ -809,9 +848,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 = ""; @@ -970,6 +1010,7 @@ 7555FFB4242A642300829871 /* Embed Frameworks */, 1FC957532C072B7900EB92D6 /* Embed Foundation Extensions */, 1F8E9DB22A80D31D006E4AF4 /* ShellScript */, + 1F988B402F30941C0094F99F /* ShellScript */, ); buildRules = ( ); @@ -1046,10 +1087,10 @@ buildActionMask = 2147483647; files = ( 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */, + 1F45E5B92F2766AB00EE8487 /* GoogleService-Info.plist in Resources */, 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 1F9DB1A3298D022E00DBB7DB /* MoreImages.xcassets in Resources */, 1FF6D8AD2BC516270050AF10 /* PrivacyInfo.xcprivacy in Resources */, - 1F5F842429E67B370010C2D2 /* GoogleService-Info.plist in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, 1F6C31532A13EB7F00EED533 /* Launch Screen.storyboard in Resources */, 1F1E45DC2E795A5900C82016 /* Localizable.xcstrings in Resources */, @@ -1081,6 +1122,23 @@ 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"; }; + 1F988B402F30941C0094F99F /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + 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; buildActionMask = 2147483647; @@ -1122,12 +1180,14 @@ 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 */, @@ -1141,6 +1201,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 */, @@ -1159,7 +1220,7 @@ 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 */, 1FDC264C29C1D1660011D8A4 /* InfoList.swift in Sources */, B784169229C9E3F50035D830 /* Title2.swift in Sources */, @@ -1167,6 +1228,7 @@ 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 */, @@ -1182,11 +1244,13 @@ 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 */, 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 */, @@ -1205,12 +1269,13 @@ 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 */, @@ -1220,6 +1285,7 @@ 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 */, 1F8EA2CF2A0CC22200F32602 /* LimeSurveyView.swift in Sources */, @@ -1237,7 +1303,7 @@ 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 */, @@ -1289,18 +1355,18 @@ 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 = 4.0.26; + 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 = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -1330,17 +1396,17 @@ 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 = 4.0.26; + 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 = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -1508,14 +1574,14 @@ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = More; + INFOPLIST_KEY_CFBundleDisplayName = PraeCura; 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.26; + MARKETING_VERSION = 0.0.11; OTHER_LDFLAGS = ( "$(inherited)", "-framework", @@ -1552,14 +1618,14 @@ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = More; + INFOPLIST_KEY_CFBundleDisplayName = PraeCura; 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.26; + MARKETING_VERSION = 0.0.11; OTHER_LDFLAGS = ( "$(inherited)", "-framework", diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index 7f7638a87..b183f5982 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -18,11 +18,12 @@ import FirebaseCore import FirebaseCrashlyticsSwift import FirebaseMessaging import Foundation -import shared 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 database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) static let repositories = MainRepository(appDatabase: database) @@ -39,7 +40,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { observationDataManager: dataManager, mainBluetoothConnector: polarConnector, observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager), - dataRecorder: IOSDataRecorder() + dataRecorder: IOSDataRecorder(), + reminderNotificationSchedulingLimit: 30 ) }() @@ -47,7 +49,7 @@ 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 FirebaseApp.configure() @@ -56,8 +58,14 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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 } @@ -80,10 +88,14 @@ 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() { diff --git a/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift new file mode 100644 index 000000000..2811af120 --- /dev/null +++ b/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift @@ -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/). + */ + +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 3c189e525..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: @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: @preconcurrency 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: @preconcurrency BackgroundTaskHandler { } } } + diff --git a/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift new file mode 100644 index 000000000..a523c3c46 --- /dev/null +++ b/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift @@ -0,0 +1,73 @@ +/* + * 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 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/ContentViewModel.swift b/iosApp/iosApp/ContentViewModel.swift index 735e4bfd2..f7e85baae 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -31,9 +31,6 @@ class ContentViewModel: ObservableObject { @Published var alertDialogModel: AlertDialogModel? = nil @Published var unreadNotificationCount: Int = 0 - lazy var simpleQuestionVM = SimpleQuestionObservationViewModel() - lazy var limeSurveyVM = LimeSurveyViewModel() - let manualSchedule = ScheduleViewModel(scheduleListType: .manuals) lazy var runningViewModel = ScheduleViewModel(scheduleListType: .running) lazy var completedViewModel = ScheduleViewModel(scheduleListType: .completed) @@ -86,20 +83,13 @@ class ContentViewModel: ObservableObject { self?.alertDialogModel = alertDialogModel } .store(in: &cancellables) - - AppDelegate.shared.unreadNotificationCountAsClosure { [weak self] kInt in - self?.unreadNotificationCount = kInt.intValue - } - } - - func getSimpleQuestionObservationVM(navigationState: NavigationState) -> SimpleQuestionObservationViewModel { - simpleQuestionVM.setScheduleId(navigationState: navigationState) - return simpleQuestionVM - } - - func getLimeSurveyVM(navigationModalState: NavigationModalState) -> LimeSurveyViewModel { - limeSurveyVM.setNavigationModalState(navigationModalState: navigationModalState) - return limeSurveyVM + + 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() { diff --git a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift index 5cdb578f5..5e562e5ff 100644 --- a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift +++ b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift @@ -19,6 +19,6 @@ import shared extension BluetoothDeviceEntity { static func fromPolarDevice(polarInfo: PolarDeviceInfo) -> BluetoothDeviceEntity { - BluetoothDeviceEntity.Companion().create(deviceId: polarInfo.deviceId, deviceName: polarInfo.name, address: polarInfo.address.uuidString) + BluetoothDeviceEntity.companion.create(deviceId: polarInfo.deviceId, deviceName: polarInfo.name, address: polarInfo.address.uuidString) } } diff --git a/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift b/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift index 87a7510f9..dbcea549d 100644 --- a/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift +++ b/iosApp/iosApp/Extensions/CMSensorDataListExtension.swift @@ -16,7 +16,7 @@ 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/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index 25816bd0e..cba5d8fbb 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -14,20 +14,15 @@ // 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 } @@ -55,65 +50,109 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { 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: .dashboard) 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: "/garmin-connect", parameters: [], fullScreen: true) + return NavigationScreenValues(screenName: "Garmin Connect", navigationLink: .garminConnect, parameters: [], fullScreen: true) } } - static let PARAM_OBSERVATION_ID = "observationId" - static let PARAM_NOTIFICATION_ID = "notificationId" } extension NavigationScreen { + 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 { return values.screenName } - 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/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 410274514..ca1439d27 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.22 + 1.0.0 CFBundleURLTypes @@ -31,12 +33,12 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes - more-app + umm-app CFBundleVersion - 4.0.26 + 1.0.0 FirebaseAppDelegateProxyEnabled ITSAppUsesNonExemptEncryption @@ -93,5 +95,10 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + CFBundleLocalizations + + en + de + diff --git a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift index d6e41be18..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 @@ -71,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/iOSObservationDataManager.swift b/iosApp/iosApp/Observations/iOSObservationDataManager.swift index abf3f411d..efb4ae62f 100644 --- a/iosApp/iosApp/Observations/iOSObservationDataManager.swift +++ b/iosApp/iosApp/Observations/iOSObservationDataManager.swift @@ -17,7 +17,7 @@ import Foundation import shared class iOSObservationDataManager: ObservationDataManager { - override func sendData(onCompletion: @escaping (KotlinBoolean) -> Void) { + override func sendData(immediately: Bool = false, 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 index ff4b9d843..24f0e0fee 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -1670,6 +1670,17 @@ } } }, + "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", 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/Services/Bluetooth/IOSBluetoothConnector.swift b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift index 5b669b0e9..6bd9052a7 100644 --- a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift @@ -168,16 +168,20 @@ class IOSBluetoothConnector: NSObject, 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 { + 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: BluetoothDeviceEntity) -> Bool { - if let connector = specificBluetoothConnectors - .first(where: { device.deviceName?.lowercased().contains(($0.key as? String)?.lowercased() ?? "") ?? false })?.value as? BluetoothConnector { + 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 } @@ -250,7 +254,7 @@ extension IOSBluetoothConnector: CBPeripheralDelegate { extension CBPeripheral { func toBluetoothDevice() -> BluetoothDeviceEntity { - BluetoothDeviceEntity.Companion().create( + BluetoothDeviceEntity.companion.create( deviceId: identifier.uuidString, deviceName: name ?? "Unknown", address: identifier.uuidString diff --git a/iosApp/iosApp/Services/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift index ce4688057..3f6c37886 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -42,22 +42,38 @@ extension FCMService: UNUserNotificationCenterDelegate { 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, 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] } @MainActor func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - ViewManager.shared.appIsInForeground(state: true) - 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) } } } @@ -74,3 +90,4 @@ extension Dictionary where Key == AnyHashable { return data } } + diff --git a/iosApp/iosApp/Services/LocalPushNotificationService.swift b/iosApp/iosApp/Services/LocalPushNotificationService.swift index 66e9e5b15..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,41 +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: NotificationEntity) { + 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 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 { + 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) { - AppDelegate.appGroupUserDefaults?.set(Int(count), forKey: LocalPushNotifications.notificationCountKey) + setAppGroupNotificiationCount(Int(count)) UNUserNotificationCenter.current().setBadgeCount(Int(count)) { error in - print(error ?? "Error setting badge count") + 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 { + Napier.e("Error adding notification\(identifier): \(error)") + } else { + 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 { - print("Error adding notification: \(error)") + Napier.e("Error adding scheduled notification \(identifier): \(error)") } else { - print("Local Notification requested!") + 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/Utils/Napier.swift b/iosApp/iosApp/Utils/Napier.swift new file mode 100644 index 000000000..af8f6be4d --- /dev/null +++ b/iosApp/iosApp/Utils/Napier.swift @@ -0,0 +1,44 @@ +// +// 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) + } +} 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/ExitButton.swift b/iosApp/iosApp/Views/Components/ExitButton.swift new file mode 100644 index 000000000..f470072bb --- /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( + 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/ModuleListItem.swift b/iosApp/iosApp/Views/Components/ModuleListItem.swift index 9bc6b6cba..dc3b41c65 100644 --- a/iosApp/iosApp/Views/Components/ModuleListItem.swift +++ b/iosApp/iosApp/Views/Components/ModuleListItem.swift @@ -38,6 +38,6 @@ struct ModuleListItem: View { struct ModuleListItem_Previews: PreviewProvider { static var previews: some View { - ModuleListItem(observation: ObservationEntity(observationId: "1", observationType: "gps", observationTitle: "GPS", participantInfo: "123", configuration: nil, hidden: false, scheduleLess: false, version: 0, required: true, collectionTimestamp: Date().timeIntervalSince1970.asTimestamp())) + 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/NotificationItem.swift b/iosApp/iosApp/Views/Components/NotificationItem.swift index 7f2b6bc09..8efc3e869 100644 --- a/iosApp/iosApp/Views/Components/NotificationItem.swift +++ b/iosApp/iosApp/Views/Components/NotificationItem.swift @@ -40,7 +40,7 @@ struct NotificationItem: View { HStack(alignment: .center) { VStack(alignment: .leading) { - BasicText(text: notificationModel.notificationBody.applyHyperlinks().trimmingCharacters(in: .whitespacesAndNewlines), color: .more.secondary) + 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) diff --git a/iosApp/iosApp/Views/Components/RadioButtonField.swift b/iosApp/iosApp/Views/Components/RadioButtonField.swift index 19af1d35d..685a2cfb1 100644 --- a/iosApp/iosApp/Views/Components/RadioButtonField.swift +++ b/iosApp/iosApp/Views/Components/RadioButtonField.swift @@ -35,18 +35,23 @@ struct RadioButtonField: View { var body: some View { Button(action: { - self.callback(self.id) + 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 } } } @@ -58,3 +63,4 @@ struct RadioButtonField_Preview: PreviewProvider { }) } } + diff --git a/iosApp/iosApp/Views/Components/ReloadButton.swift b/iosApp/iosApp/Views/Components/ReloadButton.swift new file mode 100644 index 000000000..dee6f95c0 --- /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.updateStudyAsync() + isLoading = false + } +} + +#Preview { + ReloadButton() +} diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index 2320cfe68..a89f1b57c 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -82,6 +82,6 @@ struct ConsentView_Previews: PreviewProvider { static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) static let repos = MainRepository(appDatabase: database) static var previews: some View { - ConsentView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(repository: repos, dataManager: ObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder())))) + ConsentView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(repository: repos, dataManager: ObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil)))) } } diff --git a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift index 6ad5c0ef2..117bdaf61 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyView.swift @@ -16,7 +16,12 @@ 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)) + } var body: some View { MoreMainBackgroundView(contentPadding: 0) { @@ -55,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 cedbc7f06..ef3a217bc 100644 --- a/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift +++ b/iosApp/iosApp/Views/LimeSurvey/LimeSurveyViewModel.swift @@ -16,69 +16,62 @@ import Foundation import shared import WebKit +import Combine +import KMPNativeCoroutinesCombine class LimeSurveyViewModel: ObservableObject { - private let coreViewModel = CoreLimeSurveyViewModel(repositories: AppDelegate.shared.repositories, 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() } - navigationModalState?.closeView(screen: .limeSurvey) + shouldClose = true } private func extractPathAndParameters(url: URL) -> (String, [String: String]) { diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 6138bd0a2..0b2310894 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -140,6 +140,6 @@ struct LoginView_Previews: PreviewProvider { static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) static let repos = MainRepository(appDatabase: database) static var previews: some View { - LoginView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder())))) + LoginView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil)))) } } diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 3cdf6f4ed..8a1536818 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -110,6 +110,6 @@ struct ScanQRCodeView_Previews: PreviewProvider { static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) static let repos = MainRepository(appDatabase: database) static var previews: some View { - ScanQRCodeView(model: LoginViewModel(registration: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder())))) + ScanQRCodeView(model: LoginViewModel(registration: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil)))) } } diff --git a/iosApp/iosApp/Views/MainTabView.swift b/iosApp/iosApp/Views/MainTabView.swift index ce735ac8e..466e2132e 100644 --- a/iosApp/iosApp/Views/MainTabView.swift +++ b/iosApp/iosApp/Views/MainTabView.swift @@ -18,7 +18,6 @@ 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 { @@ -31,26 +30,14 @@ struct MainTabView: View { } .tag(0) - if #available(iOS 15.0, *) { - NavigationWithDestinations { - NotificationView(coreFilterVM: contentViewModel.coreNotificationFilterViewModel) - .padding(.horizontal, navigationModalState.horizontalContentPadding) - } - .tabItem { - Label(NavigationScreen.notifications.localize(), systemImage: "bell") - } - .tag(1) - .badge(contentViewModel.unreadNotificationCount) - } else { - NavigationWithDestinations { - NotificationView(coreFilterVM: contentViewModel.coreNotificationFilterViewModel) - .padding(.horizontal, navigationModalState.horizontalContentPadding) - } - .tabItem { - Label(NavigationScreen.notifications.localize(), 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) @@ -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,12 +75,14 @@ struct MainTabView: View { } } .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .limeSurvey)) { - Navigation { - LimeSurveyView(viewModel: contentViewModel.getLimeSurveyVM(navigationModalState: navigationModalState)) - .navigationBarTitleDisplayMode(.inline) - } - .onDisappear { - navigationModalState.removeNavigationAction() + if let navigationState = navigationModalState.navigationState(for: .limeSurvey) { + Navigation { + LimeSurveyView(navigationState: navigationState) + .navigationBarTitleDisplayMode(.inline) + } + .onDisappear { + navigationModalState.removeNavigationAction() + } } } .fullScreenCover(isPresented: navigationModalState.screenBinding(for: .withdrawStudy)) { diff --git a/iosApp/iosApp/Views/NavigationModalState.swift b/iosApp/iosApp/Views/NavigationModalState.swift index 8550e3b6a..6bef5b1f5 100644 --- a/iosApp/iosApp/Views/NavigationModalState.swift +++ b/iosApp/iosApp/Views/NavigationModalState.swift @@ -15,8 +15,8 @@ import Combine import KMPNativeCoroutinesCombine -import shared import SwiftUI +import shared struct NavigationState: Hashable { var scheduleId: String? = nil @@ -58,47 +58,49 @@ class NavigationModalState: ObservableObject { 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() + .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) + .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) + .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() + .removeDuplicates() + .map { + $0.boolValue } - } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] show in - if show { - self?.openView(screen: .garminConnect) - } else { - self?.closeView(screen: .garminConnect) + .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() + } } - } - .store(in: &cancellables) + .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 { @@ -112,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) + } } } } @@ -140,14 +144,24 @@ 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 { - Task { @MainActor in - 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 { @@ -159,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 @@ -234,33 +250,37 @@ class NavigationModalState: ObservableObject { guard !ViewManager.shared.studyIsUpdatingValue else { return } - 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 - } - } + 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/NotificationViewModel.swift b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift index 6634a898c..cbffd31e9 100644 --- a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift +++ b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift @@ -29,7 +29,7 @@ class NotificationViewModel: ObservableObject { init(filterViewModel: CoreNotificationFilterViewModel) { self.filterViewModel = filterViewModel - coreModel = CoreNotificationViewModel(coreFilterModel: filterViewModel, notificationManager: AppDelegate.shared.notificationManager, protocolReplacement: nil, hostReplacement: nil) + coreModel = CoreNotificationViewModel(coreFilterModel: filterViewModel, notificationManager: AppDelegate.shared.notificationManager) createPublisher(for: coreModel.notificationList) .removeDuplicates() @@ -56,13 +56,13 @@ class NotificationViewModel: ObservableObject { 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) + if let data { + switch actionHandler { + case NotificationActionHandler.deeplink: + AppDelegate.navigationScreenHandler.openRoute(to: data) + default: + return } - default: - return } } } 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 90% rename from iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift rename to iosApp/iosApp/Views/QuestionObservation/QuestionThankYouView.swift index d91ae4a1d..8a334ffc8 100644 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionThankYouView.swift +++ b/iosApp/iosApp/Views/QuestionObservation/QuestionThankYouView.swift @@ -15,10 +15,8 @@ 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 { 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 c1944132d..000000000 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuestionObservationViewModel.swift +++ /dev/null @@ -1,66 +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(repository: AppDelegate.shared.repositories, 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) { - answerSet = answer - } - - func finish() { - if !answerSet.isEmpty { - coreModel.finishQuestion(data: answerSet, setObservationToDone: true) - } - } -} diff --git a/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift b/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift deleted file mode 100644 index c80729a58..000000000 --- a/iosApp/iosApp/Views/QuestionObservation/SimpleQuetionObservationView.swift +++ /dev/null @@ -1,75 +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 - - 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("Answer") - } - .padding(.top, 30) - } - } - .padding(.horizontal, 10) - .onAppear { - 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) - } - } - } - } -} diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift index 5a94f8357..7465d0f13 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift @@ -13,8 +13,8 @@ // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct ObservationButton: View { @EnvironmentObject var navigationModalState: NavigationModalState @@ -27,30 +27,39 @@ struct ObservationButton: View { var body: some View { VStack { - if observationType == "question-observation" { - MoreActionButton(disabled: .constant(disabled), action: { - navigationModalState.openView(screen: .questionObservation, scheduleId: scheduleId) - }) { + if QuestionType_().matches(type: observationType) { + MoreActionButton( + disabled: .constant(disabled), + action: { + navigationModalState.openView(screen: .questionObservation, scheduleId: scheduleId) + } + ) { VStack { Text("start_questionnaire") } } } else if observationType == "lime-survey-observation" { - MoreActionButton(disabled: .constant(disabled), action: { - navigationModalState.openView(screen: .limeSurvey, scheduleId: scheduleId) - }) { + MoreActionButton( + disabled: .constant(disabled), + action: { + navigationModalState.openView(screen: .limeSurvey, scheduleId: scheduleId) + } + ) { VStack { Text("Button to start a limesurvey") } } } else { - MoreActionButton(disabled: .constant(disabled), action: { - if state == .running { - observationActionDelegate.pause(scheduleId: scheduleId) - } else { - observationActionDelegate.start(scheduleId: scheduleId) + MoreActionButton( + disabled: .constant(disabled), + action: { + if state == .running { + observationActionDelegate.pause(scheduleId: scheduleId) + } else { + observationActionDelegate.start(scheduleId: scheduleId) + } } - }) { + ) { VStack { if state == ScheduleState.running { Text("pause_observation") diff --git a/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift b/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift index 1c79600c3..40dd2f16f 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyLoadingErrorView.swift @@ -6,11 +6,9 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // -import shared import SwiftUI struct StudyLoadingErrorView: View { - @EnvironmentObject private var navigationModalState: NavigationModalState var body: some View { VStack(alignment: .center) { Spacer() @@ -18,33 +16,15 @@ struct StudyLoadingErrorView: View { .font(.system(size: 60)) .foregroundColor(Color.more.important) .padding() - Title(titleText: "sure_message", textAlignment: .center) + Title(titleText: "study_loading_error_title", textAlignment: .center) .padding(.bottom, 8) - Title2(titleText: "leave_confirmation_message", textAlignment: .center) + Title2(titleText: "study_loading_error_message", textAlignment: .center) Spacer() - - MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { - withdraw() - } label: { - Text("withdraw") - } + + ReloadButton() + ExitButton() } } - - private func withdraw() { - AlertController.shared.openAlertDialog(model: AlertDialogModel( - title: "sure_message", - message: "leave_confirmation_message", - confirmLabel: "withdraw", - cancelLabel: "continue_study", - onConfirm: { - AppDelegate.shared.exitStudy { - Task { @MainActor in - navigationModalState.clearViews() - } - } - })) - } } #Preview { diff --git a/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift b/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift index 5c6324597..26f6160b3 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyPausedView.swift @@ -23,6 +23,8 @@ struct StudyPausedView: View { .padding(.bottom, 8) 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/TaskDetails/TaskDetailsViewModel.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift index cfebaf53a..40bf881dc 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift @@ -37,13 +37,11 @@ class TaskDetailsViewModel: ObservableObject { } } - var simpleQuestionObservationVM: SimpleQuestionObservationViewModel private var cancellables = Set() init(scheduleId: String) { coreModel = CoreTaskDetailsViewModel(repository: AppDelegate.shared.repositories, dataRecorder: AppDelegate.shared.dataRecorder, scheduleId: scheduleId) - simpleQuestionObservationVM = SimpleQuestionObservationViewModel() coreModel.onLoadTaskDetails { [weak self] taskDetails in if let self { if let taskDetails { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 6641c2920..8677d3c50 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -29,14 +29,14 @@ struct iOSApp: App { .onChange(of: scenePhase) { newPhase in switch newPhase { case .background: - ViewManager.shared.appIsInForeground(state: false) - if AppDelegate.shared.observationManager.hasRunningTasks() { + AppDelegate.shared.updateData(appInForeground: false) + if AppDelegate.shared.credentialRepository.hasCredentialsValue { appDelegate.scheduleTasks() } case .inactive: break case .active: - ViewManager.shared.appIsInForeground(state: true) + AppDelegate.shared.updateData(appInForeground: true) appDelegate.cancelBackgroundTasks() break default: 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/shared/build.gradle.kts b/shared/build.gradle.kts index 6c88318d0..289411956 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,3 +1,6 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.openapitools.generator.gradle.plugin.tasks.GenerateTask + plugins { kotlin("multiplatform") kotlin("plugin.serialization") @@ -6,6 +9,8 @@ plugins { 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" @@ -13,21 +18,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.services.network.openapi" +val mobileAppApiPackage = "io.redlink.more.services.network.openapi" val openapiIgnore = "$openApiInputDir/openapi-ignore" val coroutinesVersion = "1.10.2" -val ktorVersion = "3.2.3" +val ktorVersion = "3.4.0" val napierVersion = "2.7.1" val serializationVersion = "1.9.0" val gsonVersion = "2.13.2" -val roomVersion = "2.7.2" +val roomVersion = "2.8.4" val sqliteVersion = "2.5.2" +val mokoResVersion = "0.25.2" +val mokoGraphicsVersion = "0.10.1" + kotlin { androidTarget { compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + jvmTarget.set(JvmTarget.JVM_17) } publishLibraryVariants("release") } @@ -39,9 +47,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") @@ -80,19 +89,20 @@ kotlin { all { languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") } + sourceSets["commonMain"].kotlin.srcDirs("$mobileAppApiOutputDir/src/commonMain/kotlin") } } android { - namespace = "io.redlink.more.more_app_multiplatform" + 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 { @@ -110,5 +120,64 @@ dependencies { 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) +} \ 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..3766c03e9 --- /dev/null +++ b/shared/src/androidMain/kotlin/io/redlink/more/database/DatabaseManager.android.kt @@ -0,0 +1,25 @@ +/* + * 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 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/database/DatabaseManager.android.kt b/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.android.kt deleted file mode 100644 index be8cf4fb4..000000000 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.android.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.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/services/network/HttpClientReceiver.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt similarity index 95% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt rename to shared/src/androidMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt index b6fda548e..c820099f9 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/HttpClientReceiver.kt +++ b/shared/src/androidMain/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.engine.android.Android 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/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt b/shared/src/androidMain/kotlin/io/redlink/more/services/store/PrivateSharedPreferences.kt similarity index 90% rename from shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt rename to shared/src/androidMain/kotlin/io/redlink/more/services/store/PrivateSharedPreferences.kt index 054eed9cb..235928e07 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/more_app_mutliplatform/services/store/PrivateSharedPreferences.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/store/PrivateSharedPreferences.kt @@ -1,4 +1,4 @@ -package io.redlink.more.more_app_mutliplatform.services.store +package io.redlink.more.services.store import android.content.Context import android.content.SharedPreferences 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 97% 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 a4196a2f3..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,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 import android.content.SharedPreferences 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/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt b/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt similarity index 65% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt rename to shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt index dbc2c3dc1..6d3777873 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt @@ -1,8 +1,19 @@ -package io.redlink.more.more_app_mutliplatform +/* + * 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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.extensions.then -import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel +import io.redlink.more.extensions.then +import io.redlink.more.models.AlertDialogModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/NapierProxy.kt b/shared/src/commonMain/kotlin/io/redlink/more/NapierProxy.kt similarity index 93% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/NapierProxy.kt rename to shared/src/commonMain/kotlin/io/redlink/more/NapierProxy.kt index b8abb0ce1..122195af8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/NapierProxy.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/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 import io.github.aakira.napier.Antilog import io.github.aakira.napier.DebugAntilog 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 93% 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 2a96104de..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,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 kotlinx.serialization.Serializable 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..cd709a665 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -0,0 +1,407 @@ +/* + * 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.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.models.StudyState +import io.redlink.more.navigation.DeeplinkManager +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.openapi.model.Study +import io.redlink.more.services.notification.LocalNotificationListener +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.services.store.CredentialRepository +import io.redlink.more.services.store.EndpointRepository +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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +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 + +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 deeplinkManager = DeeplinkManager(repositories, observationFactory) + val endpointRepository = EndpointRepository(sharedStorageRepository) + val credentialRepository = CredentialRepository(sharedStorageRepository).also { + observationFactory.setCredentialsRepository(it) + } + val networkService = NetworkService(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 { observationFactory.setNotificationManager(it) } + + val observationService = + ObservationService(repositories, notificationManager, reminderNotificationSchedulingLimit) + + private val mutex = Mutex() + private val konnection = Konnection.instance + private var mainJob: Job? = null + + init { + 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) { + ViewManager.appIsInForeground(appInForeground) + 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.sendData(true) + } + } + } + } + + suspend fun updateSchedules() { + observationManager.updateTaskStates() + observationFactory.updateObservationErrors() + observationService.scheduleObservationReminder() + notificationManager.downloadMissedNotifications() + } + + fun updateStudyAsync() { + Scope.launch(Dispatchers.IO) { + updateStudy() + } + } + + suspend fun updateStudy( + oldStudyState: StudyState? = null, + newStudyState: StudyState? = null + ) { + if (!credentialRepository.hasCredentials.value || mutex.isLocked) { + return + } + 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 (!konnection.isConnected()) { + 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" } + } + } + + suspend fun newLogin() { + notificationManager.newFCMToken() + observationFactory.updateObservationErrors() + 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) + } + } + } + + fun exitStudy(onDeletion: () -> Unit) { + StudyScope.cancel() + bluetoothController.resetAll() + Scope.launch { + networkService.deleteParticipation() + notificationManager.deleteFCMToken() + observationService.clearReminders() + notificationManager.clearAllNotifications() + removeStudyData() + observationFactory.clearNeededObservationTypes() + onDeletion() + ViewManager.resetAll() + clearSharedStorage() + } + } + + 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() + } + + companion object { + val PROTOCOL = StringDesc.Resource(SharedRes.strings.deeplink_protocol) + val HOST = StringDesc.Resource(SharedRes.strings.deeplink_host) + } +} 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..665bc32b9 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt @@ -0,0 +1,53 @@ +/* + * 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.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.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 + ], + version = 2 +) +@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 +} \ 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/database/DatabaseManager.kt similarity index 89% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt index 7a4e22acc..f0572c85a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt @@ -8,11 +8,12 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database +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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -25,4 +26,5 @@ fun getRoomDatabase(builder: RoomDatabase.Builder): AppDatabase = builder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .addMigrations(MIGRATION_1_2) .build() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BaseDao.kt similarity index 96% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/BaseDao.kt index 753e5e24d..ae3092541 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BaseDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BaseDao.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.database.dao +package io.redlink.more.database.dao import androidx.room.Delete import androidx.room.Insert diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BluetoothDeviceDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt similarity index 67% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BluetoothDeviceDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt index f24046efc..8eedbf512 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/BluetoothDeviceDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt @@ -1,8 +1,19 @@ -package io.redlink.more.more_app_mutliplatform.database.dao +/* + * 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.more_app_mutliplatform.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.BluetoothDeviceEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/DataPointDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt similarity index 60% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/DataPointDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt index 209f1b56f..53bb27370 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/DataPointDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt @@ -1,8 +1,19 @@ -package io.redlink.more.more_app_mutliplatform.database.dao +/* + * 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.more_app_mutliplatform.database.entities.DataPointEntity +import io.redlink.more.database.entities.DataPointEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/NotificationDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/NotificationDao.kt similarity index 90% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/NotificationDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/NotificationDao.kt index b5ac2a21e..1855fe241 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/NotificationDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/NotificationDao.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.dao +package io.redlink.more.database.dao import androidx.room.Dao import androidx.room.Query -import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity +import io.redlink.more.database.entities.NotificationEntity import kotlinx.coroutines.flow.Flow @Dao @@ -96,6 +96,20 @@ interface NotificationDao : BaseDao { @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> diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDao.kt similarity index 96% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDao.kt index 57592512f..59b566f94 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDao.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.dao +package io.redlink.more.database.dao import androidx.room.Dao import androidx.room.Query -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity +import io.redlink.more.database.entities.ObservationEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDataDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDataDao.kt similarity index 98% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDataDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDataDao.kt index 471b76f57..43ed04095 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ObservationDataDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ObservationDataDao.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.dao +package io.redlink.more.database.dao import androidx.room.Dao import androidx.room.Query -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity +import io.redlink.more.database.entities.ObservationDataEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ScheduleDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt similarity index 85% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ScheduleDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt index 88d46b18f..e8f6901fd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/ScheduleDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.dao +package io.redlink.more.database.dao import androidx.room.Dao import androidx.room.Query -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity +import io.redlink.more.database.entities.ScheduleEntity import kotlinx.coroutines.flow.Flow @Dao @@ -57,6 +57,9 @@ interface ScheduleDao : BaseDao { @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 @@ -69,6 +72,23 @@ interface ScheduleDao : BaseDao { @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 @@ -98,7 +118,4 @@ interface ScheduleDao : BaseDao { @Query("UPDATE schedules SET state = :state WHERE scheduleId = :scheduleId") suspend fun updateState(scheduleId: String, state: String) - - @Query("UPDATE schedules SET hidden = :hidden WHERE scheduleId = :scheduleId") - suspend fun updateHidden(scheduleId: String, hidden: Boolean) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/StudyDao.kt similarity index 87% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/dao/StudyDao.kt index a2f04e4dd..66bbc6528 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/dao/StudyDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/StudyDao.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.dao +package io.redlink.more.database.dao import androidx.room.Dao import androidx.room.Query -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity +import io.redlink.more.database.entities.StudyEntity import kotlinx.coroutines.flow.Flow @Dao @@ -30,11 +30,11 @@ interface StudyDao : BaseDao { @Query("SELECT * FROM studies WHERE studyId = :studyId") fun getByIdFlow(studyId: String): Flow - @Query("SELECT * FROM studies") - suspend fun getAll(): List + @Query("SELECT * FROM studies LIMIT 1") + suspend fun get(): StudyEntity? - @Query("SELECT * FROM studies") - fun getAllFlow(): Flow> + @Query("SELECT * FROM studies LIMIT 1") + fun getFlow(): Flow @Query("SELECT * FROM studies WHERE active = :active") suspend fun getByActive(active: Boolean): List diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/BluetoothDeviceEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt similarity index 53% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/BluetoothDeviceEntity.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt index 8fd9ff6ee..ad5145152 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/BluetoothDeviceEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt @@ -1,8 +1,19 @@ -package io.redlink.more.more_app_mutliplatform.database.entities +/* + * 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.PrimaryKey -import io.redlink.more.more_app_mutliplatform.util.createUUID +import io.redlink.more.util.createUUID @Entity data class BluetoothDeviceEntity( 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..ef2748226 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/DataPointEntity.kt @@ -0,0 +1,24 @@ +/* + * 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.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/more_app_mutliplatform/database/entities/NotificationEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/NotificationEntity.kt similarity index 90% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/NotificationEntity.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/entities/NotificationEntity.kt index 5796d0f9d..e133809e7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/NotificationEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/NotificationEntity.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.database.entities +package io.redlink.more.database.entities import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey -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 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 @@ -116,7 +116,7 @@ data class NotificationEntity( fun toEntity(notification: PushNotification): NotificationEntity { return toEntity( - notificationId = notification.msgId, + notificationId = notification.msgId ?: createUUID(), channelId = null, title = notification.title, notificationBody = notification.body, @@ -124,7 +124,7 @@ data class NotificationEntity( priority = 1, read = false, completed = false, - userFacing = notification.type == "text", + userFacing = notification.type == PushNotification.Type.TEXT, notificationData = notification.data?.mapValues { it.value.toString() }, deepLink = notification.deepLink ) @@ -133,6 +133,7 @@ data class NotificationEntity( fun toEntityList(notifications: List): List = notifications.map { toEntity(it) } - private fun extractDeepLink(data: Map) = data[NotificationManager.DEEP_LINK] + private fun extractDeepLink(data: Map) = + data[NotificationManager.DEEP_LINK] } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt similarity index 90% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationDataEntity.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt index 02a7de1ac..a9ab2d5f8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationDataEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt @@ -8,16 +8,16 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.entities +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.more_app_mutliplatform.extensions.asString -import io.redlink.more.more_app_mutliplatform.observations.ObservationBulkModel -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.ObservationData -import io.redlink.more.more_app_mutliplatform.util.createUUID +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 diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationEntity.kt similarity index 89% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationEntity.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationEntity.kt index 1e789c652..d4c5b4ca4 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ObservationEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationEntity.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.database.entities +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.more_app_mutliplatform.services.network.openapi.model.Observation +import io.redlink.more.services.network.openapi.model.Observation import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.json.Json @@ -30,6 +30,7 @@ data class ObservationEntity( 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() @@ -56,7 +57,8 @@ data class ObservationEntity( participantInfo = observation.participantInfo, configuration = observation.configuration.toString(), hidden = observation.hidden, - scheduleLess = observation.noSchedule, + scheduleLess = observation.noSchedule ?: false, + reminder = observation.reminder ?: false, required = observation.required, version = observation.version ) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt similarity index 78% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt index 2c437dcec..76e69c409 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/ScheduleEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt @@ -1,10 +1,21 @@ -package io.redlink.more.more_app_mutliplatform.database.entities +/* + * 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.PrimaryKey -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.ObservationSchedule -import io.redlink.more.more_app_mutliplatform.util.createUUID +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 @@ -19,6 +30,7 @@ data class ScheduleEntity( 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) @@ -61,6 +73,7 @@ data class ScheduleEntity( observationType: String, observationTitle: String, hidden: Boolean, + reminder: Boolean ): ScheduleEntity? { return if (schedule.start != null && schedule.end != null) { val now = Clock.System.now().epochSeconds @@ -77,7 +90,8 @@ data class ScheduleEntity( start = schedule.start.epochSeconds, end = schedule.end.epochSeconds, hidden = hidden, - state = scheduleState.name + state = scheduleState.name, + reminder = reminder ) } else null } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/StudyEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt similarity index 74% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/StudyEntity.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt index 76b29f07c..62847fd20 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/StudyEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt @@ -1,11 +1,23 @@ -package io.redlink.more.more_app_mutliplatform.database.entities +/* + * 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.more_app_mutliplatform.models.StudyState -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study -import io.redlink.more.more_app_mutliplatform.util.createUUID +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 @@ -58,7 +70,7 @@ data class StudyEntity( contactPhoneNumber = study.contact?.phoneNumber, version = study.version, active = active, - state = (study.studyState?.let { StudyState.getState(it) } + 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/more_app_mutliplatform/services/network/openapi/model/PushNotificationConfig.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_1_2.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/migrations/Migration_1_2.kt index bf6cb76f0..3c0f9715f 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/migrations/Migration_1_2.kt @@ -8,28 +8,16 @@ * (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.migrations -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName - -/** - * - * - * @param service - */ - -interface PushNotificationConfig { - - @SerialName(value = "service") - @Required - val service: PushNotificationServiceType -} +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/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt similarity index 82% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt index 462a6b2ed..ebd2642fc 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/BluetoothDeviceRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.repository +package io.redlink.more.database.repository -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity -import io.redlink.more.more_app_mutliplatform.scopes.Scope +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 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/database/repository/DataPointCountRepository.kt similarity index 82% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/DataPointCountRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt index e444e0b44..c907f3dd2 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/DataPointCountRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.repository +package io.redlink.more.database.repository -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.DataPointEntity -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope +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 @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class DataPointCountRepository(private val appDatabase: AppDatabase) { private val mutex = Mutex() @@ -32,18 +33,20 @@ class DataPointCountRepository(private val appDatabase: AppDatabase) { fun incrementCount(scheduleIdSet: Set, addCount: Long = 1) { if (scheduleIdSet.isNotEmpty()) { - StudyScope.launch(Dispatchers.IO) { + Scope.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, Dispatchers.IO) { - storeCounts() - }.second - storeJob?.invokeOnCompletion { - storeJob = null + withContext(Dispatchers.Main) { + storeJob = Scope.repeatedLaunch(5000L, Dispatchers.IO) { + storeCounts() + }.second + storeJob?.invokeOnCompletion { + storeJob = null + } } } } @@ -57,7 +60,7 @@ class DataPointCountRepository(private val appDatabase: AppDatabase) { countQueue.clear() } if (countsToStore.isNotEmpty()) { - StudyScope.launch(Dispatchers.IO) { + 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 } @@ -87,7 +90,7 @@ class DataPointCountRepository(private val appDatabase: AppDatabase) { } fun delete(scheduleId: String) { - StudyScope.launch { + Scope.launch { appDatabase.dataPointDao().deleteByScheduleId(scheduleId) } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt new file mode 100644 index 000000000..b3ce6237f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.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.database.repository + +import io.redlink.more.database.AppDatabase + +class MainRepository(appDatabase: AppDatabase) { + val study = StudyRepository(appDatabase) + val observation = ObservationRepository(appDatabase) + val observationData = ObservationDataRepository(appDatabase) + val dataPointCount = DataPointCountRepository(appDatabase) + val schedule = ScheduleRepository(appDatabase) + + val notification = NotificationRepository(appDatabase) + val bluetoothDevice = BluetoothDeviceRepository(appDatabase) + + suspend fun deleteAll() { + study.deleteStudy() + notification.deleteAll() + } +} \ 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/database/repository/NotificationRepository.kt similarity index 76% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/NotificationRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepository.kt index 85ecbfe3d..138e8be47 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/NotificationRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepository.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.database.repository +package io.redlink.more.database.repository import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity -import io.redlink.more.more_app_mutliplatform.scopes.Scope +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.Dispatchers import kotlinx.coroutines.IO 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 NotificationRepository(private val appDatabase: AppDatabase) { private val readNotificationIds = mutableSetOf() @@ -99,13 +103,20 @@ class NotificationRepository(private val appDatabase: AppDatabase) { } } - fun getCount(): Flow = appDatabase.notificationDao().getCount() - - fun getAllNotifications() = appDatabase.notificationDao().getAllFlow() + suspend fun getNotification(notificationId: String): NotificationEntity? { + return appDatabase.notificationDao().getById(notificationId) + } - fun getAllUserFacingNotifications() = appDatabase.notificationDao().getByUserFacingFlow(true) + fun getAllUserFacingNotifications(): Flow> { + val dbFlow = appDatabase.notificationDao().getByPastUserFacingFlow(true) - fun getUnreadUserNotifications() = appDatabase.notificationDao().getUnreadUserFacingFlow() + return combine( + dbFlow, + alignedNowFlow(periodMs = 30_000L) + ) { list, now -> + list.filter { it.timestamp != null && it.timestamp <= now } + }.distinctUntilChanged() + } suspend fun update(notificationId: String, read: Boolean? = null, priority: Long? = null) { mutex.withLock { @@ -120,11 +131,6 @@ class NotificationRepository(private val appDatabase: AppDatabase) { } } - fun allUserFacingNotifications() = appDatabase.notificationDao().getByUserFacingFlow(true) - - fun countUserFacingNotifications(): Flow = - appDatabase.notificationDao().getCountByUserFacing(true) - fun setNotificationReadStatus(key: String, read: Boolean = true) { if (read) { readNotificationIds.add(key) @@ -132,9 +138,12 @@ class NotificationRepository(private val appDatabase: AppDatabase) { readNotificationIds.remove(key) } - Scope.launch(Dispatchers.IO) { + Scope.launch { mutex.withLock { - appDatabase.notificationDao().updateReadStatus(key, read) + val notification = appDatabase.notificationDao().getById(key) + if (notification != null) { + appDatabase.notificationDao().updateReadStatus(key, read) + } readNotificationIds.remove(key) } } @@ -149,19 +158,33 @@ class NotificationRepository(private val appDatabase: AppDatabase) { completedNotificationIds.remove(key) } - Scope.launch(Dispatchers.IO) { + Scope.launch { mutex.withLock { - appDatabase.notificationDao().updateCompletedStatus(key, completed) - if (completed) { - appDatabase.notificationDao().updateReadStatus(key, true) + val notification = appDatabase.notificationDao().getById(key) + if (notification != null) { + appDatabase.notificationDao().updateCompletedStatus(key, completed) + if (completed) { + appDatabase.notificationDao().updateReadStatus(key, true) + } } completedNotificationIds.remove(key) } } } + suspend fun scheduledNotificationCount(): Int { + return appDatabase.notificationDao() + .getScheduledNotificationCount(Clock.System.now().epochSeconds) + } + + suspend fun scheduledNotifications(): List { + return appDatabase.notificationDao() + .getScheduledNotifications(Clock.System.now().epochSeconds) + } + + fun deleteNotification(notificationId: String) { - Scope.launch(Dispatchers.IO) { + Scope.launch { deletedNotificationIds.add(notificationId) mutex.withLock { Napier.i { "Delete Notification: $deletedNotificationIds" } 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/database/repository/ObservationDataRepository.kt similarity index 80% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationDataRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepository.kt index 285a58dfd..1e9d3bb95 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationDataRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepository.kt @@ -8,15 +8,14 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.repository +package io.redlink.more.database.repository import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity -import io.redlink.more.more_app_mutliplatform.extensions.mapAsBulkData -import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk +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 @@ -35,7 +34,7 @@ class ObservationDataRepository(private val appDatabase: AppDatabase) { } fun addData(dataList: List) { - StudyScope.launch { + Scope.launch { mutex.withLock { queue.addAll(dataList) } 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/database/repository/ObservationRepository.kt similarity index 91% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt index 7d54eb4f4..a741cc1cd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ObservationRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.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.database.repository +package io.redlink.more.database.repository import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -import io.redlink.more.more_app_mutliplatform.extensions.asClosure +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 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/database/repository/ScheduleRepository.kt similarity index 71% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ScheduleRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt index c4fbdf4d1..78e2499d5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ScheduleRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt @@ -8,24 +8,23 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.repository +package io.redlink.more.database.repository import io.github.aakira.napier.Napier -import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -import io.redlink.more.more_app_mutliplatform.extensions.asClosure -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.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.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 @@ -39,6 +38,30 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { return appDatabase.scheduleDao().getByDoneFlow(done) } + fun allSchedulesWithStates(states: Set): Flow> { + if (states.isEmpty()) { + return flowOf(emptyList()) + } + return appDatabase.scheduleDao().getByStatesFlow(states.map { it.name }) + } + + fun getSchedulesWithReminder( + states: Set, + minTimestamp: Instant, + maxTimestamp: Instant, + limit: Int = 100 + ): Flow> { + if (states.isEmpty()) { + return flowOf(emptyList()) + } + return appDatabase.scheduleDao().getSchedulesWithReminder( + states.map { it.name }, + minTimestamp.epochSeconds, + maxTimestamp.epochSeconds, + limit + ) + } + fun allScheduleWithRunningState(scheduleState: ScheduleState = ScheduleState.RUNNING): Flow> = appDatabase.scheduleDao().getByStateFlow(scheduleState.name) @@ -79,13 +102,6 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = firstScheduleAvailableForObservationId(observationId).transform { it?.scheduleId } - fun collectRunningState( - forState: ScheduleState, - provideNewState: (List) -> Unit - ): Closeable { - return allScheduleWithRunningState(forState).asClosure(provideNewState) - } - fun getFirstAndLastDate(observationId: String): Flow> { return appDatabase.scheduleDao().getByObservationIdFlow(observationId).transform { val start = it.sortedBy { it.start }.firstOrNull() @@ -104,51 +120,6 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { appDatabase.scheduleDao().updateDoneStatus(id, wasDone) } - fun nextSchedule(): Flow { - return allSchedulesWithStatus().transform { schedules -> - val now = Clock.System.now().epochSeconds - val startTimes = schedules.mapNotNull { it.start }.filter { it > now }.toSet() - val endTimes = schedules.mapNotNull { it.end }.filter { it > now }.toSet() - val nextStart = startTimes.minOfOrNull { it } - val nextEnd = endTimes.minOfOrNull { it } - - 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 getPreviousSchedule( - observationId: String, - currScheduleId: String - ): ScheduleEntity? { - val schedules = appDatabase.scheduleDao().getByObservationId(observationId) - val currentIndex = schedules.indexOfFirst { it.scheduleId == currScheduleId } - - return if (currentIndex > 0) schedules[currentIndex - 1] else null - } - - fun queryAllSchedulesForObservationId(observationId: String): Flow> { - return appDatabase.scheduleDao().getByObservationIdFlow(observationId) - } - - suspend fun getNextSchedule() = nextSchedule().firstOrNull() - - fun nextScheduleStart(): Flow { - return allSchedulesWithStatus().transform { schedules -> - val now = Clock.System.now().epochSeconds - val nextStart = schedules.mapNotNull { it.start } - .filter { it > now } - .minOfOrNull { it } - emit(nextStart) - } - } - - fun collectNextScheduleStart(provideNewState: (Long?) -> Unit) = - nextScheduleStart().asClosure(provideNewState) - fun scheduleWithId(id: String): Flow { return appDatabase.scheduleDao().getById(id) } @@ -196,6 +167,7 @@ class ScheduleRepository(private val appDatabase: AppDatabase) { } stateUpdates.forEach { (scheduleId, newState) -> + Napier.d { "Updating the schedule with $scheduleId to state: $newState" } appDatabase.scheduleDao().updateState(scheduleId, newState.name) } 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/database/repository/StudyRepository.kt similarity index 63% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/StudyRepository.kt rename to shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepository.kt index 9114857b6..78a45ee8d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/StudyRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepository.kt @@ -8,18 +8,17 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.database.repository +package io.redlink.more.database.repository import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.database.AppDatabase -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity -import io.redlink.more.more_app_mutliplatform.extensions.mapState -import io.redlink.more.more_app_mutliplatform.models.StudyState -import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study +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 @@ -27,7 +26,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withContext class StudyRepository(private val appDatabase: AppDatabase) { private val _study = MutableStateFlow(null) @@ -46,11 +46,13 @@ class StudyRepository(private val appDatabase: AppDatabase) { val finishText: StateFlow = _finishText init { - Scope.launch(Dispatchers.IO) { - getStudy().collect { - _study.value = it - it?.let { - _finishText.value = it.finishText + Scope.launch { + getStudy().collectLatest { + withContext(Dispatchers.Main) { + _study.value = it + it?.let { + _finishText.value = it.finishText + } } } } @@ -58,18 +60,19 @@ class StudyRepository(private val appDatabase: AppDatabase) { suspend fun upsert(study: Study) { deleteStudy() - StudyScope.launch(Dispatchers.IO) { + Scope.launch(Dispatchers.IO) { val studyEntity = StudyEntity.fromStudy(study) appDatabase.studyDao().insert(studyEntity) _finishText.value = study.finishText } - StudyScope.launch(Dispatchers.IO) { - val observationEntities = study.observations.map { ObservationEntity.toEntity(it) } + Scope.launch(Dispatchers.IO) { + val observationEntities = + study.observations.map { ObservationEntity.toEntity(it) } appDatabase.observationDao().insertAll(observationEntities) } - StudyScope.launch(Dispatchers.IO) { + Scope.launch(Dispatchers.IO) { val scheduleEntities = study.observations.flatMap { observation -> observation.schedule.mapNotNull { ScheduleEntity.fromObservationSchedule( @@ -77,7 +80,8 @@ class StudyRepository(private val appDatabase: AppDatabase) { observation.observationId, observation.observationType, observation.observationTitle, - observation.hidden ?: observation.noSchedule + observation.hidden ?: observation.noSchedule ?: false, + observation.reminder ?: false ) } } @@ -86,7 +90,7 @@ class StudyRepository(private val appDatabase: AppDatabase) { } fun getStudy(): Flow { - return appDatabase.studyDao().getAllFlow().transform { emit(it.firstOrNull()) } + return appDatabase.studyDao().getFlow() } suspend fun updateStudyState(state: StudyState) { @@ -96,12 +100,10 @@ class StudyRepository(private val appDatabase: AppDatabase) { } suspend fun deleteStudy() { - study.value?.let { - appDatabase.studyDao().deleteAll() - appDatabase.observationDao().deleteAll() - appDatabase.observationDataDao().deleteAll() - appDatabase.scheduleDao().deleteAll() - appDatabase.dataPointDao().deleteAll() - } + appDatabase.studyDao().deleteAll() + appDatabase.observationDao().deleteAll() + appDatabase.observationDataDao().deleteAll() + appDatabase.scheduleDao().deleteAll() + appDatabase.dataPointDao().deleteAll() } } \ 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 9456be3a8..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,6 +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 79% 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 a6783db4a..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,13 +8,13 @@ * (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.entities.BluetoothDeviceEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity -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? { val dataPoints = this.map { it.asObservationData() } 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 95% 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 f0e2e060d..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,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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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 95% 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 6493ef618..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,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 kotlinx.datetime.Instant import kotlinx.datetime.LocalDate 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 96% 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 2c195c579..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,10 +8,10 @@ * (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.scopes.Scope +import io.redlink.more.scopes.Scope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi 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..de4e8ba7d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt @@ -0,0 +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.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..ea4e7641c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt @@ -0,0 +1,29 @@ +/* + * 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.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 65% 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 9f629b1ea..076608e0d 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,15 @@ -package io.redlink.more.more_app_mutliplatform.extensions +/* + * 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.extensions fun String.extractRouteFromDeepLink(): String? { val regexPattern = "app://[^/]+/([a-zA-Z0-9-]+)(?:/\\d+)?/?.*".toRegex() 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..0f0bba13f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.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.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/more_app_mutliplatform/models/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt similarity index 64% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/AlertDialogModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt index 836b9a748..674c66b72 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/AlertDialogModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt @@ -1,4 +1,15 @@ -package io.redlink.more.more_app_mutliplatform.models +/* + * 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 data class AlertDialogModel( var title: String, 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 93% 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 02ceab24e..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,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 io.ktor.util.encodeBase64 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 90% 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 84e65b74c..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 @@ -38,6 +38,6 @@ enum class DateFilterModel( 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 92% 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 0b2ace1cc..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,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 FilterModel( var dateFilter: Map = DateFilterModel.entries 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..92822fa37 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt @@ -0,0 +1,24 @@ +/* + * 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.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/more_app_mutliplatform/models/NotificationFilterTypeModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt similarity index 94% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationFilterTypeModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt index 8c797710e..160c2b9b4 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationFilterTypeModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.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 NotificationFilterTypeModel(val type: String, val sortIndex: Int) { ALL("All", 0), 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/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.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/models/NotificationStatusType.kt index 957adc2ec..e7460a623 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/SimpleQuestionType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt @@ -8,8 +8,10 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes -class SimpleQuestionType() : - ObservationType("question-observation", setOf()) { +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/more_app_mutliplatform/services/network/openapi/model/PushNotificationToken.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt similarity index 50% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/PushNotificationToken.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt index 88998544a..f19289aba 100644 --- 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/models/NotificationTextLocalization.kt @@ -8,28 +8,18 @@ * (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.models -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import dev.icerock.moko.resources.desc.StringDesc -/** - * - * - * @param token - */ -@Serializable - -data class PushNotificationToken( - - @SerialName(value = "token") val token: kotlin.String? = null +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..88e45b25b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt @@ -0,0 +1,32 @@ +/* + * 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 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 85% 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 764fe6321..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,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.entities.ObservationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity data class ObservationDetailsModel( val observationTitle: String, 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 88% 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 90f05086c..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,11 +8,11 @@ * (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.entities.ObservationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity -import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study +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, 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 76% 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 a8d40e6b8..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,42 +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.entities.ObservationEntity +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: ObservationEntity, scheduleId: String - ): SimpleQuestionModel { + ): 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/more_app_mutliplatform/observations/observationTypes/ObservationType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt similarity index 52% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/ObservationType.kt rename to shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt index 8bbebee6c..81a8203e6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/ObservationType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt @@ -8,14 +8,16 @@ * (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.entities.ObservationDataEntity +package io.redlink.more.models -open class ObservationType(val observationType: String, val sensorPermissions: Set) { - fun addObservationType(schema: ObservationDataEntity): ObservationDataEntity { - val obsType = observationType - schema.observationType = obsType - return schema +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 93% 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 6ff682c61..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.entities.ScheduleEntity +import io.redlink.more.database.entities.ScheduleEntity data class ScheduleModel( val scheduleId: String, 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 94% 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..9006d8172 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, 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 83% 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 c9d3b9180..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,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.entities.ObservationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.StudyEntity data class StudyDetailsModel( val study: StudyEntity, 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 93% 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 9da942456..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"), @@ -21,4 +21,5 @@ enum class StudyState(val descr: String) { companion object { 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 87% 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 3ee92451c..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.entities.ObservationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity +import io.redlink.more.database.entities.ObservationEntity +import io.redlink.more.database.entities.ScheduleEntity data class TaskDetailsModel( val observationTitle: String, 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 585fa222a..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/Shared.kt +++ /dev/null @@ -1,290 +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 dev.tmapps.konnection.Konnection -import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -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.observations.ObservationStates -import io.redlink.more.more_app_mutliplatform.observations.observationTypes.GarminType -import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -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.viewModels.ViewManager -import io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection.BluetoothController -import io.redlink.more.more_app_mutliplatform.viewModels.garminConnectOAuth.CoreGarminConnectViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class Shared( - localNotificationListener: LocalNotificationListener, - val repositories: MainRepository, - val sharedStorageRepository: SharedStorageRepository, - val observationDataManager: ObservationDataManager, - mainBluetoothConnector: BluetoothConnector, - val observationFactory: ObservationFactory, - val dataRecorder: DataRecorder -) { - private val viewManager = ViewManager - val deeplinkManager = DeeplinkManager(repositories, observationFactory) - val endpointRepository = EndpointRepository(sharedStorageRepository) - val credentialRepository = CredentialRepository(sharedStorageRepository).also { - observationFactory.setCredentialsRepository(it) - } - val networkService = NetworkService(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 { observationFactory.setNotificationManager(it) } - - val unreadNotificationCount = notificationManager.unreadUserCount - - private val mutex = Mutex() - private val konnection = Konnection.instance - - init { - Scope.launch { - var prevFg: Boolean? = null - var prevState: Boolean? = null - combine( - ViewManager.appInForeground, - credentialRepository.hasCredentials, - repositories.study.studyState - ) { fg, cred, state -> Pair(fg, cred && state.isActive()) } - .distinctUntilChanged() - .collectLatest { (fg, state) -> - ViewManager.currentStudyActive(state) - if (fg != prevFg && (state == prevState || prevState == null && state)) { - Napier.d(tag = "Shared::init") { "App went to foreground: $fg, study state: $state" } - if (fg && state) { - updateStudy() - observationManager.updateTaskStates() - observationFactory.updateObservationErrors() - notificationManager.createNewFCMIfNecessary() - notificationManager.clearAllNotifications() - notificationManager.downloadMissedNotifications() - dataRecorder.restartAll() - garminLogin() - } else { - ViewManager.showBLEView(false) - } - } else if (fg == prevFg && prevState != null && state != prevState) { - Napier.d(tag = "Shared::init") { "Study state changed: $prevState -> $state" } - if (state) { - observationDataManager.listenToDatapointCountChanges() - observationManager.activateScheduleUpdate() - Scope.launch { - observationManager.updateTaskStates() - observationFactory.updateObservationErrors() - } - garminLogin() - } else { - stopObservations() - ViewManager.showBLEView(false) - ObservationStates.resetAll() - } - } - prevFg = fg - prevState = state - } - } - } - - /** - * Updates the study state and related resources. This function ensures that any change in the - * current study, study state, or study configuration is appropriately handled. It synchronizes - * updates using a lock and takes necessary actions such as storing the new state, updating study - * resources, managing notifications, and handling changes in connectivity. - * - * @param oldStudyState The previous state of the study. Can be null if there is no specific old state to compare with. - * @param newStudyState The new state to transition the study to. Can be null if no state change is required. - */ - 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 currentStudy = repositories.study.study.value - if (newStudyState == StudyState.CLOSED || newStudyState == StudyState.PAUSED) { - Napier.d(tag = "Shared::updateStudy") { "New study State is $newStudyState" } - repositories.study.updateStudyState(newStudyState) - StudyScope.cancel() - notificationManager.clearAllNotifications() - } else { - if (!konnection.isConnected()) { - Napier.d(tag = "Shared::updateStudy") { "No network connection, skipping study update" } - if (newStudyState != null) { - repositories.study.updateStudyState(newStudyState) - } - if (currentStudy == null) { - ViewManager.studyError(true) - } - return - } - - val (study, error) = networkService.getStudyConfig() - if (error != null) { - Napier.e { error.message } - if (currentStudy == null) { - ViewManager.studyError(true) - } - return - } - if (study == null) { - Napier.d { "Study is null" } - if (currentStudy == null) { - ViewManager.studyError(true) - } - return - } - - var studyHasChanged = false - var stateChanged = false - var activeStatusChanged = false - var versionChanged = false - - currentStudy?.let { current -> - val newState = study.studyState?.let { StudyState.getState(it) } - val currentState = current.getState() - - if (newState != currentState) { - stateChanged = true - studyHasChanged = true - Napier.d(tag = "Shared::updateStudy") { "Study state changed: $currentState -> $newState" } - } - - if (current.active != study.active) { - activeStatusChanged = true - studyHasChanged = true - Napier.d(tag = "Shared::updateStudy") { "Study active status changed: ${current.active} -> ${study.active}" } - } - - if (current.version != study.version) { - versionChanged = true - studyHasChanged = true - Napier.d(tag = "Shared::updateStudy") { "Study version changed: ${current.version} -> ${study.version}" } - } - } - - val hasNoCurrentStudy = currentStudy == 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) - StudyScope.cancel() - observationFactory.clearNeededObservationTypes() - notificationManager.clearAllNotifications() - repositories.notification.deleteAll() - ViewManager.studyError(false) - repositories.study.upsert(study) - viewManager.studyIsUpdating(false) - } else { - Napier.d(tag = "Shared::updateStudy") { "No study update needed - study data is unchanged" } - } - } - } - } - - suspend fun newLogin() { - notificationManager.newFCMToken() - observationFactory.updateObservationErrors() - garminLogin() - } - - private fun garminLogin() { - Scope.launch { - Napier.d(tag = "Shared::garminLogin") { "Checking Garmin login" } - if (observationFactory.observationTypes() - .contains(GarminType().observationType) - && !sharedStorageRepository.load( - CoreGarminConnectViewModel.GARMIN_CONNECT_SUCCESSFUL_LOGIN, - false - ) - ) { - ViewManager.requestGarminConnectView(true) - } - } - } - - fun exitStudy(onDeletion: () -> Unit) { - StudyScope.cancel() - bluetoothController.resetAll() - Scope.launch { - networkService.deleteParticipation() - notificationManager.clearAllNotifications() - notificationManager.deleteFCMToken() - removeStudyData() - repositories.notification.deleteAll() - observationFactory.clearNeededObservationTypes() - clearSharedStorage() - onDeletion() - viewManager.resetAll() - } - } - - 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() - } - - fun unreadNotificationCountAsClosure(state: (Int) -> Unit) = - unreadNotificationCount.asClosure(state) -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/AppDatabase.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/AppDatabase.kt deleted file mode 100644 index e350c9a57..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/AppDatabase.kt +++ /dev/null @@ -1,42 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.database - -import androidx.room.ConstructedBy -import androidx.room.Database -import androidx.room.RoomDatabase -import io.redlink.more.more_app_mutliplatform.database.dao.BluetoothDeviceDao -import io.redlink.more.more_app_mutliplatform.database.dao.DataPointDao -import io.redlink.more.more_app_mutliplatform.database.dao.NotificationDao -import io.redlink.more.more_app_mutliplatform.database.dao.ObservationDao -import io.redlink.more.more_app_mutliplatform.database.dao.ObservationDataDao -import io.redlink.more.more_app_mutliplatform.database.dao.ScheduleDao -import io.redlink.more.more_app_mutliplatform.database.dao.StudyDao -import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity -import io.redlink.more.more_app_mutliplatform.database.entities.DataPointEntity -import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity - -@Database( - entities = [ - StudyEntity::class, - ScheduleEntity::class, - ObservationEntity::class, - ObservationDataEntity::class, - NotificationEntity::class, - BluetoothDeviceEntity::class, - DataPointEntity::class - ], - version = 1 -) -@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 -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/DataPointEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/DataPointEntity.kt deleted file mode 100644 index 35899885c..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/entities/DataPointEntity.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/database/repository/MainRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/MainRepository.kt deleted file mode 100644 index 6c2480190..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/MainRepository.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.database.repository - -import io.redlink.more.more_app_mutliplatform.database.AppDatabase - -class MainRepository(appDatabase: AppDatabase) { - val study = StudyRepository(appDatabase) - val observation = ObservationRepository(appDatabase) - val observationData = ObservationDataRepository(appDatabase) - val dataPointCount = DataPointCountRepository(appDatabase) - val schedule = ScheduleRepository(appDatabase) - - val notification = NotificationRepository(appDatabase) - val bluetoothDevice = BluetoothDeviceRepository(appDatabase) - - suspend fun deleteAll() { - study.deleteStudy() - notification.deleteAll() - - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt deleted file mode 100644 index 89986ad1e..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/extensions/FunctionExtension.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.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/more_app_mutliplatform/models/LoginModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/LoginModel.kt deleted file mode 100644 index 7dcf8bb7c..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/LoginModel.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.models - -import io.redlink.more.more_app_mutliplatform.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/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 f915a72d1..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationModel.kt +++ /dev/null @@ -1,55 +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.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 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, - priority = it.priority, - read = it.read, - completed = it.completed, - userFacing = it.userFacing, - deepLink = it.deepLink(), - notificationData = it.getNotificationDataMap() - ) - } - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt deleted file mode 100644 index fd9fe862e..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/models/NotificationStatusType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.models - -enum class NotificationStatusType { - READ, - COMPLETED; -} \ 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 b8c119c51..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.entities.ScheduleEntity -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -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 repos: MainRepository, - val observationFactory: ObservationFactory -) { - private val deepLinks = mutableSetOf() - - 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() - || repos.observation.observationById(observationId.first()) - .firstOrNull() == null - ) { - emit(null) - return@flow - } - val schedule = - repos.schedule.firstScheduleAvailableForObservationId(observationId.first()) - .cancellable().firstOrNull() - - emit(deepLinkModifier(deepLink, schedule, protocolReplacement, hostReplacement)) - } ?: run { - emit(deepLink) - } - } - - private fun deepLinkModifier( - deepLink: String, - schedule: ScheduleEntity?, - 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: ScheduleEntity?): String { - val now = Clock.System.now() - - return schedule?.let { scheduleSchema -> - if ((scheduleSchema.start ?: 0) <= now.epochSeconds) { - routeForObservation(deepLink) - } else { - TASK_DETAILS - } - } ?: OBSERVATION_DETAILS - } - - private fun replaceRoute( - deepLink: String, - routeToReplace: String, - schedule: ScheduleEntity? = 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) - 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/garmin/GarminObservation.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/garmin/GarminObservation.kt deleted file mode 100644 index df3ab6f88..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/garmin/GarminObservation.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.observations.garmin - -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.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/observationTypes/GarminType.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GarminType.kt deleted file mode 100644 index 00d734477..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/GarminType.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.observations.observationTypes - -class GarminType : ObservationType("garmin-observation", emptySet()) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/ScanMode.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/ScanMode.kt deleted file mode 100644 index 7bebf89d6..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/ScanMode.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.services.bluetooth - -enum class ScanMode { - Stopped, - Foreground, - Background -} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt deleted file mode 100644 index b01a33673..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/polar/PolarStates.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.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/NetworkService.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt deleted file mode 100644 index 442a749aa..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt +++ /dev/null @@ -1,403 +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.call.body -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.client.request.delete -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.put -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.Url -import io.ktor.http.contentType -import io.ktor.http.isSuccess -import io.ktor.serialization.kotlinx.json.json -import io.ktor.utils.io.core.Closeable -import io.redlink.more.app.android.services.network.errors.NetworkServiceError -import io.redlink.more.more_app_mutliplatform.models.LoginModel -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.PushNotification -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 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 fun initHttpClientWithAuth(): HttpClient? { - initHttpClient() - val creds = credentialRepository.credentials.value - return httpClient?.config { - install(Auth) { - creds?.let { authCredentials -> - basic { - credentials { - BasicAuthCredentials( - username = authCredentials.apiId, - password = authCredentials.apiKey - ) - } - sendWithoutRequest { true } - } - } - } - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - isLenient = true - }) - } - } - } - - private fun initHttpClient() { - if (httpClient == null) { - httpClient = getHttpClient(Logger.DEFAULT) - } - } - - fun baseUrl(): String = endpointRepository.endpoint() - - suspend fun deleteParticipation(): Pair { - try { - credentialRepository.credentials.value?.let { credentials -> - Napier.i(tag = "NetworkService::deleteParticipation") { "Deleting Participation..." } - val client = initHttpClientWithAuth() ?: return Pair( - false, - NetworkServiceError(null, "Failed to init HTTP client") - ) - val baseUrl = endpointRepository.endpoint() - - val response = client.delete("$baseUrl/registration") - - Napier.i(response.toString(), tag = TAG) - close() - if (response.status.isSuccess()) { - Napier.i(tag = "NetworkService::deleteParticipation") { "Participation deleted!" } - return Pair(true, null) - } - Napier.e(tag = "NetworkService::deleteParticipation") { "Error; Code: ${response.status.value}" } - val error = createErrorBody(response.status.value, 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(loginModel: LoginModel): Pair { - try { - Napier.i(tag = "NetworkService::validateRegistrationToken") { "Validating Registration token..." } - initHttpClient() - val baseUrl = loginModel.endpoint ?: endpointRepository.endpoint() - val client = httpClient ?: return Pair( - null, - NetworkServiceError(null, "HTTP client not initialized") - ) - - val response = client.get("$baseUrl/registration") { - header("More-Registration-Token", loginModel.token) - contentType(ContentType.Application.Json) - } - - Napier.i(response.toString(), tag = TAG) - if (response.status.isSuccess()) { - val study: Study = response.body() - Napier.i(tag = "NetworkService::validateRegistrationToken") { "Registration token valid!" } - return Pair(study, null) - } - val error = createErrorBody(response.status.value, response) - return Pair(null, error) - - } catch (err: Exception) { - Napier.e(tag = "NetworkService::validateRegistrationToken") { err.stackTraceToString() } - return Pair(null, getException(err)) - } - } - - suspend fun sendConsent( - loginModel: LoginModel, studyConsent: StudyConsent - ): Pair { - try { - Napier.i(tag = "NetworkService::sendConsent") { "Sending Consent..." } - initHttpClient() - val baseUrl = loginModel.endpoint ?: endpointRepository.endpoint() - val client = httpClient ?: return Pair( - null, - NetworkServiceError(null, "HTTP client not initialized") - ) - - val response = client.post("$baseUrl/registration") { - header("More-Registration-Token", loginModel.token) - contentType(ContentType.Application.Json) - setBody(studyConsent) - } - - if (response.status.isSuccess()) { - val appConfig: AppConfiguration = response.body() - Napier.i(tag = "NetworkService::sendConsent") { "Credentials received!" } - return Pair(appConfig, null) - } - return Pair(null, createErrorBody(response.status.value, response)) - } catch (e: Exception) { - Napier.e(tag = "NetworkService::sendConsent") { e.stackTraceToString() } - return Pair(null, getException(e)) - } - } - - suspend fun getStudyConfig(): Pair { - try { - Napier.i(tag = "NetworkService::getStudyConfig") { "Downloading study data..." } - credentialRepository.credentials.value?.let { credentials -> - val client = initHttpClientWithAuth() ?: return Pair( - null, - NetworkServiceError(null, "Failed to init HTTP client") - ) - val baseUrl = endpointRepository.endpoint() - - val response = client.get("$baseUrl/config/study") { - contentType(ContentType.Application.Json) - } - - if (response.status.isSuccess()) { - val study: Study = response.body() - Napier.i(tag = "NetworkService::getStudyConfig") { "Loading study data success!" } - return Pair(study, null) - } - return Pair(null, createErrorBody(response.status.value, response)) - } - return Pair(null, NetworkServiceError(null, "No credentials set!")) - } catch (e: Exception) { - Napier.e(tag = "NetworkService::getStudyConfig") { e.stackTraceToString() } - return Pair(null, getException(e)) - } - } - - suspend fun sendNotificationToken(token: String): Pair { - try { - Napier.i(tag = "NetworkService::sendNotificationToken") { "Sending notification token..." } - credentialRepository.credentials.value?.let { credentials -> - val client = initHttpClientWithAuth() ?: return Pair( - false, - NetworkServiceError(null, "Failed to init HTTP client") - ) - val baseUrl = endpointRepository.endpoint() - val pushToken = PushNotificationToken(token = token) - - val response = client.put("$baseUrl/config/notifications/FCM") { - contentType(ContentType.Application.Json) - setBody(pushToken) - } - - if (response.status.isSuccess()) { - Napier.i(tag = "NetworkService::sendNotificationToken") { "Uploading notification token success!" } - return Pair(true, null) - } - return Pair(false, createErrorBody(response.status.value, response)) - } - return Pair(false, NetworkServiceError(null, "No credentials found!")) - } catch (err: Exception) { - Napier.e(tag = "NetworkService::sendNotificationToken") { err.stackTraceToString() } - return Pair(false, getException(err)) - } - } - - 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()}..." } - credentialRepository.credentials.value?.let { credentials -> - val client = initHttpClientWithAuth() ?: return Pair( - emptySet(), - NetworkServiceError(null, "Failed to init HTTP client") - ) - val baseUrl = endpointRepository.endpoint() - - val response = client.post("$baseUrl/data/bulk") { - contentType(ContentType.Application.Json) - setBody(data) - } - - if (response.status.isSuccess()) { - val result: List = response.body() - Napier.i(tag = "NetworkService::sendData") { "Sent data!" } - return Pair(result.toSet(), null) - } - return Pair(emptySet(), createErrorBody(response.status.value, response)) - } - return Pair(emptySet(), NetworkServiceError(null, "No credentials set!")) - } catch (e: Exception) { - Napier.e(tag = "NetworkService::sendData") { e.stackTraceToString() } - return Pair(emptySet(), getException(e)) - } - } - - suspend fun downloadMissedNotifications(): List { - return try { - Napier.d(tag = "NetworkService::downloadMissedNotifications") { "Downloading missed notifications from the Server..." } - credentialRepository.credentials.value?.let { credentials -> - val client = initHttpClientWithAuth() ?: return@let emptyList() - val baseUrl = endpointRepository.endpoint() - - val response = client.get("$baseUrl/notifications") { - contentType(ContentType.Application.Json) - } - - if (response.status.isSuccess()) { - 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() - } - } ?: run { - Napier.d(tag = "NetworkService::downloadMissedNotifications") { "No credentials available" } - emptyList() - } - } catch (e: Exception) { - Napier.e(tag = "NetworkService::downloadMissedNotifications") { "Notification List error: $e" } - emptyList() - } - } - - fun getBasicAuthHeader(): String? { - return credentialRepository.credentials.value?.basicAuthHeader() - } - - fun getGarminSSOUrl(): Url? { - try { - credentialRepository.credentials.value?.let { - val baseUrl = endpointRepository.endpoint() - return Url("$baseUrl/registration/garmin") - } - } catch (e: Exception) { - Napier.e(tag = "NetworkService::getGarminSSOUrl") { "Error getting Garmin SSO Url: $e" } - } - return null - } - - suspend fun garminSSOCallback(code: String, status: String): Boolean { - return garminSSOCallbackUrl()?.let { callbackUrl -> - try { - credentialRepository.credentials.value?.let { - val client = initHttpClientWithAuth() ?: return@let false - val result = client.get(callbackUrl) { - url { - parameters.append("code", code) - parameters.append("state", status) - } - } - Napier.d(tag = "NetworkService::garminSSOCallback") { - "Received callback response: ${result.status}" - } - if (!result.status.isSuccess()) { - try { - val bodyText = result.bodyAsText() - Napier.e(tag = "NetworkService::garminSSOCallback") { - "Garmin SSO callback failed with status ${result.status.value}, body: $bodyText" - } - } catch (_: Exception) { - } - } - result.status.isSuccess() - } ?: false - } catch (e: Exception) { - Napier.e(tag = "NetworkService::garminSSOCallback") { "Network error: ${e.message}" } - false - } - } ?: false - } - - fun garminSSOCallbackUrl(): Url? { - return getGarminSSOUrl()?.let { - Url("$it/callback") - } - } - - suspend fun deletePushNotification(msgId: String) { - try { - credentialRepository.credentials.value?.let { _ -> - val client = initHttpClientWithAuth() ?: return - val baseUrl = endpointRepository.endpoint() - - val response = client.delete("$baseUrl/notifications/$msgId") { - contentType(ContentType.Application.Json) - } - - if (response.status.isSuccess()) { - 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?.msg ?: "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) - } - - override fun close() { - Napier.d(tag = "NetworkService::close") { "Clearing the Http engine..." } - httpClient?.close() - httpClient = null - } - -} \ 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 f586c6385..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ApiKey.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 - -/** - * 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 43f9af8ae..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 5b8f9f66a..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 754794b55..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Error.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/). - */ -@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 5caf17e4b..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 19db3021d..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/FcmNotificationConfigAllOf.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/). - */ -@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 437406fc5..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 64860030d..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 96b1440ef..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationConsent.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 - -/** - * - * - * @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 34189a101..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 b68b463d0..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/ObservationSchedule.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.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 54382df2a..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Participant.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/). - */ -@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 73f4ad8b9..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/Study.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Study.kt deleted file mode 100644 index 8ecf3c66b..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/Study.kt +++ /dev/null @@ -1,69 +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 fda06a434..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyConsent.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/). - */ -@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 e42fec7cd..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/openapi/model/StudyContact.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/). - */ -@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/util/RegexData.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt deleted file mode 100644 index 6d65de5bf..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/RegexData.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.util - -class RegexData { - companion object { - 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/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt deleted file mode 100644 index 935cd328f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/PolarController.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection - -import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.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/limeSurvey/CoreLimeSurveyViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt deleted file mode 100644 index 5555e6785..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt +++ /dev/null @@ -1,116 +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.MainRepository -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( - private val repositories: MainRepository, - 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) - - 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) - repositories.schedule.scheduleWithId(scheduleId).cancellable() - .transform { scheduleSchema -> - emit(scheduleSchema?.let { - repositories.observation.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 { - repositories.schedule.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/simpleQuestion/SimpleQuestionCoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/simpleQuestion/SimpleQuestionCoreViewModel.kt deleted file mode 100644 index 13da70964..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/simpleQuestion/SimpleQuestionCoreViewModel.kt +++ /dev/null @@ -1,85 +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.MainRepository -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( - private val repository: MainRepository, - observationFactory: ObservationFactory, -) : CoreViewModel() { - private var scheduleId: String? = null - - 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 { - repository.schedule.scheduleWithId(scheduleId).cancellable().firstOrNull() - ?.let { scheduleSchema -> - repository.observation.observationById(scheduleSchema.observationId) - .cancellable().firstOrNull()?.let { observationSchema -> - simpleQuestionModel.emit( - SimpleQuestionModel.createModelFrom( - observationSchema, - scheduleId - ) - ) - } - } - } - } - - fun setScheduleViaObservationId(observationId: String, notificationId: String? = null) { - launchScope { - repository.schedule.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/navigation/DeeplinkManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt new file mode 100644 index 000000000..05aa3ee4f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt @@ -0,0 +1,262 @@ +/* + * 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 + +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 DeeplinkManager( + private val repos: MainRepository, + val observationFactory: ObservationFactory +) { + private val deepLinks = mutableSetOf() + private var protocolReplacement: String? = null + private var hostReplacement: String? = null + + fun addAvailableDeepLinks(deepLinks: Set) { + this.deepLinks.addAll(deepLinks) + } + + fun setProtocol(protocolReplacement: String?) { + this.protocolReplacement = protocolReplacement + } + + fun setHost(hostReplacement: String?) { + this.hostReplacement = hostReplacement + } + + fun getNotificationViewDeepLink( + notificationId: String, + ): Flow { + return modifyDeepLink( + "/${NavigationRoute.NOTIFICATIONS}?${NavigationRouteParameter.NOTIFICATION_ID}=$notificationId", + ) + } + + 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 observationIdToUse = observationIdParam ?: schedule?.observationId + + emit( + DeepLinkData( + deepLinkModifier( + deepLink, + schedule, + ), + mapOf( + NavigationRouteParameter.NOTIFICATION_ID.key to notificationId, + NavigationRouteParameter.SCHEDULE_ID.key to scheduleIdParam, + 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() + + private fun validateRoute(deepLink: String): Boolean { + Napier.d { "Available deeplinks: $deepLinks" } + return deepLinks.any { registered -> + val registeredRoute = extractRegisteredRoute(registered) ?: registered + routeMatches(deepLink, registeredRoute) + } + } + + private fun routeForObservation(deepLink: String): String { + val incomingRoute = extractIncomingRoute(deepLink.lowercase()) + ?: return NavigationRoute.SCHEDULE_DETAILS.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 if (valid) { + resolvedObservationRoute + } else NavigationRoute.DASHBOARD.route + } + + 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 protocolAndHost = (this.protocolReplacement + ?: deepLink.substringBefore("://")) + "://" + val afterProtocol = deepLink.substringAfter("://") + val hostAndPath = afterProtocol.substringBefore('?') + val host = this.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(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(protocolAndHost) + append(newHostAndPath) + if (newQueryParams.isNotEmpty()) append("?").append(newQueryParams) + if (fragment.isNotEmpty()) append("#").append(fragment) + } + } + + 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). + */ + fun createDeeplinkForSchedule( + schedule: ScheduleEntity, + baseDeeplink: String? = null + ): 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) + } + } +} \ No newline at end of file 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..6242063c2 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/DeepLinkData.kt @@ -0,0 +1,17 @@ +/* + * 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 + +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..dc11a18e3 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.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.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) { + DASHBOARD("dashboard"), + NOTIFICATIONS("notifications"), + INFO("information"), + SETTINGS("settings"), + SCHEDULE_DETAILS("task-details"), + OBSERVATION_DETAILS("observation-details"), + STUDY_DETAILS("study-details"), + OBSERVATION_FILTER("observation-filter"), + QUESTION(QuestionType().observationType), + QUESTIONNAIRE_RESPONSE("${QuestionType().observationType}_response"), + BLUETOOTH_CONNECTION("devices"), + RUNNING_SCHEDULES("running-observations"), + COMPLETED_SCHEDULES("past-observations"), + NOTIFICATION_FILTER("notification-filter"), + LEAVE_STUDY("leave-study"), + LEAVE_STUDY_CONFIRM("leave-study-confirmation"), + LIMESURVEY(LimeSurveyType().observationType), + GARMIN_CONNECT(GarminType().observationType), + OBSERVATION_ERRORS("observation-errors"), + 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..3c1ca9e72 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRouteParameter.kt @@ -0,0 +1,23 @@ +/* + * 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 + +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/more_app_mutliplatform/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt similarity index 89% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt index 1551c94a3..c667ded6b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -8,17 +8,17 @@ * (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.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -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.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager +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.observationTypes.ObservationType +import io.redlink.more.scopes.Scope +import io.redlink.more.scopes.StudyScope +import io.redlink.more.services.notification.NotificationManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -38,6 +38,7 @@ abstract class Observation( private var running = false private val observationIds = mutableSetOf() + private val observationTypes = mutableMapOf() private val scheduleIds = mutableMapOf() private val notificationIds = mutableMapOf() private val config = mutableMapOf() @@ -49,6 +50,12 @@ abstract class Observation( 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 { @@ -82,11 +89,12 @@ abstract class Observation( stop { timestampCollectionJob?.cancel() saveAndSend() + observationShutdown(scheduleId) } } else { saveAndSend() + observationShutdown(scheduleId) } - observationShutdown(scheduleId) if (removeNotification) { handleNotification(scheduleId) } @@ -146,7 +154,6 @@ abstract class Observation( updateObservationErrors() } return errors.isEmpty() - } protected open fun observerErrors(): Set = emptySet() @@ -175,7 +182,11 @@ abstract class Observation( fun storeData(data: Any, timestamp: Long = -1, onCompletion: () -> Unit = {}) { val dataSchemas = ObservationDataEntity.fromData( observationIds.toSet(), setOf(ObservationBulkModel(data, timestamp)) - ).map { observationType.addObservationType(it) } + ).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() @@ -183,7 +194,11 @@ abstract class Observation( fun storeData(data: List, onCompletion: () -> Unit) { val dataSchemas = ObservationDataEntity.fromData(observationIds.toSet(), data) - .map { observationType.addObservationType(it) } + .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() @@ -248,7 +263,10 @@ abstract class Observation( private fun observationShutdown(scheduleId: String) { val observationId = scheduleIds.remove(scheduleId) - observationId?.let { observationIds.remove(it) } + observationId?.let { + observationIds.remove(it) + observationTypes.remove(it) + } if (observationIds.isEmpty()) { config.clear() configChanged = false 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 90% 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..3bb85c989 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,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 data class ObservationBulkModel( val data: Any, diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationDataManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt similarity index 80% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationDataManager.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt index f622a72ce..317906e35 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationDataManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.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.observations +package io.redlink.more.observations import dev.tmapps.konnection.Konnection import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.entities.ObservationDataEntity -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope +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.Scope +import io.redlink.more.scopes.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume abstract class ObservationDataManager(private val repository: MainRepository) { private var countJob: Job? = null @@ -35,6 +38,9 @@ abstract class ObservationDataManager(private val repository: MainRepository) { 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() + } } } @@ -57,7 +63,15 @@ abstract class ObservationDataManager(private val repository: MainRepository) { scheduleCount.remove(scheduleId) } - abstract fun sendData(onCompletion: (Boolean) -> Unit = {}) + abstract fun sendData(immediately: Boolean = false, onCompletion: (Boolean) -> Unit = {}) + + suspend fun sendData(immediately: Boolean): Boolean { + return suspendCancellableCoroutine { cont -> + sendData(immediately) { success -> + if (cont.isActive) cont.resume(success) + } + } + } fun listenToDatapointCountChanges() { if (countJob == null) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt similarity index 74% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt index 9c8bfb567..0653fae83 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -8,16 +8,16 @@ * (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.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.observations.garmin.GarminObservation -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.scopes.Scope -import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager -import io.redlink.more.more_app_mutliplatform.services.store.CredentialRepository +import io.redlink.more.database.repository.MainRepository +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.Scope +import io.redlink.more.services.notification.NotificationManager +import io.redlink.more.services.store.CredentialRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow @@ -34,7 +34,7 @@ abstract class ObservationFactory( val studyObservationTypes: StateFlow> = _studyObservationTypes init { - observations.add(SimpleQuestionObservation(repository)) + observations.add(QuestionObservation(repository)) observations.add(LimeSurveyObservation(repository)) observations.add(GarminObservation(repository)) Scope.launch(Dispatchers.IO) { @@ -60,8 +60,8 @@ abstract class ObservationFactory( } fun studySensorPermissions() = - observations.filter { it.observationType.observationType in studyObservationTypes.value } - .map { it.observationType.sensorPermissions }.flatten().toSet() + observations.filter { observationMatchesStudyTypes(it, studyObservationTypes.value) } + .flatMap { it.observationType.sensorPermissions }.toSet() fun setNotificationManager(notificationManager: NotificationManager) { observations.forEach { it.setNotificationManager(notificationManager) } @@ -69,13 +69,17 @@ abstract class ObservationFactory( fun observationTypes() = observations.map { it.observationType.observationType }.toSet() + fun getMatchingObservationTypes(types: Set): Set = + observations.filter { it.observationType.matchesAny(types) } + .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 } + observations.filter { observationMatchesStudyTypes(it, studyObservationTypes.value) } .flatMap { it.bleDevicesNeeded() }.toSet() Napier.i(tag = "ObservationFactory::bleDevicesNeeded") { "BLE observation types: $bleTypes" } return bleTypes @@ -96,7 +100,9 @@ abstract class ObservationFactory( fun observation(type: String): Observation? { Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } - return observations.firstOrNull { it.observationType.observationType == type }?.apply { + return observations.firstOrNull { + it.observationType.matches(type) + }?.apply { if (!this.observationDataManagerAdded()) { Napier.i(tag = "ObservationFactory::observation") { "Adding data manager to observation of type: $type" } setDataManager(dataManager) @@ -105,6 +111,9 @@ abstract class ObservationFactory( } private fun studyObservations() = - observations.filter { it.observationType.observationType in studyObservationTypes.value } + 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 95% 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 74f1f545e..a520277c0 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,13 +8,13 @@ * (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.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope +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.StudyScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.cancellable @@ -52,7 +52,7 @@ class ObservationManager( } val firstCall = ceil(Clock.System.now().toEpochMilliseconds() / 60_000.0).toLong() * 60_000 val initialDelay = firstCall - Clock.System.now().toEpochMilliseconds() - StudyScope.repeatedLaunch(30000L, Dispatchers.IO, initialDelay) { + StudyScope.repeatedLaunch(60000L, Dispatchers.IO, initialDelay) { updateTaskStates() } StudyScope.launch(Dispatchers.IO) { @@ -188,7 +188,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) @@ -245,9 +245,8 @@ class ObservationManager( private suspend fun findOrCreateObservation(scheduleId: String): ScheduleEntity? { return repositories.schedule.scheduleWithId(scheduleId).firstOrNull()?.let { - val fixedScheduleSchema = it Napier.d(tag = "ObservationManager::findOrCreateObservation") { "Found Schema $it" } - if (findOrCreateObservation(fixedScheduleSchema)) fixedScheduleSchema else null + if (findOrCreateObservation(it)) it else null } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationStates.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt similarity index 60% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationStates.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt index 0731313c4..d3c6c4e21 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationStates.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt @@ -1,4 +1,15 @@ -package io.redlink.more.more_app_mutliplatform.observations +/* + * 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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines import kotlinx.coroutines.flow.MutableStateFlow 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..0971a8a75 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/garmin/GarminObservation.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.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 74% 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 6fee72fe7..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,16 +8,16 @@ * (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.database.repository.MainRepository -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(repos: MainRepository) : @@ -37,10 +37,7 @@ class LimeSurveyObservation(repos: MainRepository) : 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" } @@ -57,9 +54,17 @@ class LimeSurveyObservation(repos: MainRepository) : } 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/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/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..4bbd37cd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GarminType.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.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 87% 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 7c58abfc2..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()) { } \ 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/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/PolarVerityHeartRateType.kt similarity index 88% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt rename to shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/PolarVerityHeartRateType.kt index 1ac63aa81..83e3c40b3 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/observationTypes/PolarVerityHeartRateType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/PolarVerityHeartRateType.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 PolarVerityHeartRateType(sensorPermissions: Set) : ObservationType("polar-verity-observation", sensorPermissions) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/QuestionType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/QuestionType.kt new file mode 100644 index 000000000..39d012f88 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/QuestionType.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 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 63% 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 bf449eef0..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,14 +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.database.repository.MainRepository -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(repos: MainRepository) : - Observation(repos, 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/more_app_mutliplatform/registration/RegistrationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt similarity index 68% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt rename to shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt index 9f1170dbc..5a7a3f3bc 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/registration/RegistrationService.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt @@ -1,21 +1,31 @@ -package io.redlink.more.more_app_mutliplatform.registration +/* + * 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.registration import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import dev.tmapps.konnection.Konnection 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.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.getPlatform -import io.redlink.more.more_app_mutliplatform.models.CredentialModel -import io.redlink.more.more_app_mutliplatform.models.LoginModel -import io.redlink.more.more_app_mutliplatform.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -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.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 @@ -78,6 +88,9 @@ class RegistrationService( addObservationPermissions(it) } _error.value = networkError + if (networkError != null) { + Napier.e(tag = "RegistrationService::sendRegistrationToken") { "Error sending registration token: $networkError" } + } }.second.invokeOnCompletion { _isLoading.value = false } @@ -92,7 +105,10 @@ class RegistrationService( val studyConsent = StudyConsent( consent = true, observations = study.observations.map { - ObservationConsent(observationId = it.observationId, active = true) + ObservationConsent( + observationId = it.observationId, + active = true + ) }, consentInfoMD5 = MD5().digest(study.consentInfo.toByteArray()) .encodeBase64(), @@ -107,7 +123,7 @@ class RegistrationService( studyConsent: StudyConsent, ) { _isLoading.value = true - StudyScope.launch(Dispatchers.IO) { + Scope.launch(Dispatchers.IO) { val (config, networkError) = shared.networkService.sendConsent( _validLoginModel.value!!, studyConsent @@ -122,21 +138,22 @@ class RegistrationService( } val credentialModel = CredentialModel(config.credentials.apiId, config.credentials.apiKey) - if (shared.credentialRepository.store(credentialModel)) { - val (study, error) = shared.networkService.getStudyConfig() - _error.value = error - study?.let { study -> - shared.observationFactory.clearNeededObservationTypes() + 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() - } ?: run { - if (_error.value == null) { - _error.value = NetworkServiceError(null, "Could not get study") - } + } else { + _error.value = NetworkServiceError(null, "Could not store credentials") + } + } ?: run { + if (_error.value == null) { + _error.value = NetworkServiceError(null, "Could not get study") } - } else { - _error.value = NetworkServiceError(null, "Could not store credentials") } + } }.second.invokeOnCompletion { _isLoading.value = false diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt b/shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt similarity index 96% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt rename to shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt index b96c1558c..8490b77d3 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/Scope.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.scopes +package io.redlink.more.scopes import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.extensions.repeatEveryFewSeconds -import io.redlink.more.more_app_mutliplatform.util.createUUID +import io.redlink.more.extensions.repeatEveryFewSeconds +import io.redlink.more.util.createUUID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt b/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt similarity index 83% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt rename to shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt index 56e2f295b..54b612707 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/scopes/StudyScope.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt @@ -1,4 +1,15 @@ -package io.redlink.more.more_app_mutliplatform.scopes +/* + * 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart 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..21573b81f --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.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.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 88% 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 3141e654e..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,10 +8,10 @@ * (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.more_app_mutliplatform.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.BluetoothDeviceEntity interface BluetoothConnector : BluetoothConnectorObserver, Closeable { 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 86% 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 dab60826e..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,9 +8,9 @@ * (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.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.BluetoothDeviceEntity interface BluetoothConnectorObserver { 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/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt similarity index 89% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt index b40c5bead..f15cde444 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/bluetooth/BluetoothStateManagement.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt @@ -1,8 +1,19 @@ -package io.redlink.more.more_app_mutliplatform.services.bluetooth +/* + * 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.bluetooth import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.entities.BluetoothDeviceEntity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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..1f10fba3a --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/ScanMode.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.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..d9258fa67 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/polar/PolarStates.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.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..a6caf484c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkClients.kt @@ -0,0 +1,231 @@ +/* + * 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.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..457111fb4 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkService.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 NetworkService( + endpointRepository: EndpointRepository, + credentialRepository: CredentialRepository, +) { + + private val networkClients = NetworkClients(credentialRepository, endpointRepository) + + fun baseUrl() = networkClients.baseUrl() + + 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)) + } + } + + 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)) + } + } + + 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)) + } + } + + suspend fun getStudyConfig(credentials: CredentialModel? = null): 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)) + } + } + + 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)) + } + } + + 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)) + } + } + + 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() + } + } + + fun getBasicAuthHeader(): String? = networkClients.basicAuthHeader() + + 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 + } + + fun garminSSOCallbackUrl(): Url? { + return getGarminSSOUrl()?.let { + Url("$it/callback") + } + } + + 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 + } + } + + 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) + } + +} \ No newline at end of file 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..0bcba5c09 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt @@ -0,0 +1,17 @@ +/* + * 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 + +enum class NotificationActionHandler { + NON, + 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/services/notification/NotificationManager.kt similarity index 62% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt rename to shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt index 3ea5f9def..b10f18f74 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/notification/NotificationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.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.services.notification +package io.redlink.more.services.notification +import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.entities.NotificationEntity -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.extensions.mapQueryParams -import io.redlink.more.more_app_mutliplatform.models.NotificationModel -import io.redlink.more.more_app_mutliplatform.models.NotificationStatusType -import io.redlink.more.more_app_mutliplatform.models.ScheduleState -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.scopes.Scope -import io.redlink.more.more_app_mutliplatform.scopes.StudyScope -import io.redlink.more.more_app_mutliplatform.services.network.NetworkService -import io.redlink.more.more_app_mutliplatform.services.store.SharedStorageRepository +import io.redlink.more.Shared +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.Scope +import io.redlink.more.services.network.NetworkService +import io.redlink.more.services.store.SharedStorageRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -32,11 +36,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext interface LocalNotificationListener { - fun displayNotification(notification: NotificationEntity) + fun displayNotification(notification: NotificationEntity, badgeCount: Int = 0) + + fun clearScheduledNotifications(notifications: List) fun deleteNotificationFromSystem(notificationId: String) @@ -53,17 +58,22 @@ class NotificationManager( private val deeplinkManager: DeeplinkManager, private val sharedStorageRepository: SharedStorageRepository ) { - val _unreadUserCount = MutableStateFlow(0) + private val _unreadUserCount = MutableStateFlow(0) + + @NativeCoroutines val unreadUserCount: StateFlow = _unreadUserCount init { - Scope.launch(Dispatchers.IO) { - repository.notification.getUnreadUserNotifications().collect { notificationList -> - _unreadUserCount.update { notificationList.count() } - withContext(Dispatchers.Main) { - localNotificationListener.updateBadgeCount(notificationList.count()) + Scope.launch { + repository.notification.getAllUserFacingNotifications() + .collect { notifications -> + notifications.filter { !it.read }.let { + withContext(Dispatchers.Main) { + _unreadUserCount.value = it.size + localNotificationListener.updateBadgeCount(it.size) + } + } } - } } } @@ -95,6 +105,40 @@ class NotificationManager( ) } + 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( shared: Shared, notification: NotificationEntity, @@ -114,22 +158,32 @@ class NotificationManager( displayNotification: Boolean ) { if (notification.title != null && notification.notificationBody != null) { - StudyScope.launch(Dispatchers.IO) { + Scope.launch { + Napier.i { "Storing notification: ${notification.title} - ${notification.notificationBody}" } repository.notification.storeNotification(notification) - } - if (displayNotification) { - Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } - localNotificationListener.displayNotification(notification) + if (displayNotification) { + Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } + withContext(Dispatchers.Main) { + localNotificationListener.displayNotification( + notification, + unreadUserCount.value + ) + } + } } } } fun storeNotifications(notifications: List) { - StudyScope.launch(Dispatchers.IO) { + Scope.launch { repository.notification.storeNotifications(notifications) } } + fun displayNotification(notification: NotificationEntity) { + localNotificationListener.displayNotification(notification, unreadUserCount.value) + } + suspend fun downloadMissedNotifications() { Napier.d { "Updating notifications" } storeNotifications(NotificationEntity.toEntityList(networkService.downloadMissedNotifications())) @@ -188,44 +242,45 @@ class NotificationManager( notificationId: String, deeplink: String? = null ) { - if (deeplink == null || deeplink.contains(DeeplinkManager.TASK_DETAILS) || deeplink.contains( - DeeplinkManager.OBSERVATION_DETAILS + if (deeplink == null || deeplink.contains(NavigationRoute.SCHEDULE_DETAILS.route) || deeplink.contains( + NavigationRoute.OBSERVATION_DETAILS.route ) ) { markNotificationAsRead(notificationId) } } + fun handleNotificationInteraction( - notification: NotificationModel, - protocolReplacement: String? = null, - hostReplacement: String? = null, - handler: ((NotificationActionHandler, String) -> Unit) + notificationId: String, + deepLink: String?, + handler: ((NotificationActionHandler, DeepLinkData?) -> Unit) ) { - notification.deepLink?.let { deepLink -> + deepLink?.let { Scope.launch { val state = checkIfCompletedOrRead(deepLink).cancellable().firstOrNull() if (state != null) { if (NotificationStatusType.READ == state) repository.notification.setNotificationReadStatus( - notification.notificationId, + notificationId, true ) if (NotificationStatusType.COMPLETED == state) repository.notification.setNotificationCompletedStatus( - notification.notificationId, + notificationId, true ) } - deeplinkManager.modifyDeepLink(deepLink, protocolReplacement, hostReplacement) - .firstOrNull()?.let { modifiedDeepLink -> - if (modifiedDeepLink.contains(DeeplinkManager.TASK_DETAILS) || modifiedDeepLink.contains( - DeeplinkManager.OBSERVATION_DETAILS + 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(notification.notificationId) + markNotificationAsRead(notificationId) } } withContext(Dispatchers.Main) { @@ -233,12 +288,17 @@ class NotificationManager( } } ?: run { withContext(Dispatchers.Main) { - markNotificationAsRead(notification.notificationId) + markNotificationAsRead(notificationId) } } } } ?: run { - markNotificationAsRead(notification.notificationId) + markNotificationAsRead(notificationId) + Scope.launch { + deeplinkManager.getNotificationViewDeepLink(notificationId).firstOrNull()?.let { + handler(NotificationActionHandler.DEEPLINK, it) + } + } } } @@ -246,10 +306,10 @@ class NotificationManager( notificationDeeplink: String, ): Flow = flow { - var state: NotificationStatusType? = null + var state: NotificationStatusType? val queryParams = notificationDeeplink.mapQueryParams() - val observationId = queryParams["observationId"] + val observationId = queryParams[NavigationRouteParameter.OBSERVATION_ID.key] if (observationId.isNullOrEmpty() || repository.observation.observationById(observationId.first()) .firstOrNull() == null @@ -306,16 +366,36 @@ class NotificationManager( localNotificationListener.updateBadgeCount(0) } - fun updateNotificationBadgeCount() { - Scope.launch { - repository.notification.getUnreadUserNotifications().firstOrNull().let { - withContext(Dispatchers.Main) { - localNotificationListener.updateBadgeCount(it?.count() ?: 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 suspend fun updateStudy(shared: Shared, data: Map) { val oldStudyState = data[STUDY_OLD_STATE]?.let { StudyState.getState(it) } 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/CredentialRepository.kt similarity index 92% 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/CredentialRepository.kt index 88bdc64a6..5e1c3f07b 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/CredentialRepository.kt @@ -8,11 +8,11 @@ * (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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.extensions.mapState -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 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/EndpointRepository.kt similarity index 91% 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/EndpointRepository.kt index 11a0a8ff7..85f071992 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/EndpointRepository.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.services.store +package io.redlink.more.services.store -import io.redlink.more.more_app_mutliplatform.util.validateAndNormalizeUrl +import io.redlink.more.util.validateAndNormalizeUrl class EndpointRepository(private val sharedStorageRepository: SharedStorageRepository) { private var cache: String = "" 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..270e41922 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.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.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/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt new file mode 100644 index 000000000..9fec88bd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.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.util + +import io.github.aakira.napier.Napier + +// Object for Swift to access the Napier Logger +object KMMLogger { + 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) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt new file mode 100644 index 000000000..03afbe2fd --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt @@ -0,0 +1,19 @@ +/* + * 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 + +class RegexData { + companion object { + 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/more_app_mutliplatform/util/StringUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt similarity index 83% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt rename to shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt index 152174d38..3ffa5b576 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StringUtils.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt @@ -1,4 +1,15 @@ -package io.redlink.more.more_app_mutliplatform.util +/* + * 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 /** * Validates and normalizes a URL string by adding https:// scheme if no scheme is present @@ -81,7 +92,6 @@ private fun String.isValidHostPart(): Boolean { private fun String.isValidDomain(): Boolean { if (this.isEmpty() || this.length > 253) return false if (this.startsWith(".") || this.endsWith(".")) return false - if (this.contains("..")) return false val labels = this.split('.') if (labels.isEmpty()) return false 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/more_app_mutliplatform/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt similarity index 95% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt index fdbe23188..45d9d6835 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.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.viewModels +package io.redlink.more.viewModels import io.ktor.utils.io.core.Closeable import kotlinx.coroutines.CoroutineScope diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt similarity index 87% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt index 4d3e15990..66faf90aa 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt @@ -1,4 +1,15 @@ -package io.redlink.more.more_app_mutliplatform.viewModels +/* + * 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 @@ -37,6 +48,9 @@ object ViewManager { @NativeCoroutines val showGarminConnectView: StateFlow = _showGarminConnectView + @NativeCoroutines + val activeStudy: StateFlow = _activeStudy + private fun canOpenNewView(): Boolean { return _activeStudy.value && !_studyIsUpdating.value && 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/viewModels/bluetoothConnection/BluetoothController.kt similarity index 89% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt index b10d49cfb..155ea0508 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt @@ -8,21 +8,21 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection +package io.redlink.more.viewModels.bluetoothConnection import io.github.aakira.napier.Napier import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity -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.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.scopes.Scope -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.BluetoothStateManagement -import io.redlink.more.more_app_mutliplatform.services.bluetooth.ScanMode -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager +import io.redlink.more.database.entities.BluetoothDeviceEntity +import io.redlink.more.database.repository.BluetoothDeviceRepository +import io.redlink.more.extensions.anyNameIn +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 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..94a846631 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/PolarController.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.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 86% 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 c4e3bd3c0..c97a2fbd5 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,16 @@ * (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.MainRepository -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.scopes.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.scopes.Scope +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.transform diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt similarity index 77% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt index 0c0bd6a79..e75075719 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt @@ -1,13 +1,24 @@ -package io.redlink.more.more_app_mutliplatform.viewModels.garminConnectOAuth +/* + * 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.more_app_mutliplatform.extensions.overlaps -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.viewModels.CoreViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager +import io.redlink.more.extensions.overlaps +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( 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..f24bf9c13 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt @@ -0,0 +1,106 @@ +/* + * 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.observations.ObservationFactory +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 + + 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() + 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/more_app_mutliplatform/viewModels/login/CoreLoginViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt similarity index 75% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/login/CoreLoginViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt index 772a1871d..35c522971 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/login/CoreLoginViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt @@ -8,11 +8,11 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.login +package io.redlink.more.viewModels.login -import io.redlink.more.more_app_mutliplatform.models.LoginModel -import io.redlink.more.more_app_mutliplatform.registration.RegistrationService -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import io.redlink.more.models.LoginModel +import io.redlink.more.registration.RegistrationService +import io.redlink.more.viewModels.CoreViewModel class CoreLoginViewModel(private val registrationService: RegistrationService) : CoreViewModel() { 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 87% 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 a90f840ad..f09bce34a 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,14 +8,14 @@ * (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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.extensions.mapState -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 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.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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 77% 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 f16e85ea5..bc674a7ee 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,14 +8,15 @@ * (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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines -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 io.redlink.more.extensions.set +import io.redlink.more.models.NotificationModel +import io.redlink.more.navigation.model.DeepLinkData +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 @@ -23,8 +24,6 @@ import kotlinx.coroutines.flow.cancellable 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() private val _notificationList: MutableStateFlow> = @@ -62,12 +61,11 @@ class CoreNotificationViewModel( fun handleNotificationAction( notification: NotificationModel, - handler: ((NotificationActionHandler, String) -> Unit) + handler: ((NotificationActionHandler, DeepLinkData?) -> Unit) ) { notificationManager.handleNotificationInteraction( - notification, - protocolReplacement, - hostReplacement, + notification.notificationId, + notification.deepLink, handler ) } 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 73% 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 98be41a9f..ca3bc4a1d 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,13 +8,13 @@ * (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.MainRepository -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.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine @@ -36,11 +36,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 + ) } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CoreConsentViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt similarity index 72% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CoreConsentViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt index 621e66a35..30573396e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/permission/CoreConsentViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.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.viewModels.permission +package io.redlink.more.viewModels.permission import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.extensions.mapState -import io.redlink.more.more_app_mutliplatform.models.PermissionModel -import io.redlink.more.more_app_mutliplatform.registration.RegistrationService -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import io.redlink.more.extensions.mapState +import io.redlink.more.models.PermissionModel +import io.redlink.more.registration.RegistrationService +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.StateFlow class CoreConsentViewModel( 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/viewModels/schedules/CoreScheduleViewModel.kt similarity index 78% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/schedules/CoreScheduleViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt index 42b79a8c7..1afab2cae 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/schedules/CoreScheduleViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt @@ -8,26 +8,28 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.schedules +package io.redlink.more.viewModels.schedules import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.database.entities.ScheduleEntity -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.extensions.mapState -import io.redlink.more.more_app_mutliplatform.extensions.time -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.observations.DataRecorder -import io.redlink.more.more_app_mutliplatform.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.ObservationStates -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel +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.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 @@ -41,6 +43,7 @@ class CoreScheduleViewModel( private val scheduleListType: ScheduleListType, val coreFilterModel: CoreDashboardFilterViewModel ) : CoreViewModel() { + private val scheduleStates = mutableSetOf() private var originalScheduleList = emptySet() private val _schedulesByDate = MutableStateFlow>>(emptyMap()) @@ -56,7 +59,8 @@ class CoreScheduleViewModel( CoroutineScope(parentJob) ) { it.mapValues { entry -> - entry.value.filter { it != Observation.ERROR_DEVICE_NOT_CONNECTED }.toSet() + entry.value.filter { it != Observation.ERROR_DEVICE_NOT_CONNECTED } + .toSet() } } @@ -69,6 +73,23 @@ class CoreScheduleViewModel( 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 -> @@ -87,9 +108,9 @@ class CoreScheduleViewModel( } launchScope { - repos.schedule.allSchedulesWithStatus(done = scheduleListType == ScheduleListType.COMPLETED) + repos.schedule.allSchedulesWithStates(scheduleStates) .cancellable() - .collect { schedules -> + .collectLatest { schedules -> val newList = when (scheduleListType) { ScheduleListType.COMPLETED -> createCompletedModels(schedules) ScheduleListType.RUNNING -> createRunningModels(schedules) 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/viewModels/settings/CoreSettingsViewModel.kt similarity index 85% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/settings/CoreSettingsViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt index de84f4e87..14376c680 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/settings/CoreSettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.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.viewModels.settings +package io.redlink.more.viewModels.settings import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.database.entities.StudyEntity -import io.redlink.more.more_app_mutliplatform.models.PermissionModel -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import io.redlink.more.Shared +import io.redlink.more.database.entities.StudyEntity +import io.redlink.more.models.PermissionModel +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable 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..88116d84c --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt @@ -0,0 +1,82 @@ +/* + * 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.redlink.more.database.repository.MainRepository +import io.redlink.more.models.QuestionModel +import io.redlink.more.observations.Observation +import io.redlink.more.observations.ObservationFactory +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 -> + observation?.let { observation -> + observation.start( + questionModel.observationId, + questionModel.scheduleId, + notificationId + ) + observation.storeData(mapOf(questionModel.type.observationDataResponseKey to data)) { + scheduleId?.let { + observation.stopAndSetDone(it) + } + } + } + } + } +} 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 75% 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 a75a2b9f8..31f0ccae2 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,13 +8,13 @@ * (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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.database.entities.BluetoothDeviceEntity -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel -import io.redlink.more.more_app_mutliplatform.viewModels.bluetoothConnection.BluetoothController +import io.redlink.more.database.entities.BluetoothDeviceEntity +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( 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/viewModels/studydetails/CoreStudyDetailsViewModel.kt similarity index 88% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/studydetails/CoreStudyDetailsViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt index b12f0e86a..22e19cf86 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/studydetails/CoreStudyDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.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.viewModels.studydetails +package io.redlink.more.viewModels.studydetails import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.Shared -import io.redlink.more.more_app_mutliplatform.models.StudyDetailsModel -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import io.redlink.more.Shared +import io.redlink.more.models.StudyDetailsModel +import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable 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/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt similarity index 83% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt index 8024a8402..9fe8e6324 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.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.viewModels.taskCompletionBar +package io.redlink.more.viewModels.taskCompletionBar import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -import io.redlink.more.more_app_mutliplatform.models.TaskCompletion -import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +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 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/viewModels/tasks/CoreTaskDetailsViewModel.kt similarity index 87% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/tasks/CoreTaskDetailsViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt index 07c13044b..341dc1172 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/tasks/CoreTaskDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt @@ -8,16 +8,16 @@ * (see https://www.apache.org/licenses/LICENSE-2.0 and * https://commonsclause.com/). */ -package io.redlink.more.more_app_mutliplatform.viewModels.tasks +package io.redlink.more.viewModels.tasks import io.ktor.utils.io.core.Closeable -import io.redlink.more.more_app_mutliplatform.database.repository.MainRepository -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.observations.Observation -import io.redlink.more.more_app_mutliplatform.observations.ObservationStates -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.TaskDetailsModel +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 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..423f36be8 --- /dev/null +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -0,0 +1,7 @@ + + + Please open the app and start the observation! + + app + io.redlink.more + 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..1486e9a21 --- /dev/null +++ b/shared/src/commonMain/moko-resources/de/strings.xml @@ -0,0 +1,4 @@ + + + Bitte öffne die App und starte die Beobachtung! + \ 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/ImMemoryStorageRepository.kt similarity index 95% 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/ImMemoryStorageRepository.kt index 2c0c93114..f3b7d3a4d 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/more_app_mutliplatform/services/store/ImMemoryStorageRepository.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/ImMemoryStorageRepository.kt @@ -8,7 +8,9 @@ * (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 + +import io.redlink.more.services.store.SharedStorageRepository class ImMemoryStorageRepository : SharedStorageRepository { 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/more_app_mutliplatform/database/DatabaseManager.ios.kt b/shared/src/iosMain/kotlin/io/redlink/more/database/DatabaseManager.ios.kt similarity index 93% rename from shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.ios.kt rename to shared/src/iosMain/kotlin/io/redlink/more/database/DatabaseManager.ios.kt index 9d6c7bc64..c72e72f4a 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/more_app_mutliplatform/database/DatabaseManager.ios.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/database/DatabaseManager.ios.kt @@ -1,4 +1,4 @@ -package io.redlink.more.more_app_mutliplatform.database +package io.redlink.more.database import androidx.room.Room import androidx.room.RoomDatabase 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 94% 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 14ec254b6..2b63ea041 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,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 kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSJSONSerialization 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 96% 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 5b1c862a1..fccc908e1 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,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.engine.darwin.Darwin 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/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 From 9107ba541fa4d5f66610f994b71a84444a835a4f Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:07:51 +0100 Subject: [PATCH 47/59] 291 implement shared unit tests (#370) * Initial commit * #117, #118, #119: New App Store Setup, PlayStore Setup, Firebase Setup, renamed the apps and refactored the package name * #120: Fastlane Adaptions * #120: Fastlane Adaptions * #120: Fastlane Adaptions * #156: Fixing a login bug * #120: Fixing a fastlane deployment issue * #130: Implemented the OpenAPI Generator, implemented all models into the apps, all api clients into the NetworkService.kt and moved the clients into its dedicated NetworkClients.kt class * #130: Pipeline adaption to necessary openapi generator task * #130: Pipeline adaption to necessary openapi generator task * Critical bug fix * Critical bug fix * Critical bug fix * #178:Fixed a bug where Garmin Connect Login would open, even if there is no garmin observation set * Android Version Upgrade * p2r: #360: Fixed these issues: - Past schedules are now correctly removed from the dashboard - ViewModels are now completely reloaded and use the new improved coroutines concept to load data - Small cleanup of unused functions in ScheduleRepository.kt * Android Version update * #201: Fixed an issue, where the answers were listed wrong * #230: Fixed a bug, that kept the app from reloading the study once in paused state * Fixed leaked Google Keys * Fixed a study login issue and added buttons to escape paused study states * #228: Fixeda bug where LimeSurvey would crash the phone or would not close after completing it * #230: Fixed an issue where the data collection process would not run * #230: Github build failure inspection * #230: Github build failure inspection * #230: Github build failure inspection * Fixing a pipeline * #236: Implemented Local Notification Reminder when observation are starting. This schedules and stores notifications in advance, and displays them on the certain time. It also only schedules 10 notifications at a time and reschedules them as soon as the current notification is displayed, the app goes into foreground or once a day using the Background Task. It also checks to cancel all scheduled notifications when necessary. I also implemented multiple bug fixes and improvements: - Improved a workflow issue, that crashed execution of current study updates and tasks running when the app goes into foreground - Fixed an issue that crashed the foreground service on Android - Implemented a shared localization framework, so we can localize text in the shared module * #236: PR fixes * #236: Small addition which should keep the app from scheduling multiple reminders for one schedule * #236: Fixed multiple issues, regarding the cancellation of notifications, setting the right app badge and reduced scheduling complexity. * #184: Implemented a part matching observation type system to accept all kinds of observations that include a given prefix, suffix or includes, starting with the garmin observation * #184: Small code cleanup * #236: Small change * #236: Small change * #264: Added the new observation schema, so the app knows, which schedules it should create a reminder for Also upgraded a few kotlin versions * #264: Changed * #265: Fixed a deeplink issues that hindered the reminder to start observations directly * #265: Small change in handling Deeplinks * #265: Small change in handling Deeplinks * #274: Extended the question observation type so, that different types can be answerd with the same observtion * #274: Enum Name change * #274: Fixed a bug where data would not be sent directly if a study state changes * #217: Implemented a publishing process to extract the shared module * #217: Fixed publishing pipeline and upgraded android jvm target to 17 * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Fixed publishing pipeline * #217: Merged everything back to More and checked to keep all necessary identifier from More * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Merge preparation * #217: Pipeline changes * #217: Fixes * #217: Fixes * Small changes * Small changes * #217: PR fixes and implemented, that data are immediately sent when the app is closed, so they don't hang behind * #285: Fixed the deeplink issue * #285: Fixed notification storage issues and improved deeplink handling * #285: Fixed a small update issue * #285: Fixed a small update issue * #217: PR fix * Small cleanup * Small cleanup * Path change * Path change * iOS Google Service API Key removal * #290: Removed the Google API key and implemented it into the pipeline * Readme Update * Small changes * #285: Fixed an issue, where the deeplink would not forward to a task, when the app was completely, due to the schedule update process not being finished * #282: Fixed an issue where the wrong observation type would be set, if one observation type would use another like multiple-choice-question-observation uses question-observation * #291: Implemented basic unit tests into the shared module * #291: Implemented tests for DeeplinkManager.kt and added mock to run these tests * #291: Implemented tests for ObservationDataManager and ObservationManager. Also included new CorooutineDispatcher Class to better control coroutines during testing * #291: Implemented CoreViewModel Tests * #291: Fixes * #291: test fixes * #291: test fixes * #291: test fixes * #291: Cleanup * #291: Fastlane build fix * #291: Implemented a google api key setup script * #291: Implemented a google api key setup script * #291: XCODE version implementations * #291: XCODE version implementations * #291: XCODE version implementations * #291: XCODE version implementations * #291: XCODE version implementations * #291: XCODE version implementations * #291: XCODE version implementations * #291: Pipeline fix * #291: Pipeline fix * #291: Pipeline fix * #291: Pipeline fix * #291: PR fix --- .github/workflows/build.yml | 9 +- .github/workflows/deploy-beta.yml | 9 +- README.md | 24 +- androidApp/fastlane/Fastfile | 30 +- androidApp/fastlane/README.md | 8 + .../more/app/android/MoreApplication.kt | 4 +- .../ObservationErrorListView.kt | 15 +- .../ObservationErrorViewModel.kt | 15 +- .../questionnaire/QuestionViewModel.kt | 11 - .../questionType/QuestionnaireCheckboxes.kt | 11 - .../activities/subcomponents/ExitButton.kt | 11 - .../activities/subcomponents/ReloadButton.kt | 13 +- .../tasks/ObservationActionButton.kt | 11 - .../more/app/android/firebase/FCMService.kt | 1 - .../more/app/android/util/AlarmUtils.kt | 11 - .../app/android/workers/DataUploadWorker.kt | 11 +- .../workers/NotificationDataHandlerWorker.kt | 2 +- iosApp/fastlane/Fastfile | 12 +- iosApp/fastlane/README.md | 16 + iosApp/iosApp.xcodeproj/project.pbxproj | 40 +- iosApp/iosApp/AppDelegate.swift | 11 +- .../BackgroundTasks/DailyBackgroundTask.swift | 15 +- .../ObservationReminderBackgroundTask.swift | 11 - .../iOSObservationDataManager.swift | 2 +- .../iosApp/Services/DataUploadManager.swift | 2 +- iosApp/iosApp/Services/FCMService.swift | 7 +- .../Views/Components/ReloadButton.swift | 2 +- iosApp/iosApp/Views/Consent/ConsentView.swift | 24 +- iosApp/iosApp/Views/Login/LoginView.swift | 71 +-- .../iosApp/Views/Login/ScanQRCodeView.swift | 49 +- .../Registration/RegistrationObservable.swift | 2 +- .../TaskCompletionBarViewModel.swift | 2 +- .../Views/TaskDetails/TaskDetailsView.swift | 9 +- setup_google_services.sh | 14 + shared/build.gradle.kts | 21 + .../more/database/DatabaseManager.android.kt | 11 - .../kotlin/io/redlink/more/AlertController.kt | 11 - .../kotlin/io/redlink/more/Shared.kt | 59 ++- .../io/redlink/more/database/AppDatabase.kt | 11 - .../more/database/dao/BluetoothDeviceDao.kt | 11 - .../redlink/more/database/dao/DataPointDao.kt | 11 - .../entities/BluetoothDeviceEntity.kt | 11 - .../more/database/entities/DataPointEntity.kt | 11 - .../more/database/entities/ScheduleEntity.kt | 11 - .../more/database/entities/StudyEntity.kt | 11 - .../more/database/migrations/Migration_1_2.kt | 11 - .../repository/BluetoothDeviceRepository.kt | 27 +- .../BluetoothDeviceRepositoryImpl.kt | 41 ++ .../repository/DataPointCountRepository.kt | 83 +--- .../DataPointCountRepositoryImpl.kt | 97 ++++ .../database/repository/MainRepository.kt | 34 +- .../database/repository/MainRepositoryImpl.kt | 22 + .../repository/NotificationRepository.kt | 187 +------- .../repository/NotificationRepositoryImpl.kt | 159 +++++++ .../repository/ObservationDataRepository.kt | 62 +-- .../ObservationDataRepositoryImpl.kt | 77 ++++ .../repository/ObservationRepository.kt | 112 ++--- .../repository/ObservationRepositoryImpl.kt | 101 +++++ .../database/repository/ScheduleRepository.kt | 160 +------ .../repository/ScheduleRepositoryImpl.kt | 188 ++++++++ .../database/repository/StudyRepository.kt | 88 +--- .../repository/StudyRepositoryImpl.kt | 105 +++++ .../more/extensions/FunctionExtension.kt | 11 - .../extensions/ScheduleEntityExtenstion.kt | 10 - .../more/extensions/StringExtension.kt | 11 - .../more/extensions/StudyStateExtension.kt | 11 - .../redlink/more/models/AlertDialogModel.kt | 11 - .../io/redlink/more/models/LoginModel.kt | 11 - .../more/models/NotificationStatusType.kt | 11 - .../models/NotificationTextLocalization.kt | 11 - .../more/models/NotificationTextType.kt | 11 - .../io/redlink/more/models/QuestionType.kt | 11 - .../more/navigation/DeeplinkManager.kt | 247 +--------- .../more/navigation/DeeplinkManagerImpl.kt | 240 ++++++++++ .../more/navigation/model/DeepLinkData.kt | 11 - .../more/navigation/model/NavigationRoute.kt | 11 - .../model/NavigationRouteParameter.kt | 11 - .../redlink/more/observations/Observation.kt | 12 +- .../observations/ObservationDataManager.kt | 37 +- .../more/observations/ObservationFactory.kt | 31 +- .../more/observations/ObservationManager.kt | 50 ++- .../more/observations/ObservationStates.kt | 11 - .../observations/garmin/GarminObservation.kt | 11 - .../observationTypes/GarminType.kt | 11 - .../more/registration/RegistrationService.kt | 22 +- .../io/redlink/more/scopes/MoreDispatchers.kt | 37 ++ .../io/redlink/more/scopes/MoreScope.kt | 25 ++ .../kotlin/io/redlink/more/scopes/Scope.kt | 24 +- .../io/redlink/more/scopes/StudyMoreScope.kt | 7 + .../io/redlink/more/scopes/StudyScope.kt | 33 +- .../more/services/ObservationService.kt | 11 - .../bluetooth/BluetoothStateManagement.kt | 11 - .../more/services/bluetooth/ScanMode.kt | 11 - .../services/bluetooth/polar/PolarStates.kt | 11 - .../more/services/network/NetworkClients.kt | 11 - .../more/services/network/NetworkService.kt | 263 +---------- .../services/network/NetworkServiceImpl.kt | 287 ++++++++++++ .../notification/NotificationActionHandler.kt | 11 - .../notification/NotificationManager.kt | 75 ++-- .../services/store/CredentialRepository.kt | 54 +-- .../store/CredentialRepositoryImpl.kt | 69 +++ .../more/services/store/EndpointRepository.kt | 34 +- .../services/store/EndpointRepositoryImpl.kt | 46 ++ .../kotlin/io/redlink/more/util/FlowuUtils.kt | 11 - .../kotlin/io/redlink/more/util/KMMLogger.kt | 11 - .../kotlin/io/redlink/more/util/RegexData.kt | 11 - .../io/redlink/more/util/StringUtils.kt | 11 - .../redlink/more/viewModels/CoreViewModel.kt | 8 +- .../io/redlink/more/viewModels/ViewManager.kt | 11 - .../bluetoothConnection/PolarController.kt | 11 - .../dashboard/CoreDashboardFilterViewModel.kt | 4 +- .../CoreGarminConnectViewModel.kt | 11 - .../viewModels/login/CoreLoginViewModel.kt | 3 +- .../CoreNotificationFilterViewModel.kt | 6 +- .../CoreNotificationViewModel.kt | 2 +- .../CoreObservationDetailsViewModel.kt | 4 +- .../schedules/CoreScheduleViewModel.kt | 4 +- .../CoreTaskCompletionBarViewModel.kt | 8 +- .../extensions/CollectionExtensionTest.kt | 19 + .../more/extensions/StringExtensionTest.kt | 43 ++ .../io/redlink/more/mocks/DatabaseMock.kt | 32 ++ .../InMemoryStorageRepository.kt} | 15 +- .../more/mocks/MockBluetoothConnector.kt | 28 ++ .../io/redlink/more/mocks/MockDataRecorder.kt | 29 ++ .../redlink/more/mocks/MockDeeplinkManager.kt | 40 ++ .../mocks/MockLocalNotificationListener.kt | 26 ++ .../redlink/more/mocks/MockNetworkService.kt | 54 +++ .../mocks/MockNotificationActionObserver.kt | 16 + .../more/mocks/MockNotificationManager.kt | 34 ++ .../io/redlink/more/mocks/RepositoryMocks.kt | 420 ++++++++++++++++++ .../io/redlink/more/mocks/ServiceMocks.kt | 14 + .../more/navigation/DeeplinkManagerTest.kt | 181 ++++++++ .../ObservationDataManagerTest.kt | 199 +++++++++ .../observations/ObservationManagerTest.kt | 375 ++++++++++++++++ .../notification/NotificationManagerTest.kt | 266 +++++++++++ .../login/CoreLoginViewModelTest.kt | 87 ++++ .../CoreNotificationViewModelTest.kt | 108 +++++ .../CoreObservationDetailsViewModelTest.kt | 87 ++++ .../permission/CoreConsentViewModelTest.kt | 89 ++++ .../schedules/CoreScheduleViewModelTest.kt | 78 ++++ .../CoreStudyDetailsViewModelTest.kt | 94 ++++ .../CoreTaskCompletionBarViewModelTest.kt | 83 ++++ .../tasks/CoreTaskDetailsViewModelTest.kt | 135 ++++++ 143 files changed, 4639 insertions(+), 2081 deletions(-) create mode 100755 setup_google_services.sh create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManagerImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreDispatchers.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/scopes/MoreScope.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyMoreScope.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkServiceImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepositoryImpl.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/extensions/CollectionExtensionTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/extensions/StringExtensionTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt rename shared/src/commonTest/kotlin/io/redlink/more/{ImMemoryStorageRepository.kt => mocks/InMemoryStorageRepository.kt} (72%) create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/MockBluetoothConnector.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDataRecorder.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/MockDeeplinkManager.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/MockLocalNotificationListener.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNetworkService.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationActionObserver.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/MockNotificationManager.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/navigation/DeeplinkManagerTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationDataManagerTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/ObservationManagerTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/services/notification/NotificationManagerTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModelTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModelTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModelTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModelTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModelTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModelTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModelTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModelTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 802fd0590..c233cca33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,7 @@ jobs: 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 @@ -36,10 +37,13 @@ jobs: - 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: '26.2' + xcode-version: ${{ env.XCODE_VERSION }} - name: Build iOS app run: fastlane build @@ -64,5 +68,8 @@ jobs: - 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 index bdeb3643b..02d3fe4fe 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -32,6 +32,7 @@ jobs: 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 @@ -39,10 +40,13 @@ jobs: - 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: '26.0.1' + xcode-version: ${{ env.XCODE_VERSION }} - name: Set FASTLANE_BUILD_NUMBER from tag run: echo "FASTLANE_BUILD_NUMBER=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV @@ -72,6 +76,9 @@ jobs: - 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 diff --git a/README.md b/README.md index a828c120b..111b79925 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,21 @@ The following is an instruction on how to install and configure the project on y ``` 2. Open the project in Android Studio. 3. To successfully build the iOS and Android apps, you need to integrate the Google API Key files - for each platform. - - 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". + 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 @@ -168,6 +177,8 @@ Android apps. 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 @@ -180,6 +191,7 @@ supports multiple app targets, including notification service extensions. 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 diff --git a/androidApp/fastlane/Fastfile b/androidApp/fastlane/Fastfile index f1c54b1ba..47816f9d2 100644 --- a/androidApp/fastlane/Fastfile +++ b/androidApp/fastlane/Fastfile @@ -84,7 +84,7 @@ platform :android do return apk_path end - def setup_google_services + 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") @@ -98,9 +98,13 @@ platform :android do end end + desc "Run all tests" lane :test do - gradle(task: "test", project_dir: "..") + gradle( + task: ":shared:testDebugUnitTest", + project_dir: ".." + ) end desc "Build the Android app (debug)" @@ -109,13 +113,15 @@ platform :android do setup_google_services setup_android_signing - generate_openapi + 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? && @@ -172,6 +178,15 @@ platform :android do 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) @@ -274,6 +289,15 @@ platform :android do 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) diff --git a/androidApp/fastlane/README.md b/androidApp/fastlane/README.md index 7dfa0638a..4022a4cb3 100644 --- a/androidApp/fastlane/README.md +++ b/androidApp/fastlane/README.md @@ -15,6 +15,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do ## Android +### android setup_google_services + +```sh +[bundle exec] fastlane android setup_google_services +``` + + + ### android test ```sh 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 adaab2560..139d1bf47 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 @@ -29,7 +29,7 @@ import io.redlink.more.app.android.util.logging.FirebaseCrashlyticsAntilog import io.redlink.more.database.AppDatabase import io.redlink.more.database.getDatabaseBuilder import io.redlink.more.database.getRoomDatabase -import io.redlink.more.database.repository.MainRepository +import io.redlink.more.database.repository.MainRepositoryImpl import io.redlink.more.models.NotificationTextLocalization import io.redlink.more.napierDebugBuild import io.redlink.more.services.store.SharedPreferencesRepository @@ -98,7 +98,7 @@ class MoreApplication : Application(), DefaultLifecycleObserver { polarConnector = PolarConnector(context) val androidBluetoothConnector = polarConnector!! val database: AppDatabase = getRoomDatabase(getDatabaseBuilder(context)) - val repositories = MainRepository(database) + val repositories = MainRepositoryImpl(database) val dataManager = AndroidObservationDataManager(context, repositories) shared = Shared( LocalPushNotificationService(context), 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 e29e75c5e..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 @@ -1,14 +1,3 @@ -/* - * 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 android.app.Activity @@ -38,7 +27,7 @@ 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.theme.MoreColors -import io.redlink.more.observations.Observation +import io.redlink.more.observations.Observation.Companion.ERROR_DEVICE_NOT_CONNECTED @Composable fun ObservationErrorListView( @@ -75,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/ObservationErrorViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt index 7413c0bf7..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 @@ -1,21 +1,10 @@ -/* - * 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.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.aakira.napier.Napier -import io.redlink.more.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 @@ -30,7 +19,7 @@ class ObservationErrorViewModel : ViewModel() { 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) 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 index a54ee7210..0d56ddd14 100644 --- 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 @@ -1,14 +1,3 @@ -/* - * 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.mutableStateOf 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 index 2461d8875..7a20607cc 100644 --- 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 @@ -1,14 +1,3 @@ -/* - * 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.questionType import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt index 4554f6043..58b2aaf8d 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt @@ -1,14 +1,3 @@ -/* - * 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.subcomponents import android.app.Activity 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 index 6e6a8a6cb..7d0604250 100644 --- 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 @@ -1,14 +1,3 @@ -/* - * 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.subcomponents import androidx.compose.material.ButtonDefaults @@ -34,7 +23,7 @@ fun ReloadButton( modifier = modifier ) { isLoading.value = true - MoreApplication.shared!!.updateStudyAsync() + 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/tasks/ObservationActionButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/ObservationActionButton.kt index 897e8de08..332fe2a73 100644 --- 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 @@ -1,14 +1,3 @@ -/* - * 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 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 67567b18a..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 @@ -33,7 +33,6 @@ class FCMService : FirebaseMessagingService() { message.daoFromRemoteMessage().toString() } MoreApplication.shared!!.notificationManager.storeAndHandleNotification( - MoreApplication.shared!!, message.daoFromRemoteMessage(), true ) 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 index 3432da90e..8951a0a0f 100644 --- 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 @@ -1,14 +1,3 @@ -/* - * 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.AlarmManager 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 cfa1f0d4a..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,10 +16,11 @@ 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.services.network.NetworkService +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.EndpointRepository +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 @@ -36,12 +37,12 @@ class DataUploadWorker( private val workManager = WorkManager.getInstance(applicationContext) private val sharedPreferences = SharedPreferencesRepository(applicationContext) private val credentialRepository: CredentialRepository = - MoreApplication.shared?.credentialRepository ?: CredentialRepository( + MoreApplication.shared?.credentialRepository ?: CredentialRepositoryImpl( sharedPreferences ) private val networkService = - MoreApplication.shared?.networkService ?: NetworkService( - EndpointRepository(sharedPreferences), credentialRepository + MoreApplication.shared?.networkService ?: NetworkServiceImpl( + EndpointRepositoryImpl(sharedPreferences), credentialRepository ) private var stopped = false 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 4d3c907ed..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 @@ -47,7 +47,7 @@ class NotificationDataHandlerWorker(context: Context, workerParameters: WorkerPa val type: Type = object : TypeToken>() {}.type val notificationData: Map = Gson().fromJson(data, type) Napier.i("NotificationData: $notificationData") - shared.notificationManager.handleNotificationData(shared, notificationData) + shared.notificationManager.handleNotificationData(notificationData) Result.success() } catch (err: Exception) { Napier.e(err.stackTraceToString()) diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile index 56e124eee..31aac9adc 100644 --- a/iosApp/fastlane/Fastfile +++ b/iosApp/fastlane/Fastfile @@ -18,7 +18,7 @@ end default_platform(:ios) platform :ios do - def setup_google_services + 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") @@ -68,6 +68,13 @@ platform :ios do ) 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"] @@ -110,7 +117,7 @@ platform :ios do end generate_openapi - + test build_app( scheme: ENV["FASTLANE_IOS_SCHEME"], clean: true, @@ -118,6 +125,7 @@ platform :ios do export_method: "app-store", export_team_id: ENV["FASTLANE_TEAM_ID"] ) + end desc "Deploy a new beta to TestFlight" diff --git a/iosApp/fastlane/README.md b/iosApp/fastlane/README.md index 4cb1fb99f..89ad1f01d 100644 --- a/iosApp/fastlane/README.md +++ b/iosApp/fastlane/README.md @@ -15,6 +15,22 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do ## 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 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 199389b93..c5084e0bc 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -60,7 +60,6 @@ 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 */; }; - 1F45E5B92F2766AB00EE8487 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1F45E5B82F2766AB00EE8487 /* GoogleService-Info.plist */; }; 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 */; }; @@ -115,6 +114,7 @@ 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */; }; 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 */; }; 1FBD513A2BBC3E2D0029D185 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 1FBD51392BBC3E2D0029D185 /* FirebaseMessaging */; }; 1FBD513C2BBC3E8A0029D185 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 1FBD513B2BBC3E8A0029D185 /* FirebaseCrashlytics */; }; @@ -275,7 +275,6 @@ 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 = ""; }; - 1F45E5B82F2766AB00EE8487 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 = ""; }; @@ -331,6 +330,7 @@ 1F9DB1A4298D02FB00DBB7DB /* MoreColors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MoreColors.xcassets; 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 = ""; }; 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; }; @@ -815,6 +815,7 @@ 7555FF7D242A565900829871 /* iosApp */ = { isa = PBXGroup; children = ( + 1FA9ADD02F5F0993004DF4ED /* GoogleService-Info.plist */, 1F9283C02BC512E500D459A7 /* PrivacyInfo.xcprivacy */, EDAC149529DEF2010084CE1C /* iosApp.entitlements */, 1F8847C22991517F0023EF10 /* Resources */, @@ -833,7 +834,6 @@ 1F0026DA29CC8E730034EF65 /* AppDelegate.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */, - 1F45E5B82F2766AB00EE8487 /* GoogleService-Info.plist */, ); path = iosApp; sourceTree = ""; @@ -1009,8 +1009,7 @@ 7555FF79242A565900829871 /* Resources */, 7555FFB4242A642300829871 /* Embed Frameworks */, 1FC957532C072B7900EB92D6 /* Embed Foundation Extensions */, - 1F8E9DB22A80D31D006E4AF4 /* ShellScript */, - 1F988B402F30941C0094F99F /* ShellScript */, + 1F988B402F30941C0094F99F /* Run Script */, ); buildRules = ( ); @@ -1087,7 +1086,7 @@ buildActionMask = 2147483647; files = ( 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */, - 1F45E5B92F2766AB00EE8487 /* GoogleService-Info.plist in Resources */, + 1FA9ADD12F5F0993004DF4ED /* GoogleService-Info.plist in Resources */, 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 1F9DB1A3298D022E00DBB7DB /* MoreImages.xcassets in Resources */, 1FF6D8AD2BC516270050AF10 /* PrivacyInfo.xcprivacy in Resources */, @@ -1100,29 +1099,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1F8E9DB22A80D31D006E4AF4 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - 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)", - ); - 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"; - }; - 1F988B402F30941C0094F99F /* ShellScript */ = { + 1F988B402F30941C0094F99F /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -1131,6 +1108,7 @@ ); inputPaths = ( ); + name = "Run Script"; outputFileListPaths = ( ); outputPaths = ( @@ -1574,7 +1552,7 @@ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = PraeCura; + INFOPLIST_KEY_CFBundleDisplayName = More; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -1618,7 +1596,7 @@ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = PraeCura; + INFOPLIST_KEY_CFBundleDisplayName = More; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index b183f5982..eaf127095 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -26,12 +26,12 @@ class AppDelegate: NSObject, UIApplicationDelegate { static let appGroup = "group." + bundleId static let appGroupUserDefaults = UserDefaults(suiteName: appGroup) static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) - static let repositories = MainRepository(appDatabase: database) + 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(repository: repositories) + let dataManager = iOSObservationDataManager(repository: repositories, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) return Shared( localNotificationListener: LocalPushNotifications(), @@ -41,7 +41,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { mainBluetoothConnector: polarConnector, observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager), dataRecorder: IOSDataRecorder(), - reminderNotificationSchedulingLimit: 30 + reminderNotificationSchedulingLimit: 30, + connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() ) }() @@ -60,7 +61,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { DataUploadBackgroundTask.setupBackgroundTasks() DailyBackgroundTask.setupBackgroundTasks() ObservationReminderBackgroundTask.setupBackgroundTasks() - + let routes = Set(NavigationScreen.allCases.map { $0.values.navigationLink.route }) AppDelegate.shared.deeplinkManager.addAvailableDeepLinks(deepLinks: routes) @@ -72,7 +73,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { 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) } diff --git a/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift index 2811af120..e6a3e7638 100644 --- a/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift +++ b/iosApp/iosApp/BackgroundTasks/DailyBackgroundTask.swift @@ -1,13 +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/). - */ +// +// DailyBackgroundTask.swift +// +// Created to run a daily background refresh with a placeholder body. +// import Foundation import BackgroundTasks diff --git a/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift b/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift index a523c3c46..601dcc05a 100644 --- a/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift +++ b/iosApp/iosApp/BackgroundTasks/ObservationReminderBackgroundTask.swift @@ -1,14 +1,3 @@ -/* - * 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 Foundation import BackgroundTasks import shared diff --git a/iosApp/iosApp/Observations/iOSObservationDataManager.swift b/iosApp/iosApp/Observations/iOSObservationDataManager.swift index efb4ae62f..8043bdc8d 100644 --- a/iosApp/iosApp/Observations/iOSObservationDataManager.swift +++ b/iosApp/iosApp/Observations/iOSObservationDataManager.swift @@ -17,7 +17,7 @@ import Foundation import shared class iOSObservationDataManager: ObservationDataManager { - override func sendData(immediately: Bool = false, 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/Services/DataUploadManager.swift b/iosApp/iosApp/Services/DataUploadManager.swift index 94110d635..25425cd65 100644 --- a/iosApp/iosApp/Services/DataUploadManager.swift +++ b/iosApp/iosApp/Services/DataUploadManager.swift @@ -26,7 +26,7 @@ class DataUploadManager { Task(priority: .background) { [weak self] in print("Fetching Data Bulk...") - let observationDataRepository = await ObservationDataRepository(appDatabase: AppDelegate.database) + let observationDataRepository = await ObservationDataRepositoryImpl(appDatabase: AppDelegate.database) do { if let dataBulk = try await observationDataRepository.allAsBulk(), !dataBulk.dataPoints.isEmpty { print("Sending data to backend...") diff --git a/iosApp/iosApp/Services/FCMService.swift b/iosApp/iosApp/Services/FCMService.swift index 3f6c37886..7eacfa05c 100644 --- a/iosApp/iosApp/Services/FCMService.swift +++ b/iosApp/iosApp/Services/FCMService.swift @@ -40,7 +40,12 @@ extension FCMService: UNUserNotificationCenterDelegate { 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, completed: 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)") } do { try await AppDelegate.shared.observationService.scheduleObservationReminder() diff --git a/iosApp/iosApp/Views/Components/ReloadButton.swift b/iosApp/iosApp/Views/Components/ReloadButton.swift index dee6f95c0..f5ba08e36 100644 --- a/iosApp/iosApp/Views/Components/ReloadButton.swift +++ b/iosApp/iosApp/Views/Components/ReloadButton.swift @@ -22,7 +22,7 @@ struct ReloadButton: View { private func reload() { isLoading = true - AppDelegate.shared.updateStudyAsync() + AppDelegate.shared.updateStudy(oldStudyState: nil, newStudyState: nil) isLoading = false } } diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index a89f1b57c..b5a56d7d8 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -13,8 +13,8 @@ // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct ConsentView: View { @StateObject private var viewModel: ConsentViewModel @@ -78,10 +78,20 @@ struct ConsentView: View { } } -struct ConsentView_Previews: PreviewProvider { - static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) - static let repos = MainRepository(appDatabase: database) - static var previews: some View { - ConsentView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: ObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: ObservationFactory(repository: repos, dataManager: ObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil)))) - } +#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 shared = Shared( + localNotificationListener: LocalPushNotifications(), + repositories: repos, + sharedStorageRepository: UserDefaultsRepository(), + observationDataManager: dataManager, + mainBluetoothConnector: IOSBluetoothConnector(), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + 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/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 0b2310894..e31074d19 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -13,8 +13,8 @@ // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct LoginView: View { @ObservedObject private var registration: RegistrationObservable @@ -42,14 +42,15 @@ struct LoginView: View { Image("more_welcome") .padding(.vertical, 40) - MoreTextFieldHL(isSmTextfield: .constant(false), - headerText: "participation_key_entry", - inputPlaceholder: .constant("participation_key_entry"), - input: $model.token, - capitalization: .uppercase, - autoCorrectDisabled: true, - textType: .oneTimeCode, - hlAlignment: .center + MoreTextFieldHL( + isSmTextfield: .constant(false), + headerText: "participation_key_entry", + inputPlaceholder: .constant("participation_key_entry"), + input: $model.token, + capitalization: .uppercase, + autoCorrectDisabled: true, + textType: .oneTimeCode, + hlAlignment: .center ) .padding(.bottom, 12) @@ -73,15 +74,16 @@ struct LoginView: View { if showTokenInput { 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" - } + 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) } @@ -96,11 +98,12 @@ struct LoginView: View { if registration.connected { model.validate() } else { - AlertController.shared.openAlertDialog(model: AlertDialogModel( - title: "no_internet_title", - message: "no_internet_message", - confirmLabel: "Ok", - cancelLabel: nil, onConfirm: nil) + AlertController.shared.openAlertDialog( + model: AlertDialogModel( + title: "no_internet_title", + message: "no_internet_message", + confirmLabel: "Ok", + cancelLabel: nil, onConfirm: nil) ) } } @@ -136,10 +139,20 @@ struct LoginView: View { } } -struct LoginView_Previews: PreviewProvider { - static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) - static let repos = MainRepository(appDatabase: database) - static var previews: some View { - LoginView(registration: RegistrationObservable(service: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil)))) - } +#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 shared = Shared( + localNotificationListener: LocalPushNotifications(), + repositories: repos, + sharedStorageRepository: UserDefaultsRepository(), + observationDataManager: dataManager, + mainBluetoothConnector: IOSBluetoothConnector(), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + 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/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 8a1536818..84027b0e7 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -6,10 +6,9 @@ // Copyright © 2025 Redlink GmbH. All rights reserved. // -import shared - import AVFoundation import SwiftUI +import shared struct ScanQRCodeView: View { @StateObject private var viewModel = ScanQRCodeViewModel() @@ -87,29 +86,47 @@ struct ScanQRCodeView: View { 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) - } - }), + 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 + model.showQRCodeView = false // View schließen } } } } -struct ScanQRCodeView_Previews: PreviewProvider { - static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) - static let repos = MainRepository(appDatabase: database) - static var previews: some View { - ScanQRCodeView(model: LoginViewModel(registration: RegistrationService(shared: Shared(localNotificationListener: LocalPushNotifications(), repositories: repos, sharedStorageRepository: UserDefaultsRepository(), observationDataManager: iOSObservationDataManager(repository: repos), mainBluetoothConnector: IOSBluetoothConnector(), observationFactory: IOSObservationFactory(repository: repos, dataManager: iOSObservationDataManager(repository: repos)), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil)))) - } +#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 sharedContainer = Shared( + localNotificationListener: LocalPushNotifications(), + repositories: repos, + sharedStorageRepository: UserDefaultsRepository(), + observationDataManager: dataManager, + mainBluetoothConnector: IOSBluetoothConnector(), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + 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/Registration/RegistrationObservable.swift b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift index e0c37c419..35ecbe898 100644 --- a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift +++ b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift @@ -34,7 +34,7 @@ class RegistrationObservable: ObservableObject { } .store(in: &cancellables) - createPublisher(for: service.study) + createPublisher(for: service.study_) .receive(on: DispatchQueue.main) .sink { _ in } receiveValue: { [weak self] study in diff --git a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift index 3fd670e4c..16a462a63 100644 --- a/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift +++ b/iosApp/iosApp/Views/TaskCompletionBar/TaskCompletionBarViewModel.swift @@ -20,7 +20,7 @@ import shared class TaskCompletionBarViewModel: ObservableObject { @Published var taskCompletion: TaskCompletion = TaskCompletion(finishedTasks: 0, totalTasks: 0) @Published var taskCompletionPercentage: Double = 0 - var coreViewModel = CoreTaskCompletionBarViewModel(repository: AppDelegate.shared.repositories) + var coreViewModel = CoreTaskCompletionBarViewModel(repository: AppDelegate.shared.repositories, dispatcher: AppDispatchers.shared.default_) private var cancellables: Set = [] diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift index 4489c715f..f3931f99d 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift @@ -13,8 +13,8 @@ // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct TaskDetailsView: View { @StateObject private var viewModel: TaskDetailsViewModel @@ -93,8 +93,9 @@ struct TaskDetailsView: View { scheduleId: scheduleId, observationType: detailsModel.observationType, state: detailsModel.state, - disabled: !detailsModel.state.active() || !viewModel.taskObservationErrors.isEmpty) - .padding(.bottom) + disabled: !detailsModel.state.active() || !viewModel.taskObservationErrors.isEmpty + ) + .padding(.bottom) } } } @@ -113,7 +114,7 @@ struct TaskDetailsView: View { struct TaskDetailsViewPreview_Provider: PreviewProvider { static let database = DatabaseManagerKt.getRoomDatabase(builder: DatabaseManager_iosKt.getDatabaseBuilder()) - static let repos = MainRepository(appDatabase: database) + static let repos = MainRepositoryImpl(appDatabase: database) static var previews: some View { TaskDetailsView(scheduleId: "preview-schedule-id") .environmentObject(NavigationModalState(repos: repos)) 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 289411956..0d0e5e82c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -74,6 +74,7 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") } androidMain.dependencies { @@ -111,6 +112,14 @@ android { } } +tasks.withType { + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} + room { schemaDirectory("$projectDir/schemas") } @@ -180,4 +189,16 @@ tasks.register( // 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") +} + +tasks.withType().configureEach { + dependsOn("generateOpenApiClasses") } \ 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 index 3766c03e9..7c7569eb5 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/database/DatabaseManager.android.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/database/DatabaseManager.android.kt @@ -1,14 +1,3 @@ -/* - * 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 android.content.Context diff --git a/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt b/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt index 6d3777873..e52d18411 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt @@ -1,14 +1,3 @@ -/* - * 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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt index cd709a665..0936c4ebd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -18,6 +18,7 @@ import io.redlink.more.database.repository.MainRepository import io.redlink.more.extensions.toStudyState 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 @@ -29,11 +30,15 @@ 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 @@ -41,9 +46,9 @@ import io.redlink.more.viewModels.garminConnectOAuth.CoreGarminConnectViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO 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 @@ -53,7 +58,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -class Shared( +open class Shared( localNotificationListener: LocalNotificationListener, val repositories: MainRepository, val sharedStorageRepository: SharedStorageRepository, @@ -62,13 +67,17 @@ class Shared( val observationFactory: ObservationFactory, val dataRecorder: DataRecorder, reminderNotificationSchedulingLimit: Int? = null, -) { - val deeplinkManager = DeeplinkManager(repositories, observationFactory) - val endpointRepository = EndpointRepository(sharedStorageRepository) - val credentialRepository = CredentialRepository(sharedStorageRepository).also { - observationFactory.setCredentialsRepository(it) - } - val networkService = NetworkService(endpointRepository, credentialRepository) + val connectionStatusFlow: Flow = + konnectionInstance().observeHasConnection() +) : NotificationActionObserver, 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, @@ -89,13 +98,13 @@ class Shared( deeplinkManager, sharedStorageRepository ) + .also { it.setActionObserver(this) } .also { observationFactory.setNotificationManager(it) } val observationService = ObservationService(repositories, notificationManager, reminderNotificationSchedulingLimit) private val mutex = Mutex() - private val konnection = Konnection.instance private var mainJob: Job? = null init { @@ -180,21 +189,17 @@ class Shared( notificationManager.downloadMissedNotifications() } - fun updateStudyAsync() { - Scope.launch(Dispatchers.IO) { - updateStudy() - } - } - - suspend fun updateStudy( - oldStudyState: StudyState? = null, - newStudyState: StudyState? = null + override fun updateStudy( + oldStudyState: StudyState?, + newStudyState: StudyState? ) { if (!credentialRepository.hasCredentials.value || mutex.isLocked) { return } - mutex.withLock { - updateStudyInternal(oldStudyState, newStudyState) + Scope.launch { + mutex.withLock { + updateStudyInternal(oldStudyState, newStudyState) + } } } @@ -219,7 +224,7 @@ class Shared( val currentStudyBeforeFetch = repositories.study.getStudy().firstOrNull() - if (!konnection.isConnected()) { + if (connectionStatusFlow.firstOrNull() == false) { Napier.d(tag = "Shared::updateStudy") { "No network connection, skipping study update" } if (newStudyState != null) { repositories.study.updateStudyState(newStudyState) @@ -400,8 +405,18 @@ class Shared( 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 } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt index 665bc32b9..aa563cf1a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 8eedbf512..23b660f7d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/BluetoothDeviceDao.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 53bb27370..750ebd08b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/DataPointDao.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index ad5145152..fcc547a06 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/BluetoothDeviceEntity.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index ef2748226..014242ea9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/DataPointEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/DataPointEntity.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 76e69c409..7fc989c2a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ScheduleEntity.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 62847fd20..feebbb925 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/StudyEntity.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 3c0f9715f..7d3552989 100644 --- 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 @@ -1,14 +1,3 @@ -/* - * 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 diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt index ebd2642fc..7ee5ee17e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/BluetoothDeviceRepository.kt @@ -10,32 +10,13 @@ */ 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 BluetoothDeviceRepository( - private val database: AppDatabase -) { - fun storePairedDevice(bluetoothDevice: BluetoothDeviceEntity) { - if (bluetoothDevice.address != null) { - Scope.launch(Dispatchers.IO) { - database.bluetoothDeviceDao().insert(bluetoothDevice) - } - } - } +interface BluetoothDeviceRepository { + fun storePairedDevice(bluetoothDevice: BluetoothDeviceEntity) - fun unpairDevice(bluetoothDevice: BluetoothDeviceEntity) { - bluetoothDevice.address?.let { - Scope.launch(Dispatchers.IO) { - database.bluetoothDeviceDao().deleteByAddress(it) - } - } - } + fun unpairDevice(bluetoothDevice: BluetoothDeviceEntity) - fun pairedDevices(): Flow> = - database.bluetoothDeviceDao().getAllFlow() + 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/database/repository/DataPointCountRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt index c907f3dd2..fcffb51a6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/DataPointCountRepository.kt @@ -10,88 +10,15 @@ */ 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.flow.conflate -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -class DataPointCountRepository(private val appDatabase: AppDatabase) { - private val mutex = Mutex() - private val countQueue = mutableMapOf() - private var storeJob: Job? = null +interface DataPointCountRepository { + fun count(): Flow - fun count(): Flow { - return appDatabase.dataPointDao().getCountFlow() - } + fun incrementCount(scheduleIdSet: Set, addCount: Long = 1) - fun incrementCount(scheduleIdSet: Set, addCount: Long = 1) { - 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 - } - } - } - } - } - } + fun get(scheduleId: String): Flow - 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) - } - } - } - } - - fun get(scheduleId: String): Flow { - return appDatabase.dataPointDao().getByScheduleId(scheduleId).conflate() - } - - fun delete(scheduleId: String) { - Scope.launch { - appDatabase.dataPointDao().deleteByScheduleId(scheduleId) - } - } + 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/database/repository/MainRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt index b3ce6237f..ea41de51a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt @@ -1,30 +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.database.repository -import io.redlink.more.database.AppDatabase - -class MainRepository(appDatabase: AppDatabase) { - val study = StudyRepository(appDatabase) - val observation = ObservationRepository(appDatabase) - val observationData = ObservationDataRepository(appDatabase) - val dataPointCount = DataPointCountRepository(appDatabase) - val schedule = ScheduleRepository(appDatabase) +interface MainRepository { + val study: StudyRepository + val observation: ObservationRepository + val observationData: ObservationDataRepository + val dataPointCount: DataPointCountRepository + val schedule: ScheduleRepository - val notification = NotificationRepository(appDatabase) - val bluetoothDevice = BluetoothDeviceRepository(appDatabase) + val notification: NotificationRepository + val bluetoothDevice: BluetoothDeviceRepository - suspend fun deleteAll() { - study.deleteStudy() - notification.deleteAll() - } + 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..33b71d30d --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt @@ -0,0 +1,22 @@ +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 suspend fun deleteAll() { + study.deleteStudy() + notification.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 index 138e8be47..b68784f25 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/NotificationRepository.kt @@ -10,192 +10,29 @@ */ 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.Dispatchers -import kotlinx.coroutines.IO 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 NotificationRepository(private val appDatabase: AppDatabase) { - private val readNotificationIds = mutableSetOf() - private val completedNotificationIds = mutableSetOf() - private val deletedNotificationIds = mutableSetOf() - private val mutex = Mutex() +interface NotificationRepository { + suspend fun storeNotification(notification: NotificationEntity) - fun storeNotification( - key: String, - channelId: String?, - title: String?, - body: String?, - timestamp: Long, - priority: Long = 1, - read: Boolean = false, - completed: Boolean = false, - userFacing: Boolean = true, - additionalData: Map? = null - ) { - Scope.launch(Dispatchers.IO) { - mutex.withLock { - if (key !in deletedNotificationIds) { - storeNotification( - NotificationEntity.toEntity( - key, - channelId, - title, - body, - timestamp, - priority, - read, - completed, - userFacing, - additionalData - ) - ) - } else { - deletedNotificationIds.remove(key) - } - } - } - } + suspend fun storeNotifications(notifications: List) - suspend fun storeNotification(notification: NotificationEntity) { - mutex.withLock { - Napier.i { "Delete Notification: $deletedNotificationIds. Notification to store: $notification" } - if (notification.notificationId !in deletedNotificationIds) { - val updatedNotification = notification.copy( - read = if (notification.notificationId in readNotificationIds) { - readNotificationIds.remove(notification.notificationId) - true - } else notification.read, - completed = if (notification.notificationId in completedNotificationIds) { - completedNotificationIds.remove(notification.notificationId) - true - } else notification.completed - ) - appDatabase.notificationDao().insert(updatedNotification) - } else { - deletedNotificationIds.remove(notification.notificationId) - } - } - } + suspend fun getNotification(notificationId: String): NotificationEntity? - suspend fun storeNotifications(notifications: List) { - mutex.withLock { - val (notificationsToStore, notificationsToDelete) = notifications.partition { it.notificationId !in deletedNotificationIds } - Napier.i { "Delete Notification: $deletedNotificationIds. Storing notifications: $notificationsToStore. Notifications to delete: $notificationsToDelete" } + fun getAllUserFacingNotifications(): Flow> - val updatedNotifications = notificationsToStore.map { notification -> - notification.copy( - read = notification.notificationId in readNotificationIds, - completed = notification.notificationId in completedNotificationIds - ) - } - appDatabase.notificationDao().insertAll(updatedNotifications) - notificationsToDelete.forEach { deletedNotificationIds.remove(it.notificationId) } - } - } + suspend fun update(notificationId: String, read: Boolean? = null, priority: Long? = null) - suspend fun getNotification(notificationId: String): NotificationEntity? { - return appDatabase.notificationDao().getById(notificationId) - } + fun setNotificationReadStatus(key: String, read: Boolean = true) - fun getAllUserFacingNotifications(): Flow> { - val dbFlow = appDatabase.notificationDao().getByPastUserFacingFlow(true) + fun setNotificationCompletedStatus(key: String, completed: Boolean = true) - return combine( - dbFlow, - alignedNowFlow(periodMs = 30_000L) - ) { list, now -> - list.filter { it.timestamp != null && it.timestamp <= now } - }.distinctUntilChanged() - } + suspend fun scheduledNotificationCount(): Int - suspend fun update(notificationId: String, read: Boolean? = null, priority: Long? = null) { - 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) - } - } - } + suspend fun scheduledNotifications(): List - fun setNotificationReadStatus(key: String, read: Boolean = true) { - if (read) { - readNotificationIds.add(key) - } else { - readNotificationIds.remove(key) - } + fun deleteNotification(notificationId: String) - Scope.launch { - mutex.withLock { - val notification = appDatabase.notificationDao().getById(key) - if (notification != null) { - appDatabase.notificationDao().updateReadStatus(key, read) - } - readNotificationIds.remove(key) - } - } - } - - fun setNotificationCompletedStatus(key: String, completed: Boolean = true) { - 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) - } - } - } - - suspend fun scheduledNotificationCount(): Int { - return appDatabase.notificationDao() - .getScheduledNotificationCount(Clock.System.now().epochSeconds) - } - - suspend fun scheduledNotifications(): List { - return appDatabase.notificationDao() - .getScheduledNotifications(Clock.System.now().epochSeconds) - } - - - 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" } - } - } - } - - suspend fun deleteAll() { - appDatabase.notificationDao().deleteAll() - } + 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 index 1e9d3bb95..b7085a11f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationDataRepository.kt @@ -10,67 +10,17 @@ */ 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 ObservationDataRepository(private val appDatabase: AppDatabase) { - private var queue = mutableSetOf() - private val mutex = Mutex() +interface ObservationDataRepository { + fun addData(dataList: List) - init { - Scope.repeatedLaunch(10000L, Dispatchers.IO) { - if (queue.isNotEmpty()) { - store() - } - } - } + suspend fun store() - fun addData(dataList: List) { - Scope.launch { - mutex.withLock { - queue.addAll(dataList) - } - } - } + suspend fun getCount(): Int - suspend fun store() { - if (queue.isNotEmpty()) { - val queueCopy = mutex.withLock { - val queueCopy = queue.toSet() - queue.clear() - queueCopy - } - appDatabase.observationDataDao().insertAll(queueCopy.toList()) - } - } + suspend fun allAsBulk(): DataBulk? - suspend fun getCount(): Int = appDatabase.observationDataDao().getCount() - - suspend fun allAsBulk(): DataBulk? { - return mutex.withLock { - val observationDataEntities = appDatabase.observationDataDao().getLatest(5000) - if (observationDataEntities.isNotEmpty()) { - observationDataEntities.mapAsBulkData() - } else { - null - } - } - } - - suspend fun deleteAllWithId(idSet: Set) { - Napier.i { "Deleting ${idSet.size} elements..." } - mutex.withLock { - idSet.forEach { dataId -> - appDatabase.observationDataDao().deleteById(dataId) - } - } - } + 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 index a741cc1cd..c3e4efad4 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ObservationRepository.kt @@ -11,89 +11,37 @@ 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 ObservationRepository(private val appDatabase: AppDatabase) { - - suspend fun getCount(): Int = appDatabase.observationDao().getCount() - - fun observations() = appDatabase.observationDao().getAllFlow() - - fun observationWithUndoneSchedules(): Flow>> { - return appDatabase.scheduleDao().getByDoneFlow(false) - .combine(observations()) { schedules: List, observations: List -> - observations.associateWith { observation -> - schedules.filter { schedule -> schedule.observationId == observation.observationId } - } - } - } - - 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) - } - } - - 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) - } - } - } - - fun collectionTimestamp(type: String): Flow = - appDatabase.observationDao().getByObservationTypeFlow(type) - .transform { observationList -> - emit(observationList.firstOrNull()?.collectionTimestamp) - } - - fun collectAllTimestamps(): Flow> = - observations().transform { observationList -> - emit(observationList.associate { it.observationType to it.collectionTimestamp }) - } - - 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) - } - - fun collectTimestampOfType(type: String, newState: (Long?) -> Unit): Closeable { - return collectionTimestamp(type).asClosure(newState) - } - - fun collectAllTimestamps(newState: (Map) -> Unit): Closeable { - return collectAllTimestamps().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) = - appDatabase.observationDao().getByObservationIdFlow(observationId) - - suspend fun getObservationByObservationId(observationId: String): ObservationEntity? { - return appDatabase.observationDao().getByObservationId(observationId) - } + +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 index 78e2499d5..054abd2cd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt @@ -10,179 +10,47 @@ */ 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.flowOf -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 ScheduleRepository(private val appDatabase: AppDatabase) { +interface ScheduleRepository { - private val mutex = Mutex() + fun count(): Flow - fun count() = appDatabase.scheduleDao().countAsFlow() + fun allSchedulesWithStatus(done: Boolean = false): Flow> - fun allSchedulesWithStatus(done: Boolean = false): Flow> { - return appDatabase.scheduleDao().getByDoneFlow(done) - } - - fun allSchedulesWithStates(states: Set): Flow> { - if (states.isEmpty()) { - return flowOf(emptyList()) - } - return appDatabase.scheduleDao().getByStatesFlow(states.map { it.name }) - } + fun allSchedulesWithStates(states: Set): Flow> fun getSchedulesWithReminder( states: Set, minTimestamp: Instant, maxTimestamp: Instant, limit: Int = 100 - ): Flow> { - if (states.isEmpty()) { - return flowOf(emptyList()) - } - return appDatabase.scheduleDao().getSchedulesWithReminder( - states.map { it.name }, - minTimestamp.epochSeconds, - maxTimestamp.epochSeconds, - limit - ) - } + ): Flow> - fun allScheduleWithRunningState(scheduleState: ScheduleState = ScheduleState.RUNNING): Flow> = - appDatabase.scheduleDao().getByStateFlow(scheduleState.name) + fun allScheduleWithRunningState(scheduleState: ScheduleState = ScheduleState.RUNNING): Flow> - 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) - } - } - } + fun firstScheduleAvailableForObservationId(observationId: String): Flow - 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) - }) - } - } + fun allSchedulesToday(observationType: ObservationType): Flow> - fun firstScheduleIdAvailableForObservationId(observationId: String): Flow = - firstScheduleAvailableForObservationId(observationId).transform { it?.scheduleId } + fun firstScheduleIdAvailableForObservationId(observationId: String): Flow - 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)) - } - } + fun getFirstAndLastDate(observationId: String): Flow> - suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) { - appDatabase.scheduleDao().updateState(id, scheduleState.name) - } + suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) - 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) - } + suspend fun setCompletionStateFor(id: String, wasDone: Boolean) - fun scheduleWithId(id: String): Flow { - return appDatabase.scheduleDao().getById(id) - } + fun scheduleWithId(id: String): Flow 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().getByDone(false) - - 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) - } - } - - } + ) } \ 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..ac3126f83 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt @@ -0,0 +1,188 @@ +/* + * 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.flowOf +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 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().getByDone(false) + + 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 index 78a45ee8d..09852b4c5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/StudyRepository.kt @@ -11,99 +11,27 @@ package io.redlink.more.database.repository import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -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 StudyRepository(private val appDatabase: AppDatabase) { - private val _study = MutableStateFlow(null) +interface StudyRepository { @NativeCoroutines - val study: StateFlow = _study + val study: StateFlow @NativeCoroutines - val studyState: StateFlow = - study.mapState(CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) { - it?.let { StudyState.getState(it.state) } ?: StudyState.NONE - } - private val _finishText = MutableStateFlow(null) + val studyState: StateFlow @NativeCoroutines - val finishText: StateFlow = _finishText - - init { - Scope.launch { - getStudy().collectLatest { - withContext(Dispatchers.Main) { - _study.value = it - it?.let { - _finishText.value = it.finishText - } - } - } - } - } - - 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) - } + val finishText: StateFlow - 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) - } - } + suspend fun upsert(study: Study) - fun getStudy(): Flow { - return appDatabase.studyDao().getFlow() - } + fun getStudy(): Flow - suspend fun updateStudyState(state: StudyState) { - study.value?.let { - appDatabase.studyDao().updateStudyState(it.studyId, state.descr) - } - } + suspend fun updateStudyState(state: StudyState) - suspend fun deleteStudy() { - appDatabase.studyDao().deleteAll() - appDatabase.observationDao().deleteAll() - appDatabase.observationDataDao().deleteAll() - appDatabase.scheduleDao().deleteAll() - appDatabase.dataPointDao().deleteAll() - } + 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/extensions/FunctionExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt index de4e8ba7d..b82f894d5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/FunctionExtension.kt @@ -1,14 +1,3 @@ -/* - * 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.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 index ea4e7641c..616d03526 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/ScheduleEntityExtenstion.kt @@ -1,13 +1,3 @@ -/* - * 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.extensions import io.redlink.more.database.entities.NotificationEntity diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringExtension.kt index 076608e0d..e94793dee 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StringExtension.kt @@ -1,14 +1,3 @@ -/* - * 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.extensions fun String.extractRouteFromDeepLink(): String? { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt index 0f0bba13f..1797311fa 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/extensions/StudyStateExtension.kt @@ -1,14 +1,3 @@ -/* - * 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.extensions import io.redlink.more.models.StudyState diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt index 674c66b72..e0806f96f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt @@ -1,14 +1,3 @@ -/* - * 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 data class AlertDialogModel( diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt index 92822fa37..7c570ebc5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/LoginModel.kt @@ -1,14 +1,3 @@ -/* - * 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.util.validateAndNormalizeUrl diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt index e7460a623..ec595770f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationStatusType.kt @@ -1,14 +1,3 @@ -/* - * 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 NotificationStatusType { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt index f19289aba..d1ada6db9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextLocalization.kt @@ -1,14 +1,3 @@ -/* - * 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 dev.icerock.moko.resources.desc.StringDesc diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt index 88e45b25b..d176ed88d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationTextType.kt @@ -1,14 +1,3 @@ -/* - * 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 dev.icerock.moko.resources.desc.Resource diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt index 81a8203e6..fb82da8d6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/QuestionType.kt @@ -1,14 +1,3 @@ -/* - * 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 QuestionType(val observationType: String, val observationDataResponseKey: String) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt index 05aa3ee4f..7c38cd62e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/DeeplinkManager.kt @@ -1,262 +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.navigation -import io.github.aakira.napier.Napier +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.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 DeeplinkManager( - private val repos: MainRepository, +interface DeeplinkManager { val observationFactory: ObservationFactory -) { - private val deepLinks = mutableSetOf() - private var protocolReplacement: String? = null - private var hostReplacement: String? = null - fun addAvailableDeepLinks(deepLinks: Set) { - this.deepLinks.addAll(deepLinks) - } + fun addAvailableDeepLinks(deepLinks: Set) - fun setProtocol(protocolReplacement: String?) { - this.protocolReplacement = protocolReplacement - } + fun setProtocol(protocolReplacement: String?) - fun setHost(hostReplacement: String?) { - this.hostReplacement = hostReplacement - } + fun setHost(hostReplacement: String?) fun getNotificationViewDeepLink( notificationId: String, - ): Flow { - return modifyDeepLink( - "/${NavigationRoute.NOTIFICATIONS}?${NavigationRouteParameter.NOTIFICATION_ID}=$notificationId", - ) - } + ): Flow 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 observationIdToUse = observationIdParam ?: schedule?.observationId - - emit( - DeepLinkData( - deepLinkModifier( - deepLink, - schedule, - ), - mapOf( - NavigationRouteParameter.NOTIFICATION_ID.key to notificationId, - NavigationRouteParameter.SCHEDULE_ID.key to scheduleIdParam, - 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() - - private fun validateRoute(deepLink: String): Boolean { - Napier.d { "Available deeplinks: $deepLinks" } - return deepLinks.any { registered -> - val registeredRoute = extractRegisteredRoute(registered) ?: registered - routeMatches(deepLink, registeredRoute) - } - } - - private fun routeForObservation(deepLink: String): String { - val incomingRoute = extractIncomingRoute(deepLink.lowercase()) - ?: return NavigationRoute.SCHEDULE_DETAILS.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 if (valid) { - resolvedObservationRoute - } else NavigationRoute.DASHBOARD.route - } - - 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 protocolAndHost = (this.protocolReplacement - ?: deepLink.substringBefore("://")) + "://" - val afterProtocol = deepLink.substringAfter("://") - val hostAndPath = afterProtocol.substringBefore('?') - val host = this.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(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(protocolAndHost) - append(newHostAndPath) - if (newQueryParams.isNotEmpty()) append("?").append(newQueryParams) - if (fragment.isNotEmpty()) append("#").append(fragment) - } - } + ): Flow fun modifyDeepLink( deepLink: String?, newState: (DeepLinkData?) -> Unit - ) = modifyDeepLink(deepLink).asClosure(newState) + ): Closeable + + fun validateRoute(deepLink: String): Boolean - /** - * Creates a deep link for a given [ScheduleEntity]. - * - * Expected format: - * /?observationId=&scheduleId= - * - * `baseDeeplink` should look like: ":///" (including the trailing slash). - */ fun createDeeplinkForSchedule( schedule: ScheduleEntity, baseDeeplink: String? = null - ): 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) - } - } + ): 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 index 6242063c2..fc7867359 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/DeepLinkData.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/DeepLinkData.kt @@ -1,14 +1,3 @@ -/* - * 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 data class DeepLinkData( 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 index dc11a18e3..afe78b330 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 3c1ca9e72..9b491b2b3 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRouteParameter.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRouteParameter.kt @@ -1,14 +1,3 @@ -/* - * 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 enum class NavigationRouteParameter(val key: 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 index c667ded6b..1cc876795 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -48,7 +48,11 @@ abstract class Observation( var timestampCollectionJob: Job? = null - fun start(observationId: String, scheduleId: String, notificationId: String? = null): Boolean { + open fun start( + observationId: String, + scheduleId: String, + notificationId: String? = null + ): Boolean { observationIds.add(observationId) StudyScope.launch { val realObservationType = @@ -83,7 +87,7 @@ abstract class Observation( } else true } - fun stop(scheduleId: String, removeNotification: Boolean = false) { + open 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 { @@ -204,7 +208,7 @@ abstract class Observation( onCompletion() } - fun stopAndFinish(scheduleId: String) { + open fun stopAndFinish(scheduleId: String) { Napier.i(tag = "Observation::stopAndFinish") { "Stopping and finishing observation ${observationType.observationType} for observationIds: $observationIds" } stop { timestampCollectionJob?.cancel() @@ -323,8 +327,6 @@ abstract class Observation( 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" diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt index 317906e35..4a283b337 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationDataManager.kt @@ -15,25 +15,32 @@ 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.Dispatchers -import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume -abstract class ObservationDataManager(private val repository: MainRepository) { +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 val konnection = Konnection.instance + protected open val konnection: Konnection? by lazy { Konnection.instance } init { Napier.i(tag = "ObservationDataManager::init") { "ObservationDataManager init!" } } - fun add(dataList: List, scheduleIdList: Set) { + 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) @@ -44,28 +51,28 @@ abstract class ObservationDataManager(private val repository: MainRepository) { } } - fun saveAndSend() { + open fun saveAndSend() { Napier.i(tag = "ObservationDataManager::saveAndSend") { "Saving and sending observations" } - StudyScope.launch(Dispatchers.IO) { + scope.launch(dispatchers.io) { repository.observationData.store() } } - fun store() { + open fun store() { Napier.i(tag = "ObservationDataManager::store") { "Storing observations" } - StudyScope.launch(Dispatchers.IO) { + studyScope.launch(dispatchers.io) { repository.observationData.store() } } - fun removeDataPointCount(scheduleId: String) { + 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 = {}) - suspend fun sendData(immediately: Boolean): Boolean { + open suspend fun sendData(immediately: Boolean): Boolean { return suspendCancellableCoroutine { cont -> sendData(immediately) { success -> if (cont.isActive) cont.resume(success) @@ -73,10 +80,10 @@ abstract class ObservationDataManager(private val repository: MainRepository) { } } - fun listenToDatapointCountChanges() { + 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) { + countJob = scope.repeatedLaunch(60000, dispatchers.io) { if (isConnected()) { val count = repository.observationData.getCount() if (count > 0) { @@ -93,13 +100,13 @@ abstract class ObservationDataManager(private val repository: MainRepository) { } } - fun stopListeningToCountChanges() { + open fun stopListeningToCountChanges() { Napier.d(tag = "ObservationDataManager::stopListeningToCAndroiduntChanges") { "Stopped listening for changes in datapoint counts" } countJob?.cancel() countJob = null } - protected fun isConnected() = konnection.isConnected() + protected open fun isConnected() = konnection?.isConnected() ?: false protected suspend fun dataBulk() = repository.observationData.allAsBulk() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt index 0653fae83..7253e264a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -28,10 +28,10 @@ abstract class ObservationFactory( private val dataManager: ObservationDataManager ) { private var credentialRepository: CredentialRepository? = null - val observations = mutableSetOf() + open val observations = mutableSetOf() private val _studyObservationTypes: MutableStateFlow> = MutableStateFlow(emptySet()) - val studyObservationTypes: StateFlow> = _studyObservationTypes + open val studyObservationTypes: StateFlow> = _studyObservationTypes init { observations.add(QuestionObservation(repository)) @@ -45,38 +45,39 @@ abstract class ObservationFactory( } } - fun addNeededObservationTypes(observationTypes: Set) { + open fun addNeededObservationTypes(observationTypes: Set) { Napier.i(tag = "ObservationFactory::addNeededObservationTypes") { "Adding observation types to studyObservationTypes: $observationTypes" } _studyObservationTypes.value += observationTypes } - fun clearNeededObservationTypes() { + open fun clearNeededObservationTypes() { _studyObservationTypes.value = setOf() ObservationStates.resetAll() } - fun setCredentialsRepository(credentialRepository: CredentialRepository) { + open fun setCredentialsRepository(credentialRepository: CredentialRepository) { this.credentialRepository = credentialRepository } - fun studySensorPermissions() = + open fun studySensorPermissions() = observations.filter { observationMatchesStudyTypes(it, studyObservationTypes.value) } .flatMap { it.observationType.sensorPermissions }.toSet() - fun setNotificationManager(notificationManager: NotificationManager) { + open fun setNotificationManager(notificationManager: NotificationManager) { observations.forEach { it.setNotificationManager(notificationManager) } } - fun observationTypes() = observations.map { it.observationType.observationType }.toSet() + open fun observationTypes() = + observations.map { it.observationType.observationType }.toSet() - fun getMatchingObservationTypes(types: Set): Set = + open fun getMatchingObservationTypes(types: Set): Set = observations.filter { it.observationType.matchesAny(types) } .map { it.observationType.observationType }.toSet() - fun sensorPermissions() = + open fun sensorPermissions() = observations.map { it.observationType.sensorPermissions }.flatten().toSet() - fun bleDevicesNeeded(): Set { + open fun bleDevicesNeeded(): Set { Napier.i(tag = "ObservationFactory::bleDevicesNeeded") { "Filtering types for BLE: ${studyObservationTypes.value}" } val bleTypes = observations.filter { observationMatchesStudyTypes(it, studyObservationTypes.value) } @@ -85,20 +86,20 @@ abstract class ObservationFactory( return bleTypes } - fun autoStartableObservations(): Set { + 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 } - suspend fun updateObservationErrors() { + open suspend fun updateObservationErrors() { if (this.credentialRepository?.hasCredentials?.value == true) { studyObservations().forEach { it.updateObservationErrors() } } } - fun observation(type: String): Observation? { + open fun observation(type: String): Observation? { Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } return observations.firstOrNull { it.observationType.matches(type) @@ -110,7 +111,7 @@ abstract class ObservationFactory( } } - private fun studyObservations() = + private fun studyObservations(): List = observations.filter { it.observationType.matchesAny(studyObservationTypes.value) } private fun observationMatchesStudyTypes(obs: Observation, types: Set): Boolean = diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt index a520277c0..67da29706 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt @@ -14,9 +14,10 @@ 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.scopes.AppDispatchers +import io.redlink.more.scopes.MoreDispatchers +import io.redlink.more.scopes.StudyMoreScope import io.redlink.more.scopes.StudyScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull @@ -26,7 +27,9 @@ 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 runningObservations = mutableMapOf() @@ -34,11 +37,11 @@ class ObservationManager( 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(Dispatchers.IO) { + studyScope.launch(dispatchers.io) { repositories.schedule.allSchedulesWithStatus(true).distinctUntilChanged().cancellable() .collect { list -> if (runningObservations.isNotEmpty()) { @@ -52,10 +55,10 @@ class ObservationManager( } val firstCall = ceil(Clock.System.now().toEpochMilliseconds() / 60_000.0).toLong() * 60_000 val initialDelay = firstCall - Clock.System.now().toEpochMilliseconds() - StudyScope.repeatedLaunch(60000L, Dispatchers.IO, initialDelay) { + studyScope.repeatedLaunch(60000L, dispatchers.io, initialDelay) { updateTaskStates() } - StudyScope.launch(Dispatchers.IO) { + studyScope.launch(dispatchers.io) { repositories.observation.collectAllTimestamps().cancellable().collect { upToDateTimestamps = it } @@ -141,7 +144,7 @@ class ObservationManager( runningObservations[scheduleId]?.let { observation -> 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}" } } @@ -156,7 +159,7 @@ 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" } } } @@ -174,13 +177,19 @@ class ObservationManager( Napier.i(tag = "ObservationManager::startObservationType") { "Failed to start schedule: $it" } } } + dataRecorder.startMultiple( + repositories.schedule.allSchedulesWithStatus(false) + .firstOrNull() + ?.filter { it.observationType == type && it.getState().active() } + ?.map { it.scheduleId }?.toSet() ?: emptySet() + ) } fun stop(scheduleId: String) { runningObservations[scheduleId]?.let { observation -> 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) @@ -210,7 +219,7 @@ class ObservationManager( val runningObs = runningObservations.toList() runningObs.forEach { (scheduleId, observation) -> observation.stopAndFinish(scheduleId) - StudyScope.launch(Dispatchers.IO) { + studyScope.launch(dispatchers.io) { repositories.schedule.setCompletionStateFor(scheduleId, true) } repositories.dataPointCount.delete(scheduleId) @@ -221,21 +230,28 @@ class ObservationManager( } fun collectAllData(onCompletion: (Boolean) -> Unit) { - StudyScope.launch(Dispatchers.IO) { + 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, Clock.System.now().epochSeconds) { - if (++counter == runningObservations.size) { + if (++counter == observationsToStore.size) { onCompletion(true) } } } ?: run { - if (++counter == runningObservations.size) { + if (++counter == observationsToStore.size) { onCompletion(true) } } @@ -267,13 +283,15 @@ class ObservationManager( } private fun setObservationState(scheduleId: String, state: ScheduleState) { - StudyScope.launch(Dispatchers.IO) { + 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 index d3c6c4e21..1552db49c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationStates.kt @@ -1,14 +1,3 @@ -/* - * 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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines 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 index 0971a8a75..36f878b41 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/garmin/GarminObservation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/garmin/GarminObservation.kt @@ -1,14 +1,3 @@ -/* - * 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.garmin import io.redlink.more.database.repository.MainRepository 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 index 4bbd37cd1..8cf25a527 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GarminType.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/GarminType.kt @@ -1,14 +1,3 @@ -/* - * 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 GarminType : ObservationType("garmin-observation", emptySet(), prefix = PREFIX) { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt index 5a7a3f3bc..88eaad8c6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/registration/RegistrationService.kt @@ -1,18 +1,6 @@ -/* - * 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.registration import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import dev.tmapps.konnection.Konnection import io.github.aakira.napier.Napier import io.ktor.util.encodeBase64 import io.ktor.utils.io.core.toByteArray @@ -32,7 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.kotlincrypto.hash.md.MD5 -class RegistrationService( +open class RegistrationService( private val shared: Shared, ) { private val _validLoginModel = MutableStateFlow(null) @@ -53,8 +41,6 @@ class RegistrationService( @NativeCoroutines val isLoading: StateFlow = _isLoading - private val konnection = Konnection.createInstance() - private val _connected = MutableStateFlow(false) @NativeCoroutines @@ -62,7 +48,7 @@ class RegistrationService( init { Scope.launch(Dispatchers.IO) { - konnection.observeHasConnection().collect { + shared.connectionStatusFlow.collect { _connected.value = it Napier.i("Device connected: $it") } @@ -71,11 +57,11 @@ class RegistrationService( fun getEndpointRepository(): EndpointRepository = shared.endpointRepository - fun clearError() { + open fun clearError() { _error.value = null } - fun sendRegistrationToken( + open fun sendRegistrationToken( loginModel: LoginModel ) { clearError() 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 index 8490b77d3..e504d4cfa 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/Scope.kt @@ -17,7 +17,6 @@ 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 @@ -26,18 +25,19 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.coroutines.CoroutineContext -object Scope { +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 + Dispatchers.Default + exceptionHandler) + private val scope = CoroutineScope(rootJob + AppDispatchers.default + exceptionHandler) + override val coroutineContext: CoroutineContext = scope.coroutineContext private val jobs = mutableMapOf() - fun launch( - coroutineContext: CoroutineContext = Dispatchers.Default, - start: CoroutineStart = CoroutineStart.DEFAULT, + override fun launch( + coroutineContext: CoroutineContext, + start: CoroutineStart, block: suspend CoroutineScope.() -> Unit ): Pair { val uuid = createUUID() @@ -105,10 +105,10 @@ object Scope { fun isActive(uuid: String) = jobs[uuid]?.isActive ?: false - fun repeatedLaunch( + override fun repeatedLaunch( intervalMillis: Long, - coroutineContext: CoroutineContext = Dispatchers.Default, - initalDelay: Long = 0, + coroutineContext: CoroutineContext, + initalDelay: Long, block: suspend CoroutineScope.() -> Unit ): Pair { val uuid = createUUID() @@ -142,7 +142,7 @@ object Scope { return Pair(uuid, job) } - fun cancel(uuid: String) { + override fun cancel(uuid: String) { scope.launch { mutex.withLock { jobs[uuid]?.cancel() @@ -150,7 +150,7 @@ object Scope { } } - fun cancel(uuids: Collection) { + override fun cancel(uuids: Collection) { if (uuids.isEmpty()) return scope.launch { @@ -168,7 +168,7 @@ object Scope { } } - fun cancel() { + override fun cancel() { scope.launch { mutex.withLock { try { 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 index 54b612707..74ea40078 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/scopes/StudyScope.kt @@ -1,31 +1,20 @@ -/* - * 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 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 { +object StudyScope : StudyMoreScope { private val mutex = Mutex() private val studyJobs = mutableSetOf() + override val coroutineContext: CoroutineContext = Scope.coroutineContext - fun launch( - coroutineContext: CoroutineContext = Dispatchers.Default, - start: CoroutineStart = CoroutineStart.DEFAULT, + override fun launch( + coroutineContext: CoroutineContext, + start: CoroutineStart, block: suspend CoroutineScope.() -> Unit ): Pair { val result = Scope.launch(coroutineContext, start, block) @@ -47,10 +36,10 @@ object StudyScope { return result } - fun repeatedLaunch( + override fun repeatedLaunch( intervalMillis: Long, - coroutineContext: CoroutineContext = Dispatchers.Default, - initalDelay: Long = 0, + coroutineContext: CoroutineContext, + initalDelay: Long, block: suspend CoroutineScope.() -> Unit ): Pair { val result = Scope.repeatedLaunch(intervalMillis, coroutineContext, initalDelay, block) @@ -72,15 +61,15 @@ object StudyScope { return result } - fun cancel(uuid: String) { + override fun cancel(uuid: String) { Scope.cancel(uuid) } - fun cancel(uuids: Collection) { + override fun cancel(uuids: Collection) { Scope.cancel(uuids) } - fun cancel() { + override fun cancel() { val jobsToCancel = mutex.tryLock().let { acquired -> if (acquired) { try { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt index 21573b81f..e71e2530c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/ObservationService.kt @@ -1,14 +1,3 @@ -/* - * 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 import io.github.aakira.napier.Napier 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 index f15cde444..2e307e1dd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/BluetoothStateManagement.kt @@ -1,14 +1,3 @@ -/* - * 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.bluetooth import com.rickclephas.kmp.nativecoroutines.NativeCoroutines 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 index 1f10fba3a..27b82dfdd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/ScanMode.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/bluetooth/ScanMode.kt @@ -1,14 +1,3 @@ -/* - * 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.bluetooth enum class ScanMode { 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 index d9258fa67..4218b0274 100644 --- 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 @@ -1,14 +1,3 @@ -/* - * 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.bluetooth.polar import com.rickclephas.kmp.nativecoroutines.NativeCoroutines 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 index a6caf484c..7b44150fb 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkClients.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkClients.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 457111fb4..6f1cf973b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkService.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/network/NetworkService.kt @@ -10,9 +10,6 @@ */ 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 @@ -20,268 +17,36 @@ 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" +interface NetworkService { -class NetworkService( - endpointRepository: EndpointRepository, - credentialRepository: CredentialRepository, -) { + fun baseUrl(): String - private val networkClients = NetworkClients(credentialRepository, endpointRepository) + suspend fun deleteParticipation(): Pair - fun baseUrl() = networkClients.baseUrl() - - 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)) - } - } - - 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)) - } - } + suspend fun validateRegistrationToken(loginModel: LoginModel): Pair 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)) - } - } - - suspend fun getStudyConfig(credentials: CredentialModel? = null): 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)) - } - } - - 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)) - } - } - - 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)) - } - } - - 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() - } - } - - fun getBasicAuthHeader(): String? = networkClients.basicAuthHeader() - - 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 - } + ): Pair - fun garminSSOCallbackUrl(): Url? { - return getGarminSSOUrl()?.let { - Url("$it/callback") - } - } + suspend fun getStudyConfig(credentials: CredentialModel? = null): Pair - 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 - } - } + suspend fun sendNotificationToken(token: String): Pair - suspend fun deletePushNotification(msgId: String) { - try { - val client = networkClients.getNotificationApi() ?: return + suspend fun sendData(data: DataBulk): Pair, NetworkServiceError?> - val response = client.deleteNotification(msgId) + suspend fun downloadMissedNotifications(): List - 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" } - } - } + fun getBasicAuthHeader(): String? - 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 - } + fun getGarminSSOUrl(): Url? - NetworkServiceError(code = code, message = error?.message ?: "Error") - } catch (e: Exception) { - getException(e, code) - } - } + fun garminSSOCallbackUrl(): Url? - 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) - } + 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/services/notification/NotificationActionHandler.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt index 0bcba5c09..c26d8f918 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationActionHandler.kt @@ -1,14 +1,3 @@ -/* - * 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 enum class NotificationActionHandler { 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 index b10f18f74..032a2affb 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/notification/NotificationManager.kt @@ -12,7 +12,6 @@ package io.redlink.more.services.notification import com.rickclephas.kmp.nativecoroutines.NativeCoroutines import io.github.aakira.napier.Napier -import io.redlink.more.Shared import io.redlink.more.database.entities.NotificationEntity import io.redlink.more.database.entities.ScheduleEntity import io.redlink.more.database.repository.MainRepository @@ -25,11 +24,11 @@ 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.Dispatchers -import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,24 +50,35 @@ interface LocalNotificationListener { fun updateBadgeCount(count: Int = 0) } -class NotificationManager( +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 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) { + withContext(dispatchers.main) { _unreadUserCount.value = it.size localNotificationListener.updateBadgeCount(it.size) } @@ -78,7 +88,6 @@ class NotificationManager( } fun storeAndHandleNotification( - shared: Shared, key: String, title: String?, body: String?, @@ -89,7 +98,6 @@ class NotificationManager( displayNotification: Boolean ) { storeAndHandleNotification( - shared, NotificationEntity.toEntity( notificationId = key, channelId = null, @@ -140,14 +148,12 @@ class NotificationManager( } fun storeAndHandleNotification( - shared: Shared, notification: NotificationEntity, displayNotification: Boolean ) { storeAndDisplayNotification(notification, displayNotification) if (notification.notificationData.isNotEmpty()) { handleNotificationDataAsync( - shared, notification.getNotificationDataMap() ) } @@ -163,7 +169,7 @@ class NotificationManager( repository.notification.storeNotification(notification) if (displayNotification) { Napier.d(tag = "NotificationManager::storeAndDisplayNotification") { "Displaying notification: $notification" } - withContext(Dispatchers.Main) { + withContext(dispatchers.main) { localNotificationListener.displayNotification( notification, unreadUserCount.value @@ -186,7 +192,8 @@ class NotificationManager( suspend fun downloadMissedNotifications() { Napier.d { "Updating notifications" } - storeNotifications(NotificationEntity.toEntityList(networkService.downloadMissedNotifications())) + val missed = networkService.downloadMissedNotifications() + repository.notification.storeNotifications(NotificationEntity.toEntityList(missed)) } fun deleteNotificationFromRepository(notificationId: String) { @@ -196,7 +203,7 @@ class NotificationManager( fun deleteNotificationFromServer(msgID: String) { Napier.i { "Deleting notification with msgID $msgID from server..." } - Scope.launch(Dispatchers.IO) { + Scope.launch(dispatchers.io) { networkService.deletePushNotification(msgID) } } @@ -205,32 +212,28 @@ class NotificationManager( localNotificationListener.deleteNotificationFromSystem(notificationId = notificationId) } - fun markNotificationAsRead(notificationId: String) { + open fun markNotificationAsRead(notificationId: String) { repository.notification.setNotificationReadStatus(notificationId, true) deleteNotificationFromSystemTray(notificationId) } - fun markNotificationAsCompleted(notificationId: String) { + open fun markNotificationAsCompleted(notificationId: String) { repository.notification.setNotificationCompletedStatus(notificationId, true) deleteNotificationFromSystemTray(notificationId) } - fun handleNotificationDataAsync(shared: Shared, data: Map) { - Scope.launch(Dispatchers.IO) { - handleNotificationData( - shared, - data - ) - } + fun handleNotificationDataAsync(data: Map) { + handleNotificationData( + data + ) } - suspend fun handleNotificationData( - shared: Shared, + fun handleNotificationData( data: Map ) { if (data.isNotEmpty()) { if (data[MAIN_DATA_KEY] == STUDY_CHANGED) { - updateStudy(shared, data) + updateStudy(data) } data[MSG_ID]?.let { deleteNotificationFromServer(it) @@ -238,7 +241,7 @@ class NotificationManager( } } - fun handleNotificationInteraction( + open fun handleNotificationInteraction( notificationId: String, deeplink: String? = null ) { @@ -251,7 +254,7 @@ class NotificationManager( } - fun handleNotificationInteraction( + open fun handleNotificationInteraction( notificationId: String, deepLink: String?, handler: ((NotificationActionHandler, DeepLinkData?) -> Unit) @@ -279,15 +282,15 @@ class NotificationManager( NavigationRoute.OBSERVATION_DETAILS.route ) ) { - withContext(Dispatchers.Main) { + withContext(dispatchers.main) { markNotificationAsRead(notificationId) } } - withContext(Dispatchers.Main) { + withContext(dispatchers.main) { handler(NotificationActionHandler.DEEPLINK, modifiedDeepLink) } } ?: run { - withContext(Dispatchers.Main) { + withContext(dispatchers.main) { markNotificationAsRead(notificationId) } } @@ -344,7 +347,7 @@ class NotificationManager( } private fun storeAndUploadToken(newToken: String) { - Scope.launch(Dispatchers.IO) { + Scope.launch(dispatchers.io) { val (successful, _) = networkService.sendNotificationToken(newToken) sharedStorageRepository.store(FCM_TOKEN_UPLOADED, successful) } @@ -370,7 +373,7 @@ class NotificationManager( val notifications = schedules.map { it.toNotificationEntity(true, deeplinkManager.createDeeplinkForSchedule(it)) } - withContext(Dispatchers.Main) { + withContext(dispatchers.main) { storeNotifications(notifications) notifications.forEach { Napier.i { "Scheduling notification ${it.notificationId} at ${it.timestamp} with deeplink: ${it.deepLink}" } @@ -380,7 +383,7 @@ class NotificationManager( } suspend fun rescheduleNotifications(notifications: List) { - withContext(Dispatchers.Main) { + withContext(dispatchers.main) { notifications.forEach { displayNotification(it) } @@ -396,17 +399,15 @@ class NotificationManager( } } - private suspend fun updateStudy(shared: Shared, data: Map) { + 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) } - shared.updateStudy(oldStudyState, newStudyState) + actionObserver?.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" 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 index 5e1c3f07b..6845fbe7b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepository.kt @@ -11,62 +11,20 @@ package io.redlink.more.services.store import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -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 val _credentialsLoaded = MutableStateFlow(false) - +interface CredentialRepository { @NativeCoroutines - val credentialsLoaded: StateFlow = _credentialsLoaded - private var _cache = MutableStateFlow(null) + val credentialsLoaded: StateFlow @NativeCoroutines - val credentials: StateFlow = _cache + val credentials: StateFlow @NativeCoroutines - val hasCredentials: StateFlow = - credentials.mapState(CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), false) { - it != null - } - - init { - _cache.value = load() - _credentialsLoaded.value = true - } - - 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.value = credentials - return true - } - return false - } - - private fun load(): CredentialModel? { - val apiId = sharedStorageRepository.load(CREDENTIAL_ID, "") - val apiKey = sharedStorageRepository.load(CREDENTIAL_KEY, "") - if (apiId.isNotEmpty() && apiKey.isNotEmpty()) { - return CredentialModel(apiId, apiKey) - } - return null - } + val hasCredentials: StateFlow - fun remove() { - sharedStorageRepository.remove(CREDENTIAL_ID) - sharedStorageRepository.remove(CREDENTIAL_KEY) - _cache.value = null - } + fun store(credentials: CredentialModel): Boolean - companion object { - private const val CREDENTIAL_ID = "sharedStorageCredentialID" - private const val CREDENTIAL_KEY = "sharedStorageCredentialKey" - } + fun remove() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepositoryImpl.kt new file mode 100644 index 000000000..ca540fcad --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/CredentialRepositoryImpl.kt @@ -0,0 +1,69 @@ +/* + * 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 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 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.value = load() + _credentialsLoaded.value = true + } + + 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.value = credentials + return true + } + return false + } + + private fun load(): CredentialModel? { + val apiId = sharedStorageRepository.load(CREDENTIAL_ID, "") + val apiKey = sharedStorageRepository.load(CREDENTIAL_KEY, "") + if (apiId.isNotEmpty() && apiKey.isNotEmpty()) { + return CredentialModel(apiId, apiKey) + } + return null + } + + override fun remove() { + sharedStorageRepository.remove(CREDENTIAL_ID) + sharedStorageRepository.remove(CREDENTIAL_KEY) + _cache.value = null + } + + companion object { + private const val CREDENTIAL_ID = "sharedStorageCredentialID" + private const val CREDENTIAL_KEY = "sharedStorageCredentialKey" + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepository.kt index 85f071992..5dfeb5221 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepository.kt @@ -10,36 +10,10 @@ */ package io.redlink.more.services.store -import io.redlink.more.util.validateAndNormalizeUrl +interface EndpointRepository { + fun storeEndpoint(endpoint: String) -class EndpointRepository(private val sharedStorageRepository: SharedStorageRepository) { - private var cache: String = "" + fun removeEndpoint() - init { - cache = loadEndpoint() - } - - 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() { - cache = "" - sharedStorageRepository.remove(ENDPOINT_KEY) - } - - 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" - } + fun endpoint(): String } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepositoryImpl.kt b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepositoryImpl.kt new file mode 100644 index 000000000..45567ab47 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/services/store/EndpointRepositoryImpl.kt @@ -0,0 +1,46 @@ +/* + * 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 io.redlink.more.util.validateAndNormalizeUrl + +class EndpointRepositoryImpl(private val sharedStorageRepository: SharedStorageRepository) : + EndpointRepository { + private var cache: String = "" + + init { + cache = loadEndpoint() + } + + 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) + } + + override fun removeEndpoint() { + cache = "" + sharedStorageRepository.remove(ENDPOINT_KEY) + } + + 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.platform-test.more.redlink.io/api/v1" + } +} diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt index 270e41922..6d12199fe 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/FlowuUtils.kt @@ -1,14 +1,3 @@ -/* - * 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 kotlinx.coroutines.currentCoroutineContext diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt index 9fec88bd1..c5760bd92 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt @@ -1,14 +1,3 @@ -/* - * 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 diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt index 03afbe2fd..e5abc161b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/RegexData.kt @@ -1,14 +1,3 @@ -/* - * 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 class RegexData { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt index 3ffa5b576..c19276229 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/StringUtils.kt @@ -1,14 +1,3 @@ -/* - * 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 /** diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt index 45d9d6835..a01307912 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt @@ -11,6 +11,7 @@ package io.redlink.more.viewModels import io.ktor.utils.io.core.Closeable +import io.redlink.more.scopes.AppDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -28,10 +29,13 @@ abstract class CoreViewModel : Closeable { } fun launchScope( - coroutineContext: CoroutineContext = Dispatchers.Default, + coroutineContext: CoroutineContext? = null, block: suspend CoroutineScope.() -> Unit ) { - viewModelScope.launch(coroutineContext, block = block) + viewModelScope.launch( + coroutineContext ?: AppDispatchers.default, + block = block + ) } override fun close() { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt index 66faf90aa..b5e06286d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 94a846631..48a9589d9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/PolarController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/PolarController.kt @@ -1,14 +1,3 @@ -/* - * 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 com.rickclephas.kmp.nativecoroutines.NativeCoroutines diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt index c97a2fbd5..d12937988 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt @@ -25,7 +25,7 @@ import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.plus -class CoreDashboardFilterViewModel(repository: MainRepository) : CoreViewModel() { +open class CoreDashboardFilterViewModel(repository: MainRepository) : CoreViewModel() { val currentTypeFilter = MutableStateFlow(emptyMap()) val currentDateFilter = MutableStateFlow( DateFilterModel.entries.associateWith { it == DateFilterModel.ENTIRE_TIME }) @@ -78,7 +78,7 @@ class CoreDashboardFilterViewModel(repository: MainRepository) : CoreViewModel() fun filterActive() = activeDateFilter() || activeTypeFilter() - fun applyFilter(scheduleModelList: Collection): Collection { + open fun applyFilter(scheduleModelList: Collection): Collection { var schedules = scheduleModelList.toList() if (filterActive()) { if (activeTypeFilter()) { 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 index e75075719..ca332efa5 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt @@ -1,14 +1,3 @@ -/* - * 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 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 index 35c522971..7f6ed2e77 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt @@ -14,7 +14,8 @@ import io.redlink.more.models.LoginModel import io.redlink.more.registration.RegistrationService import io.redlink.more.viewModels.CoreViewModel -class CoreLoginViewModel(private val registrationService: RegistrationService) : CoreViewModel() { +open class CoreLoginViewModel(private val registrationService: RegistrationService) : + CoreViewModel() { fun sendRegistrationToken( loginModel: LoginModel diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt index f09bce34a..2cdf57f98 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -19,7 +19,7 @@ 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 private val _filters = MutableStateFlow>(mapOf()) @@ -63,7 +63,7 @@ class CoreNotificationFilterViewModel : CoreViewModel() { 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) { @@ -79,7 +79,7 @@ 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 diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt index bc674a7ee..9bd6bfc4e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt @@ -21,7 +21,7 @@ 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, ) : CoreViewModel() { diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt index ca3bc4a1d..2f15b8f68 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt @@ -49,9 +49,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/schedules/CoreScheduleViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt index 1afab2cae..11e832734 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt @@ -37,11 +37,11 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -class CoreScheduleViewModel( +open class CoreScheduleViewModel( private val repos: MainRepository, private val dataRecorder: DataRecorder, private val scheduleListType: ScheduleListType, - val coreFilterModel: CoreDashboardFilterViewModel + val coreFilterModel: CoreDashboardFilterViewModel, ) : CoreViewModel() { private val scheduleStates = mutableSetOf() private var originalScheduleList = emptySet() 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 index 9fe8e6324..f1409e7fa 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt @@ -18,8 +18,12 @@ 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) : CoreViewModel() { +class CoreTaskCompletionBarViewModel( + private val repository: MainRepository, + dispatcher: CoroutineContext? = null +) : CoreViewModel() { private val _taskCompletion: MutableStateFlow = MutableStateFlow(TaskCompletion()) @@ -27,7 +31,7 @@ class CoreTaskCompletionBarViewModel(private val repository: MainRepository) : C val taskCompletion: StateFlow = _taskCompletion init { - launchScope { + launchScope(dispatcher) { repository.schedule.count() .combine( repository.schedule.allSchedulesWithStatus(true).cancellable() 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..62acc6753 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt @@ -0,0 +1,32 @@ +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 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/ImMemoryStorageRepository.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/InMemoryStorageRepository.kt similarity index 72% rename from shared/src/commonTest/kotlin/io/redlink/more/ImMemoryStorageRepository.kt rename to shared/src/commonTest/kotlin/io/redlink/more/mocks/InMemoryStorageRepository.kt index f3b7d3a4d..555fe311e 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/ImMemoryStorageRepository.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/InMemoryStorageRepository.kt @@ -1,19 +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 +package io.redlink.more.mocks import io.redlink.more.services.store.SharedStorageRepository -class ImMemoryStorageRepository : 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..17c6c0b89 --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt @@ -0,0 +1,420 @@ +package io.redlink.more.mocks + +import io.ktor.utils.io.core.Closeable +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.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 } + + 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 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() + + 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 + + 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 + + override suspend fun deleteAll() {} +} + +class MockObservationFactory( + repository: MainRepository = MockMainRepository(), + dataManager: ObservationDataManager? = null +) : ObservationFactory(repository, 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..a9c83a38d --- /dev/null +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt @@ -0,0 +1,14 @@ +package io.redlink.more.mocks + +import dev.tmapps.konnection.Konnection +import io.redlink.more.database.repository.MainRepository +import io.redlink.more.observations.ObservationDataManager + + +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) {} + } +} 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/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/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) + } +} From 2f13289c391e3ed85f1fe4b9f0017902b8b6b4f2 Mon Sep 17 00:00:00 2001 From: florentinposch Date: Tue, 24 Mar 2026 11:07:48 +0100 Subject: [PATCH 48/59] fix: localization for inputfields --- .../Resources/Strings/Localizable.xcstrings | 23 ++++++++++++++++--- .../Views/Components/ExpandableInput.swift | 4 ++-- .../Views/Components/MoreTextField.swift | 2 +- .../Views/Components/MoreTextFieldHL.swift | 4 ++-- .../Components/MoreTextFieldSmBottom.swift | 2 +- iosApp/iosApp/Views/Login/LoginView.swift | 4 ++-- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 24f0e0fee..471b60f87 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -563,19 +563,19 @@ } } }, - "enter_study_url" : { + "enter_study_endpoint_placeholder" : { "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "URL der Studie eigeben" + "value" : "URL der Studie eingeben" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enter study URL" + "value" : "Enter Study URL" } } } @@ -1382,6 +1382,23 @@ } } }, + "participation_key_entry_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Token eingeben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter Token" + } + } + } + }, "pause_observation" : { "comment" : "A label for a button that pauses an observation.", "isCommentAutoGenerated" : true, diff --git a/iosApp/iosApp/Views/Components/ExpandableInput.swift b/iosApp/iosApp/Views/Components/ExpandableInput.swift index fb3dd0798..6e1bde76f 100644 --- a/iosApp/iosApp/Views/Components/ExpandableInput.swift +++ b/iosApp/iosApp/Views/Components/ExpandableInput.swift @@ -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/MoreTextField.swift b/iosApp/iosApp/Views/Components/MoreTextField.swift index a54e22918..3ab21d5c0 100644 --- a/iosApp/iosApp/Views/Components/MoreTextField.swift +++ b/iosApp/iosApp/Views/Components/MoreTextField.swift @@ -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 1c20b1ec0..838396640 100644 --- a/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift +++ b/iosApp/iosApp/Views/Components/MoreTextFieldHL.swift @@ -37,9 +37,9 @@ struct MoreTextFieldHL: View { .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) } } } diff --git a/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift b/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift index cabb7c0d4..325fb64cd 100644 --- a/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift +++ b/iosApp/iosApp/Views/Components/MoreTextFieldSmBottom.swift @@ -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) diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index e31074d19..00c2e5084 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -45,7 +45,7 @@ struct LoginView: View { MoreTextFieldHL( isSmTextfield: .constant(false), headerText: "participation_key_entry", - inputPlaceholder: .constant("participation_key_entry"), + inputPlaceholder: .constant("participation_key_entry_placeholder"), input: $model.token, capitalization: .uppercase, autoCorrectDisabled: true, @@ -121,7 +121,7 @@ struct LoginView: View { expanded: $showEndpoint, isSmTextfield: .constant(true), headerText: .constant("study_endpoint_headling"), - inputPlaceholder: .constant("enter_study_endpoint"), + inputPlaceholder: .constant("enter_study_endpoint_placeholder"), input: $model.endpoint, capitalization: .lowercase, textType: .URL From f5081e912e644730f5a4937b7f0ef86fb8f7c9d1 Mon Sep 17 00:00:00 2001 From: florentinposch Date: Tue, 24 Mar 2026 16:08:20 +0100 Subject: [PATCH 49/59] fix: localization for navigation screen --- iosApp/iosApp/Extensions/NavigationScreen.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iosApp/iosApp/Extensions/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index cba5d8fbb..6192bb6c7 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -142,7 +142,8 @@ extension NavigationScreen { } func localize() -> String { - return values.screenName + let localizedKey = String.LocalizationValue(stringLiteral: values.screenName) + return String(localized: localizedKey) } func generateURL(withParameters params: [NavigationRouteParameter: String]) -> URL? { From 621633e03cb302b504bbce84956adf3eae092ca5 Mon Sep 17 00:00:00 2001 From: florentinposch Date: Wed, 25 Mar 2026 09:34:03 +0100 Subject: [PATCH 50/59] fix: localization observation type --- .../Resources/Strings/Localizable.xcstrings | 53 ++++++++++++++++++- .../Schedule/List/ObservationDetails.swift | 2 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 471b60f87..20f7b1416 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -225,6 +225,23 @@ } } }, + "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" : { @@ -959,6 +976,23 @@ } } }, + "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" : { @@ -1868,6 +1902,23 @@ } } }, + "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" : { @@ -2025,7 +2076,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Starte den Fragebogen" + "value" : "Starte den Fragebogen" } }, "en" : { diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift b/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift index d193636cd..946c728b0 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift @@ -28,7 +28,7 @@ struct ObservationDetails: View { .font(Font.more.headline) .foregroundColor(Color.more.primary) .padding(.bottom, 1) - Text(observationType) + Text(LocalizedStringKey(observationType)) .foregroundColor(Color.more.secondary) } .padding(4) From 3e35a90fa7a5bbbb82a183066d934bd13fb2a79e Mon Sep 17 00:00:00 2001 From: florentinposch Date: Wed, 25 Mar 2026 09:34:42 +0100 Subject: [PATCH 51/59] fix: screen observation filter --- iosApp/iosApp/Extensions/NavigationScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/iosApp/Extensions/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index 6192bb6c7..939960a68 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -70,7 +70,7 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { case .questionObservationThanks: return NavigationScreenValues(screenName: "Question Thanks", navigationLink: .questionnaireResponse, fullScreen: true) case .dashboardFilter: - return NavigationScreenValues(screenName: "Dashboard Filter", navigationLink: .dashboard) + return NavigationScreenValues(screenName: "Dashboard Filter", navigationLink: .observationFilter) case .notificationFilter: return NavigationScreenValues(screenName: "Notification Filter", navigationLink: .notificationFilter) case .pastObservations: From 94a4fdefa20fc642fe78c675af9c809b2bdfc5eb Mon Sep 17 00:00:00 2001 From: florentinposch Date: Wed, 25 Mar 2026 10:09:18 +0100 Subject: [PATCH 52/59] fix: add missing localization nachrichten filter --- .../Resources/Strings/Localizable.xcstrings | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 20f7b1416..0cc539091 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -1147,6 +1147,17 @@ } } }, + "Notification Filter" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nachrichten Filter" + } + } + } + }, "Notification Permissions Not Granted" : { "extractionState" : "manual", "localizations" : { @@ -1433,6 +1444,17 @@ } } }, + "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, From 952774c6571d290022ee58cbb4b701e9941839a5 Mon Sep 17 00:00:00 2001 From: florentinposch Date: Wed, 25 Mar 2026 10:25:15 +0100 Subject: [PATCH 53/59] fix: localization of unread + important notification filter --- .../Resources/Strings/Localizable.xcstrings | 10 +++++----- .../Notification/NotificationViewModel.swift | 15 ++++++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 0cc539091..36213e526 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -761,13 +761,13 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Nur wichtige" + "value" : "Wichtig" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Only Important" + "value" : "Important" } } } @@ -2626,14 +2626,14 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", - "value" : "Nicht gelesene" + "state" : "needs_review", + "value" : "Ungelesen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Not read" + "value" : "Unread" } } } diff --git a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift index cbffd31e9..351ad4c46 100644 --- a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift +++ b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift @@ -40,12 +40,17 @@ class NotificationViewModel: ObservableObject { .store(in: &cancellables) createPublisher(for: filterViewModel.activeTypes) - .map { (types: Set) -> String in - guard !types.isEmpty else { - return "" + .map { (types: Set) -> String in + guard !types.isEmpty else { + return "" + } + + let localized = types + .sorted() + .map { String(localized: String.LocalizationValue($0)) } + + return ListFormatter.localizedString(byJoining: localized) } - return types.sorted().joined(separator: ", ") - } .removeDuplicates() .receive(on: DispatchQueue.main) .sink(receiveCompletion: { _ in }) { [weak self] text in From c2889b39ad3907230dbcfe098dd7eac8c5bec953 Mon Sep 17 00:00:00 2001 From: florentinposch Date: Thu, 26 Mar 2026 21:05:05 +0100 Subject: [PATCH 54/59] draft: share some string resources between android and ios --- .../schedule/list/ScheduleListItem.kt | 3 +- .../notification/NotificationViewModel.kt | 8 +++-- .../filter/NotificationFilterView.kt | 6 ++-- .../android/extensions/ResourceExtension.kt | 5 +++ .../src/main/res/values-de/filter-strings.xml | 3 -- .../values-de/observation-type-strings.xml | 10 ++++++ .../src/main/res/values/filter-strings.xml | 3 -- .../res/values/observation-type-strings.xml | 10 ++++++ .../Resources/Strings/Localizable.xcstrings | 32 +++++++++---------- .../Filter/NotificationFilterView.swift | 14 +++++--- .../kotlin/io/redlink/more/Shared.kt | 3 ++ .../models/NotificationFilterTypeModel.kt | 23 ++++--------- .../CoreNotificationFilterViewModel.kt | 4 ++- .../moko-resources/base/strings.xml | 4 +++ .../commonMain/moko-resources/de/strings.xml | 4 +++ 15 files changed, 83 insertions(+), 49 deletions(-) create mode 100644 androidApp/src/main/res/values-de/observation-type-strings.xml create mode 100644 androidApp/src/main/res/values/observation-type-strings.xml 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 73bb33a56..8e759b0ed 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 @@ -33,6 +33,7 @@ import io.redlink.more.app.android.R 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.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.SmallTitle @@ -81,7 +82,7 @@ fun ScheduleListItem( modifier = Modifier.fillMaxWidth() ) { BasicText( - text = scheduleModel().observationType, + text = getStringResourceByName(scheduleModel().observationType.replace("-", "_")), color = MoreColors.Secondary ) Row(horizontalArrangement = Arrangement.End) { 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 396ca4fd9..26375eade 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 @@ -13,8 +13,12 @@ package io.redlink.more.app.android.activities.notification import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.navigation.NavController +import io.redlink.more.Shared.Companion.getSharedResource +import io.redlink.more.SharedRes 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.extensions.sharedResource import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.models.NotificationModel import io.redlink.more.services.notification.NotificationActionHandler @@ -42,8 +46,8 @@ class NotificationViewModel(private val coreFilterViewModel: CoreNotificationFil fun getFilterString(): String { if (!coreFilterViewModel.filterActive()) { - return stringResource(R.string.more_filter_notification_all) + return sharedResource(SharedRes.strings.more_filter_notification_all) } - return coreFilterViewModel.activeTypes.value.joinToString(", ") + return coreFilterViewModel.activeTypes.value.joinToString(", ") { sharedResource(it) } } } 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 5e5fcfbcf..2225dce61 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 @@ -27,8 +27,11 @@ 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.Shared.Companion.getSharedResource import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.extensions.getStringResourceByName +import io.redlink.more.app.android.extensions.sharedResource 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 @@ -70,7 +73,7 @@ fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { .padding(4.dp) ) { HeaderDescription( - description = entry.key.type, + description = sharedResource(entry.key.type), color = MoreColors.Secondary ) } @@ -78,5 +81,4 @@ fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { 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/extensions/ResourceExtension.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ResourceExtension.kt index 126d9be36..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 @@ -13,6 +13,8 @@ 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 @@ -30,4 +32,7 @@ fun getSecureID(context: Context) = fun getProductName() = 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/res/values-de/filter-strings.xml b/androidApp/src/main/res/values-de/filter-strings.xml index a6cc9ce15..d460d2e66 100644 --- a/androidApp/src/main/res/values-de/filter-strings.xml +++ b/androidApp/src/main/res/values-de/filter-strings.xml @@ -23,7 +23,4 @@ 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/observation-type-strings.xml b/androidApp/src/main/res/values-de/observation-type-strings.xml new file mode 100644 index 000000000..ec81ad88a --- /dev/null +++ b/androidApp/src/main/res/values-de/observation-type-strings.xml @@ -0,0 +1,10 @@ + + + Frage + Fragebogen + Multiple Choice + GPS + Accelerometer + App Nutzung + Polar Verity + \ 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..21d5c2ece 100644 --- a/androidApp/src/main/res/values/filter-strings.xml +++ b/androidApp/src/main/res/values/filter-strings.xml @@ -23,7 +23,4 @@ Select Filter - All Notifications - Unread - Important \ 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..02d43f38a --- /dev/null +++ b/androidApp/src/main/res/values/observation-type-strings.xml @@ -0,0 +1,10 @@ + + + Question + Questionnaire + Multiple Choice + GPS + Accelerometer + App Usage + Polar Verity + \ No newline at end of file diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 36213e526..841aa5a0a 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -135,46 +135,46 @@ "comment" : "The title of an alert.", "isCommentAutoGenerated" : true }, - "All" : { - "extractionState" : "manual", + "All Items" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Alle Nachrichten" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All Notifications" + "value" : "Alle Daten" } } } }, - "All Items" : { + "All types" : { + "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Alle Daten" + "value" : "Alle Typen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All types" } } } }, - "All types" : { + "All1" : { "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Alle Typen" + "value" : "Alle Nachrichten" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "All types" + "value" : "All Notifications" } } } @@ -755,7 +755,7 @@ } } }, - "Important" : { + "Important1" : { "extractionState" : "manual", "localizations" : { "de" : { @@ -2621,7 +2621,7 @@ } } }, - "Unread" : { + "Unread1" : { "extractionState" : "manual", "localizations" : { "de" : { diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift index bbb6e377d..1576c6f08 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift @@ -31,14 +31,18 @@ struct NotificationFilterView: View { .padding(15) Divider() - ForEach(viewModel.allFilters.keys.sorted { - $0.sortIndex < $1.sortIndex - }, id: \.self) { filter in - if let selected = viewModel.allFilters[filter] { + ForEach( + viewModel.allFilters.keys.sorted { $0.sortIndex < $1.sortIndex }, + id: \.name + ) { filter in + if let selected = viewModel.allFilters.get(key: filter) { Button { viewModel.toggleFilters(filter: filter) } label: { - MoreFilterOption(option: filter.type, isSelected: .constant(selected)) + MoreFilterOption( + option: filter.type.localize(), + isSelected: .constant(selected) + ) Spacer() } .buttonStyle(.borderless) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt index 0936c4ebd..7c29f719e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -10,6 +10,7 @@ */ 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 @@ -418,5 +419,7 @@ open class Shared( 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/models/NotificationFilterTypeModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt index 160c2b9b4..a5921f0ac 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt @@ -10,21 +10,12 @@ */ package io.redlink.more.models -enum class NotificationFilterTypeModel(val type: String, val sortIndex: Int) { - ALL("All", 0), - UNREAD("Unread", 1), - IMPORTANT("Important", 2); +import dev.icerock.moko.resources.StringResource +import io.redlink.more.SharedRes + +enum class NotificationFilterTypeModel(val type: StringResource, val sortIndex: Int) { + ALL(SharedRes.strings.more_filter_notification_all, 0), + UNREAD(SharedRes.strings.more_filter_notification_unread, 1), + IMPORTANT(SharedRes.strings.more_filter_notification_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/viewModels/notifications/CoreNotificationFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt index 2cdf57f98..0bcee9fcd 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -11,6 +11,8 @@ package io.redlink.more.viewModels.notifications import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import dev.icerock.moko.resources.StringResource +import io.redlink.more.Shared.Companion.getSharedResource import io.redlink.more.extensions.mapState import io.redlink.more.extensions.set import io.redlink.more.models.NotificationFilterTypeModel @@ -28,7 +30,7 @@ open class CoreNotificationFilterViewModel : CoreViewModel() { val filters: StateFlow> = _filters @NativeCoroutines - val activeTypes: StateFlow> = filters.mapState(viewModelScope) { + val activeTypes: StateFlow> = filters.mapState(viewModelScope) { it.filter { it.value }.map { it.key.type }.toSet() } diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 423f36be8..4a820b8f9 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -4,4 +4,8 @@ app io.redlink.more + + All Notifications + Unread + Important diff --git a/shared/src/commonMain/moko-resources/de/strings.xml b/shared/src/commonMain/moko-resources/de/strings.xml index 1486e9a21..6ce0698fe 100644 --- a/shared/src/commonMain/moko-resources/de/strings.xml +++ b/shared/src/commonMain/moko-resources/de/strings.xml @@ -1,4 +1,8 @@ Bitte öffne die App und starte die Beobachtung! + + Alle Nachrichten + Ungelesen + Wichtig \ No newline at end of file From 36ca9a7c195b774eac829aab5f61c4868ceb2d2b Mon Sep 17 00:00:00 2001 From: florentinposch Date: Wed, 1 Apr 2026 08:35:52 +0200 Subject: [PATCH 55/59] draft: revert NotificationFilterTypeModel.kt localization changes to get a working draft again; --- .../activities/notification/NotificationViewModel.kt | 2 +- .../notification/filter/NotificationFilterView.kt | 2 +- .../io/redlink/more/models/NotificationFilterTypeModel.kt | 8 ++++---- .../notifications/CoreNotificationFilterViewModel.kt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) 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 26375eade..1d139caaa 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 @@ -48,6 +48,6 @@ class NotificationViewModel(private val coreFilterViewModel: CoreNotificationFil if (!coreFilterViewModel.filterActive()) { return sharedResource(SharedRes.strings.more_filter_notification_all) } - return coreFilterViewModel.activeTypes.value.joinToString(", ") { sharedResource(it) } + return coreFilterViewModel.activeTypes.value.joinToString(", ") { it } } } 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 2225dce61..77a73c88e 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 @@ -73,7 +73,7 @@ fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { .padding(4.dp) ) { HeaderDescription( - description = sharedResource(entry.key.type), + description = entry.key.type, color = MoreColors.Secondary ) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt index a5921f0ac..4dc21fa22 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt @@ -13,9 +13,9 @@ package io.redlink.more.models import dev.icerock.moko.resources.StringResource import io.redlink.more.SharedRes -enum class NotificationFilterTypeModel(val type: StringResource, val sortIndex: Int) { - ALL(SharedRes.strings.more_filter_notification_all, 0), - UNREAD(SharedRes.strings.more_filter_notification_unread, 1), - IMPORTANT(SharedRes.strings.more_filter_notification_important, 2); +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/viewModels/notifications/CoreNotificationFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt index 0bcee9fcd..4b028189a 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -30,7 +30,7 @@ open class CoreNotificationFilterViewModel : CoreViewModel() { val filters: StateFlow> = _filters @NativeCoroutines - val activeTypes: StateFlow> = filters.mapState(viewModelScope) { + val activeTypes: StateFlow> = filters.mapState(viewModelScope) { it.filter { it.value }.map { it.key.type }.toSet() } From d36e8ad266900d7fdaa4e5501fb75b7119515afc Mon Sep 17 00:00:00 2001 From: florentinposch Date: Wed, 1 Apr 2026 08:35:52 +0200 Subject: [PATCH 56/59] fix: revert shared localization changes to get a working state again; --- .../notification/NotificationViewModel.kt | 6 +--- .../filter/NotificationFilterView.kt | 3 -- .../src/main/res/values-de/filter-strings.xml | 3 ++ .../src/main/res/values/filter-strings.xml | 3 ++ .../Resources/Strings/Localizable.xcstrings | 32 +++++++++---------- .../Filter/NotificationFilterView.swift | 14 +++----- .../models/NotificationFilterTypeModel.kt | 3 -- .../CoreNotificationFilterViewModel.kt | 2 -- 8 files changed, 28 insertions(+), 38 deletions(-) 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 1d139caaa..be7b356de 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 @@ -13,12 +13,8 @@ package io.redlink.more.app.android.activities.notification import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.navigation.NavController -import io.redlink.more.Shared.Companion.getSharedResource -import io.redlink.more.SharedRes 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.extensions.sharedResource import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.models.NotificationModel import io.redlink.more.services.notification.NotificationActionHandler @@ -46,7 +42,7 @@ class NotificationViewModel(private val coreFilterViewModel: CoreNotificationFil fun getFilterString(): String { if (!coreFilterViewModel.filterActive()) { - return sharedResource(SharedRes.strings.more_filter_notification_all) + return stringResource(R.string.more_filter_notification_all) } return coreFilterViewModel.activeTypes.value.joinToString(", ") { it } } 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 77a73c88e..0ff90d2b7 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 @@ -27,11 +27,8 @@ 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.Shared.Companion.getSharedResource import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource -import io.redlink.more.app.android.extensions.getStringResourceByName -import io.redlink.more.app.android.extensions.sharedResource 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 diff --git a/androidApp/src/main/res/values-de/filter-strings.xml b/androidApp/src/main/res/values-de/filter-strings.xml index d460d2e66..a6cc9ce15 100644 --- a/androidApp/src/main/res/values-de/filter-strings.xml +++ b/androidApp/src/main/res/values-de/filter-strings.xml @@ -23,4 +23,7 @@ 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/filter-strings.xml b/androidApp/src/main/res/values/filter-strings.xml index 21d5c2ece..a3081eb9b 100644 --- a/androidApp/src/main/res/values/filter-strings.xml +++ b/androidApp/src/main/res/values/filter-strings.xml @@ -23,4 +23,7 @@ Select Filter + All Notifications + Unread + Important \ No newline at end of file diff --git a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 841aa5a0a..36213e526 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -135,46 +135,46 @@ "comment" : "The title of an alert.", "isCommentAutoGenerated" : true }, - "All Items" : { + "All" : { + "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Alle Daten" + "value" : "Alle Nachrichten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All Notifications" } } } }, - "All types" : { - "extractionState" : "manual", + "All Items" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Alle Typen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All types" + "value" : "Alle Daten" } } } }, - "All1" : { + "All types" : { "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Alle Nachrichten" + "value" : "Alle Typen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "All Notifications" + "value" : "All types" } } } @@ -755,7 +755,7 @@ } } }, - "Important1" : { + "Important" : { "extractionState" : "manual", "localizations" : { "de" : { @@ -2621,7 +2621,7 @@ } } }, - "Unread1" : { + "Unread" : { "extractionState" : "manual", "localizations" : { "de" : { diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift index 1576c6f08..bbb6e377d 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift @@ -31,18 +31,14 @@ struct NotificationFilterView: View { .padding(15) Divider() - ForEach( - viewModel.allFilters.keys.sorted { $0.sortIndex < $1.sortIndex }, - id: \.name - ) { filter in - if let selected = viewModel.allFilters.get(key: filter) { + 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(), - isSelected: .constant(selected) - ) + MoreFilterOption(option: filter.type, isSelected: .constant(selected)) Spacer() } .buttonStyle(.borderless) diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt index 4dc21fa22..06101d625 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/NotificationFilterTypeModel.kt @@ -10,9 +10,6 @@ */ package io.redlink.more.models -import dev.icerock.moko.resources.StringResource -import io.redlink.more.SharedRes - enum class NotificationFilterTypeModel(val type: String, val sortIndex: Int) { ALL("All", 0), UNREAD("Unread", 1), diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt index 4b028189a..2cdf57f98 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -11,8 +11,6 @@ package io.redlink.more.viewModels.notifications import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import dev.icerock.moko.resources.StringResource -import io.redlink.more.Shared.Companion.getSharedResource import io.redlink.more.extensions.mapState import io.redlink.more.extensions.set import io.redlink.more.models.NotificationFilterTypeModel From 1c151aa24371560248e2b1426171bda396474718 Mon Sep 17 00:00:00 2001 From: florentinposch Date: Mon, 4 May 2026 14:36:55 +0200 Subject: [PATCH 57/59] fix: observation type + notification filter localization --- .../activities/dashboard/filter/DashboardFilterView.kt | 4 +++- .../dashboard/schedule/list/ScheduleListItem.kt | 3 ++- .../notification/filter/NotificationFilterView.kt | 3 ++- .../app/android/extensions/ResourceMappingExtension.kt | 10 ++++++++++ .../more/app/android/extensions/StringExtension.kt | 3 +++ androidApp/src/main/res/values-de/filter-strings.xml | 3 --- .../main/res/values-de/notification-view-strings.xml | 7 ------- androidApp/src/main/res/values/filter-strings.xml | 3 --- .../src/main/res/values/notification-view-strings.xml | 7 ------- 9 files changed, 20 insertions(+), 23 deletions(-) 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 33a1765d7..a3b73a293 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 @@ -30,6 +30,8 @@ import androidx.compose.ui.Modifier 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.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 @@ -140,7 +142,7 @@ fun DashboardFilterView(coreScheduleViewModel: CoreScheduleViewModel) { .padding(4.dp) ) { HeaderDescription( - description = item.key, + description = getStringResourceByName(item.key.observationTypeToResource()), color = if (item.value) MoreColors.Primary else MoreColors.Secondary ) } 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 8e759b0ed..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 @@ -35,6 +35,7 @@ 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.SmallTitle import io.redlink.more.app.android.shared_composables.TimeframeHours @@ -82,7 +83,7 @@ fun ScheduleListItem( modifier = Modifier.fillMaxWidth() ) { BasicText( - text = getStringResourceByName(scheduleModel().observationType.replace("-", "_")), + text = getStringResourceByName(scheduleModel().observationType.observationTypeToResource()), color = MoreColors.Secondary ) Row(horizontalArrangement = Arrangement.End) { 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 0ff90d2b7..92a7762ea 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 @@ -28,6 +28,7 @@ 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.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 @@ -70,7 +71,7 @@ fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { .padding(4.dp) ) { HeaderDescription( - description = entry.key.type, + description = entry.key.toString().formatNotificationFilterString(), color = MoreColors.Secondary ) } 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 f43b84c54..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,8 +10,10 @@ */ package io.redlink.more.app.android.extensions +import io.redlink.more.SharedRes import io.redlink.more.app.android.R import io.redlink.more.models.DateFilterModel +import io.redlink.more.models.NotificationFilterTypeModel fun String.formatDateFilterString(): String { return when (this) { @@ -22,3 +24,11 @@ fun String.formatDateFilterString(): String { } } + +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) + } +} 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 12a6d87f8..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 @@ -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/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/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/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/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 From 681a3bb40adfa4498bf4003958b7e98d34c1f4a5 Mon Sep 17 00:00:00 2001 From: florentinposch Date: Wed, 6 May 2026 15:34:31 +0200 Subject: [PATCH 58/59] fix: remove duplicate Ktor JSON configuration in HttpClient setup --- .../io/redlink/more/services/network/HttpClientReceiver.kt | 5 ----- .../io/redlink/more/services/network/HttpClientReceiver.kt | 5 ----- 2 files changed, 10 deletions(-) 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 index c820099f9..3c0f4d66d 100644 --- a/shared/src/androidMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt +++ b/shared/src/androidMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt @@ -12,7 +12,6 @@ package io.redlink.more.services.network import io.ktor.client.HttpClient import io.ktor.client.engine.android.Android -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 @@ -20,12 +19,8 @@ import io.ktor.client.plugins.logging.Logging 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(Android) { - install(ContentNegotiation) { - json() - } defaultRequest { contentType(ContentType.Application.Json) diff --git a/shared/src/iosMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt b/shared/src/iosMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt index fccc908e1..f13ab59a3 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/services/network/HttpClientReceiver.kt @@ -13,7 +13,6 @@ 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.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger @@ -21,12 +20,8 @@ import io.ktor.client.plugins.logging.Logging 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) From d8edac627b3072899318aeabbee429055f237bf8 Mon Sep 17 00:00:00 2001 From: Jan Cortiel <37823749+janoliver20@users.noreply.github.com> Date: Thu, 7 May 2026 14:25:49 +0200 Subject: [PATCH 59/59] #369: Implemented App Usage observation (#383) * #369: Implemented App Usage observation Other changes: - Better observation initalization process for less memory usage - Added a long running observation storage possibility, so data are not sent right away * #369: Build issue fix * #369: Small fix * Small merging fixes * #369: Multiple bug fixes: - GPS recording on iOS and Android - Permission request now fully works - Notification deeplinks no longer infinitley route to the notificationview when a notification is clicked without deeplink - Translation Strings added - Cleanup * #369: Multiple bug fixes: - Permission request improvements * #369: Multiple bug fixes: - Fixed an view foreground/background issue --- androidApp/src/main/AndroidManifest.xml | 2 +- .../more/app/android/MoreApplication.kt | 23 +- .../android/activities/ContentViewModel.kt | 4 +- .../Views.kt => OnAppearDisappear.kt} | 23 +- .../bluetooth/BluetoothViewModel.kt | 16 +- .../CompletedSchedulesView.kt | 40 +- .../android/activities/consent/ConsentView.kt | 93 ++--- .../activities/consent/ConsentViewModel.kt | 28 +- .../consent/composables/ConsentButtons.kt | 18 +- .../activities/dashboard/DashboardView.kt | 41 ++- .../dashboard/filter/DashboardFilterView.kt | 187 +++++----- .../filter/DashboardFilterViewModel.kt | 2 +- .../app/android/activities/info/InfoView.kt | 317 ++++++++-------- .../android/activities/info/InfoViewModel.kt | 4 +- .../leaveStudy/LeaveStudyConfirmView.kt | 142 ++++---- .../activities/leaveStudy/LeaveStudyView.kt | 130 ++++--- .../leaveStudy/LeaveStudyViewModel.kt | 30 +- .../activities/login/LoginViewModel.kt | 18 +- .../android/activities/main/MainActivity.kt | 3 +- .../notification/NotificationView.kt | 76 ++-- .../notification/NotificationViewModel.kt | 9 +- .../filter/NotificationFilterView.kt | 69 ++-- .../filter/NotificationFilterViewModel.kt | 2 +- .../garmin/GarminConnectActivity.kt | 4 +- .../garmin/GarminConnectViewModel.kt | 6 +- .../limeSurvey/LimeSurveyViewModel.kt | 4 +- .../questionnaire/QuestionnaireView.kt | 66 ++-- .../runningSchedules/RunningSchedulesView.kt | 39 +- .../activities/setting/SettingsView.kt | 107 ++++-- .../activities/setting/SettingsViewModel.kt | 12 +- .../studyDetails/StudyDetailsView.kt | 86 ++--- .../ObservationDetailsView.kt | 122 +++---- .../ObservationDetailsViewModel.kt | 15 +- .../activities/subcomponents/ExitButton.kt | 28 +- .../tasks/ObservationActionButton.kt | 110 ++++-- .../activities/tasks/TaskDetailsView.kt | 211 +++++------ .../activities/tasks/TaskDetailsViewModel.kt | 2 - .../app/android/activities/web/WebClient.kt | 5 +- .../extensions/ComposableExtensions.kt | 6 +- .../AndroidObservationDataManager.kt | 6 +- .../observations/AndroidObservationFactory.kt | 46 ++- .../AndroidObservationPermissionObserver.kt | 91 +++++ .../observations/GPS/GPSObservation.kt | 69 +--- .../android/observations/GPS/GPSService.kt | 5 +- .../HR/PolarHeartRateObservation.kt | 2 - .../observations/ObservationExtension.kt | 20 +- .../ObservationManagerExtension.kt | 7 +- .../ObservationServiceExtension.kt | 7 +- .../android/observations/PermissionUtils.kt | 36 +- .../accelerometer/AccelerometerObservation.kt | 15 +- .../services/ObservationRecordingService.kt | 160 ++++++-- .../shared_composables/MessageAlertDialog.kt | 12 +- .../MoreBackgroundComposable.kt | 8 +- .../res/values-de/alert-dialog-strings.xml | 1 + .../src/main/res/values-de/error-strings.xml | 1 + .../values-de/observation-type-strings.xml | 2 + .../main/res/values/alert-dialog-strings.xml | 1 + .../src/main/res/values/error-strings.xml | 1 + .../res/values/observation-type-strings.xml | 2 + gradle.properties | 13 +- iosApp/iosApp.xcodeproj/project.pbxproj | 16 + iosApp/iosApp/AppDelegate.swift | 14 +- iosApp/iosApp/Info.plist | 32 +- iosApp/iosApp/InfoPlist.xcstrings | 171 +++++++++ .../iosApp/Observations/GPSObservation.swift | 89 +++-- .../Observations/IOSObservationFactory.swift | 22 +- .../IOSObservationPermissionObserver.swift | 159 ++++++++ .../Resources/Strings/Localizable.xcstrings | 4 - .../iosApp/Services/PermissionManager.swift | 98 +++-- iosApp/iosApp/Utils/Napier.swift | 44 ++- .../CompletedSchedules.swift | 7 +- .../iosApp/Views/Components/ExitButton.swift | 2 +- .../Views/Components/MoreAlertDialog.swift | 35 +- .../Components/WebView/WebViewViewModel.swift | 1 + iosApp/iosApp/Views/Consent/ConsentView.swift | 7 +- .../Views/Consent/ConsentViewModel.swift | 49 +-- .../Views/Dashboard/DashboardView.swift | 8 +- .../GarminConnect/GarminConnectView.swift | 4 + .../GarminConnectViewModel.swift | 2 +- iosApp/iosApp/Views/Info/InfoView.swift | 12 +- iosApp/iosApp/Views/Info/InfoViewModel.swift | 2 +- .../Views/LeaveStudy/LeaveStudyView.swift | 9 +- iosApp/iosApp/Views/Login/LoginView.swift | 7 +- .../iosApp/Views/Login/LoginViewModel.swift | 2 +- .../iosApp/Views/Login/ScanQRCodeView.swift | 5 +- .../Filter/NotificationFilterView.swift | 10 +- .../Filter/NotificationFilterViewModel.swift | 1 - .../Views/Notification/NotificationView.swift | 7 + .../Notification/NotificationViewModel.swift | 2 +- .../Registration/RegistrationObservable.swift | 8 +- .../RunningSchedules/RunningSchedules.swift | 7 +- .../Schedule/List/ObservationButton.swift | 65 ++-- .../Views/Schedule/ScheduleViewModel.swift | 2 +- .../iosApp/Views/Settings/SettingsView.swift | 53 ++- .../Views/Settings/SettingsViewModel.swift | 76 +++- .../StudyDetails/StudyDetailsViewModel.swift | 2 +- .../Views/StudyStates/StudyClosedView.swift | 2 +- iosApp/iosApp/iOSApp.swift | 39 +- shared/build.gradle.kts | 14 +- .../io/redlink/more/util/PlatformUtils.kt | 18 + .../kotlin/io/redlink/more/Shared.kt | 22 +- .../io/redlink/more/database/AppDatabase.kt | 19 +- .../redlink/more/database/DatabaseManager.kt | 3 +- .../dao/AggregatedObservationDataDao.kt | 59 +++ .../redlink/more/database/dao/ScheduleDao.kt | 3 + .../AggregatedObservationDataEntity.kt | 123 +++++++ .../entities/ObservationDataEntity.kt | 10 +- .../more/database/migrations/Migration_2_3.kt | 22 ++ .../AggregatedObservationDataRepository.kt | 39 ++ ...AggregatedObservationDataRepositoryImpl.kt | 70 ++++ .../database/repository/MainRepository.kt | 12 + .../database/repository/MainRepositoryImpl.kt | 14 + .../database/repository/ScheduleRepository.kt | 2 + .../repository/ScheduleRepositoryImpl.kt | 13 +- .../more/{ => dialog}/AlertController.kt | 14 +- .../redlink/more/dialog/AlertDialogModel.kt | 66 ++++ .../redlink/more/logging/EventCollection.kt | 52 +++ .../io/redlink/more/logging/KMMLogger.kt | 44 +++ .../redlink/more/{ => logging}/NapierProxy.kt | 6 +- .../redlink/more/models/AlertDialogModel.kt | 32 -- .../io/redlink/more/models/ScheduleState.kt | 1 + .../more/navigation/model/NavigationRoute.kt | 61 ++-- .../redlink/more/observations/Observation.kt | 178 +++++++-- .../more/observations/ObservationBulkModel.kt | 2 +- .../more/observations/ObservationFactory.kt | 175 ++++++++- .../more/observations/ObservationManager.kt | 2 +- .../appUsage/AppUsageObservation.kt | 343 ++++++++++++++++++ .../observations/appUsage/model/EventModel.kt | 20 + .../model/InstantObservationPayload.kt | 21 ++ .../observations/appUsage/model/LogEvent.kt | 107 ++++++ .../appUsage/model/RangeObservationPayload.kt | 22 ++ .../DefaultLongRunningObservationStorage.kt | 128 +++++++ .../InMemoryLongRunningObservationStorage.kt | 89 +++++ .../LongRunningObservationStorage.kt | 65 ++++ .../AppUsageObservationType.kt | 15 + .../services/store/PermissionRepository.kt | 33 ++ .../store/PermissionRepositoryImpl.kt | 49 +++ .../kotlin/io/redlink/more/util/KMMLogger.kt | 22 -- .../io/redlink/more/util/PlatformUtils.kt | 14 + .../redlink/more/viewModels/CoreViewModel.kt | 19 +- .../io/redlink/more/viewModels/ViewManager.kt | 29 ++ .../BluetoothController.kt | 5 + .../dashboard/CoreDashboardFilterViewModel.kt | 5 + .../CoreGarminConnectViewModel.kt | 16 + .../limeSurvey/CoreLimeSurveyViewModel.kt | 10 + .../viewModels/login/CoreLoginViewModel.kt | 5 + .../CoreNotificationFilterViewModel.kt | 5 + .../CoreNotificationViewModel.kt | 5 + .../CoreObservationDetailsViewModel.kt | 4 + .../permission/CoreConsentViewModel.kt | 5 + .../schedules/CoreScheduleViewModel.kt | 10 + .../settings/CoreSettingsViewModel.kt | 104 +++++- .../simpleQuestion/QuestionCoreViewModel.kt | 16 + .../CoreBluetoothViewModel.kt | 2 + .../studydetails/CoreStudyDetailsViewModel.kt | 7 +- .../CoreTaskCompletionBarViewModel.kt | 3 + .../tasks/CoreTaskDetailsViewModel.kt | 5 + .../moko-resources/base/strings.xml | 17 +- .../commonMain/moko-resources/de/strings.xml | 20 + .../entities/ObservationDataEntityTest.kt | 39 ++ .../io/redlink/more/mocks/DatabaseMock.kt | 12 + .../io/redlink/more/mocks/RepositoryMocks.kt | 98 ++++- .../io/redlink/more/mocks/ServiceMocks.kt | 52 +++ ...MemoryLongRunningObservationStorageTest.kt | 220 +++++++++++ .../appUsage/AppUsageObservationTest.kt | 253 +++++++++++++ .../io/redlink/more/extensions/toString.kt | 2 +- .../io/redlink/more/util/PlatformUtils.kt | 28 ++ 167 files changed, 5184 insertions(+), 1496 deletions(-) rename androidApp/src/main/java/io/redlink/more/app/android/activities/{dashboard/Views.kt => OnAppearDisappear.kt} (54%) create mode 100644 androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationPermissionObserver.kt create mode 100644 iosApp/iosApp/InfoPlist.xcstrings create mode 100644 iosApp/iosApp/Observations/IOSObservationPermissionObserver.swift create mode 100644 shared/src/androidMain/kotlin/io/redlink/more/util/PlatformUtils.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/dao/AggregatedObservationDataDao.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/entities/AggregatedObservationDataEntity.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/migrations/Migration_2_3.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepository.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/database/repository/AggregatedObservationDataRepositoryImpl.kt rename shared/src/commonMain/kotlin/io/redlink/more/{ => dialog}/AlertController.kt (69%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertDialogModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/logging/EventCollection.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/logging/KMMLogger.kt rename shared/src/commonMain/kotlin/io/redlink/more/{ => logging}/NapierProxy.kt (86%) delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/AppUsageObservation.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/LogEvent.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/RangeObservationPayload.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/DefaultLongRunningObservationStorage.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/InMemoryLongRunningObservationStorage.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/longRunningObservation/LongRunningObservationStorage.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepository.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/services/store/PermissionRepositoryImpl.kt delete mode 100644 shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/database/entities/ObservationDataEntityTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/InMemoryLongRunningObservationStorageTest.kt create mode 100644 shared/src/commonTest/kotlin/io/redlink/more/observations/appUsage/AppUsageObservationTest.kt create mode 100644 shared/src/iosMain/kotlin/io/redlink/more/util/PlatformUtils.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index b29ccc59a..02f420b88 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -105,7 +105,7 @@ android:name="io.redlink.more.app.android.services.ObservationRecordingService" android:enabled="true" android:exported="false" - android:foregroundServiceType="dataSync" /> + android:foregroundServiceType="dataSync|location|connectedDevice" /> .onCreate() napierDebugBuild(FirebaseCrashlyticsAntilog()) napierDebugBuild() + + firebaseAnalytics = FirebaseAnalytics.getInstance(this) + appContext = this packagePath = this.packageName appName = this.getString(R.string.app_name) @@ -60,12 +64,14 @@ class MoreApplication : Application(), DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { super.onResume(owner) Napier.i { "App is in the foreground..." } + ViewManager.appIsInForeground(true) shared?.updateData(true) } override fun onPause(owner: LifecycleOwner) { super.onPause(owner) Napier.i { "App is in the background..." } + ViewManager.appIsInForeground(false) shared?.updateData(false) } @@ -100,16 +106,23 @@ class MoreApplication : Application(), DefaultLifecycleObserver { val database: AppDatabase = getRoomDatabase(getDatabaseBuilder(context)) val repositories = MainRepositoryImpl(database) val dataManager = AndroidObservationDataManager(context, repositories) - shared = Shared( + val sharedPreferences = SharedPreferencesRepository(context) + val tempShared = Shared( LocalPushNotificationService(context), repositories, - SharedPreferencesRepository(context), + sharedPreferences, dataManager, androidBluetoothConnector, - AndroidObservationFactory(context, dataManager, repositories), + AndroidObservationFactory( + context, + dataManager, + repositories, + sharedPreferences, + ), AndroidDataRecorder() ) - shared?.let { shared -> + 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 } 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 1cf36fb03..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 @@ -20,12 +20,12 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import io.github.aakira.napier.Napier -import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.main.MainActivity import io.redlink.more.app.android.extensions.showNewActivityAndClearStack import io.redlink.more.app.android.workers.ScheduleUpdateWorker -import io.redlink.more.models.AlertDialogModel +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 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/OnAppearDisappear.kt similarity index 54% rename from androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/Views.kt rename to androidApp/src/main/java/io/redlink/more/app/android/activities/OnAppearDisappear.kt index 485b30a5c..b9f10734a 100644 --- 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/OnAppearDisappear.kt @@ -8,12 +8,23 @@ * (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 +package io.redlink.more.app.android.activities -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)); +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/BluetoothViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothViewModel.kt index e4426f63b..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 @@ -14,12 +14,12 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.redlink.more.AlertController 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.database.entities.BluetoothDeviceEntity -import io.redlink.more.models.AlertDialogModel +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 @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BluetoothViewModel : ViewModel() { - private val coreBluetoothViewModel = CoreBluetoothViewModel( + val coreViewModel = CoreBluetoothViewModel( MoreApplication.shared!!.observationFactory, MoreApplication.shared!!.bluetoothController ) @@ -86,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()) @@ -113,21 +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: BluetoothDeviceEntity) { Scope.launch { - coreBluetoothViewModel.connectToDevice(device) + coreViewModel.connectToDevice(device) } } fun disconnectFromDevice(device: BluetoothDeviceEntity) { - coreBluetoothViewModel.disconnectFromDevice(device) + 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 bd2e133e2..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 @@ -21,6 +21,7 @@ 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.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 @@ -32,25 +33,30 @@ fun CompletedSchedulesView( navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { - 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( + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = viewModel, navController = navController, - 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 57c354a9e..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 @@ -30,6 +30,7 @@ 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 @@ -42,51 +43,55 @@ fun ConsentView(registrationService: RegistrationService) { val model = remember { ConsentViewModel(registrationService) } val permissions by model.coreModel.permissions.collectAsStateWithLifecycle() permissions?.let { permissionModel -> - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.9f) - ) { - 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)) - } + OnAppearDisappear( + { model.coreModel.viewDidAppear() }, + { model.coreModel.viewDidDisappear() }) { + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.9f) + ) { + 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) + 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 021f17fda..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 @@ -12,12 +12,14 @@ package io.redlink.more.app.android.activities.consent import android.content.Context import androidx.lifecycle.ViewModel -import io.redlink.more.AlertController +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.models.AlertDialogModel +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 @@ -33,13 +35,17 @@ class ConsentViewModel( } } - fun openPermissionDeniedAlertDialog(context: Context) { + 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 = stringResource(R.string.required_permissions_not_granted_title), - message = stringResource(R.string.required_permission_not_granted_message), - confirmLabel = stringResource(R.string.proceed_to_settings_button), - cancelLabel = stringResource(R.string.proceed_without_granting_button), + 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 }, @@ -52,10 +58,10 @@ class ConsentViewModel( 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), - confirmLabel = stringResource(R.string.proceed_to_settings_button), - cancelLabel = stringResource(R.string.proceed_without_granting_button), + 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 }, 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 1bff25f02..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 @@ -34,12 +34,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp 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.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) { @@ -63,10 +67,12 @@ 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) } @@ -82,6 +88,7 @@ fun ConsentButtons(model: ConsentViewModel) { ) { Button( onClick = { + Napier.event(LogEvent.BUTTON_PRESS, "Consent approved") checkAndRequestPermissions(context, launcher, model) }, colors = ButtonDefaults @@ -99,6 +106,7 @@ fun ConsentButtons(model: ConsentViewModel) { Button( onClick = { + Napier.event(LogEvent.BUTTON_PRESS, "Consent declined") model.decline() }, colors = ButtonDefaults @@ -148,6 +156,8 @@ fun checkAndRequestPermissions( ?: emptySet() ) + permissions.removeAll(AppUsageObservationType().sensorPermissions) + val hasBackgroundLocationPermission = permissions.contains(Manifest.permission.ACCESS_BACKGROUND_LOCATION) if (hasBackgroundLocationPermission) { 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 42776cebf..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 @@ -21,6 +21,7 @@ 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.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 @@ -32,25 +33,29 @@ fun DashboardView( scheduleViewModel: ScheduleViewModel, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { - 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 + 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/filter/DashboardFilterView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/filter/DashboardFilterView.kt index a3b73a293..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 @@ -29,6 +29,7 @@ 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 @@ -43,111 +44,115 @@ import io.redlink.more.viewModels.schedules.CoreScheduleViewModel @Composable fun DashboardFilterView(coreScheduleViewModel: CoreScheduleViewModel) { val viewModel = remember { DashboardFilterViewModel(coreScheduleViewModel.coreFilterModel) } - LazyColumn { - item { - HeaderTitle( - title = stringResource(R.string.more_filter_set_duration), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } + 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( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - 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( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - 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( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.toggleTypeFilter(item.key) }) - .padding(4.dp) + items(viewModel.currentTypeFilter.entries.sortedBy { it.key }) { item -> + Row( + verticalAlignment = Alignment.CenterVertically, ) { - HeaderDescription( - description = getStringResourceByName(item.key.observationTypeToResource()), - 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 3cbc0f6df..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 @@ -24,7 +24,7 @@ import io.redlink.more.viewModels.dashboard.CoreDashboardFilterViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class DashboardFilterViewModel(private val coreViewModel: CoreDashboardFilterViewModel) : +class DashboardFilterViewModel(val coreViewModel: CoreDashboardFilterViewModel) : ViewModel() { val currentTypeFilter = mutableStateMapOf() val currentDateFilter = mutableStateMapOf() 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 9d10eaa02..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 @@ -41,6 +41,7 @@ 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 @@ -54,185 +55,189 @@ fun InfoView(navController: NavController) { val viewModel = remember { InfoViewModel() } val studyInfo by viewModel.coreViewModel.studyModel.collectAsStateWithLifecycle() val context = LocalContext.current - 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 { - studyInfo?.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 588f8cd88..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 @@ -12,8 +12,10 @@ package io.redlink.more.app.android.activities.info import androidx.lifecycle.ViewModel import io.redlink.more.app.android.MoreApplication +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.viewModels.studydetails.CoreStudyDetailsViewModel class InfoViewModel : ViewModel() { - val coreViewModel = CoreStudyDetailsViewModel(MoreApplication.shared!!) + 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 ec22db7f6..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 @@ -47,94 +46,87 @@ import io.redlink.more.app.android.theme.moreImportant 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(80.dp)) - Spacer(Modifier.height(18.dp)) + 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) + ) + } - BasicText( - text = stringResource(id = R.string.more_settings_withdraw_statement_long), - color = MoreColors.TextDefault, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontSize = 16.sp - ) + Spacer(Modifier.height(18.dp)) + BasicText( + text = stringResource(id = R.string.more_settings_withdraw_statement_long), + color = MoreColors.TextDefault, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 16.sp + ) - Spacer(Modifier.height(10.dp)) - 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(10.dp)) - Spacer(Modifier.height(32.dp)) + SmallTitle( + text = stringResource(id = R.string.more_settings_withdraw_question_confirm), + color = MoreColors.Primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 16.sp + ) - SmallTextButton( - text = stringResource(id = R.string.more_settings_continue), - buttonColors = ButtonDefaults.moreApproved(), - borderStroke = MoreColors.borderApproved() - ) { - navController.navigate(NavigationScreen.INFO.routeWithParameters()) - } + 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 e3af730e1..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,6 +30,7 @@ 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 @@ -46,85 +45,78 @@ import io.redlink.more.app.android.theme.moreImportant 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 ac456877c..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 @@ -21,27 +21,33 @@ import io.redlink.more.app.android.activities.ContentActivity import io.redlink.more.app.android.extensions.showNewActivityAndClearStack 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 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 } @@ -49,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) { @@ -71,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/LoginViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/login/LoginViewModel.kt index 9cf31f75f..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,10 +12,12 @@ package io.redlink.more.app.android.activities.login import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import io.redlink.more.AlertController +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.models.AlertDialogModel +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 @@ -45,17 +47,17 @@ class LoginViewModel( } else { AlertController.openAlertDialog( AlertDialogModel( - stringResource(R.string.more_token_error), - stringResource(R.string.more_404), - confirmLabel = "Ok" + StringDesc.Raw(stringResource(R.string.more_token_error)), + StringDesc.Raw(stringResource(R.string.more_404)), + confirmLabel = StringDesc.Raw("Ok") ) ) } } else { val dialogModel = AlertDialogModel( - title = stringResource(R.string.no_internet_connection_title), - message = stringResource(R.string.no_internet_connection_body), - confirmLabel = "Ok" + 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) } 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 0e1a9c6e6..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 @@ -282,8 +282,7 @@ fun MainView( } ObservationDetailsView( - viewModel = obsDetailsVM, - navController = navController + viewModel = obsDetailsVM ) } } 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 10f11f61a..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 @@ -33,6 +33,7 @@ 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.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 @@ -46,48 +47,53 @@ fun NotificationView( ) { val viewModel = remember { NotificationViewModel(coreFilterViewModel) } val notificationList by viewModel.coreViewModel.notificationList.collectAsStateWithLifecycle() - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - ) { + 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 (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(notificationList.sortedByDescending { it.timestamp }) { notification -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - 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) - ) { - Column { - NotificationItem(viewModel, notification, navController) - 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 be7b356de..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 @@ -33,7 +33,14 @@ class NotificationViewModel(private val coreFilterViewModel: CoreNotificationFil coreViewModel.handleNotificationAction(notification) { actionType, data -> data?.let { when (actionType) { - NotificationActionHandler.DEEPLINK -> navController.navigate(data.route.toUri()) + 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 -> {} } } 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 92a7762ea..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 @@ -28,6 +28,7 @@ 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 @@ -41,42 +42,46 @@ import io.redlink.more.viewModels.notifications.CoreNotificationFilterViewModel @Composable fun NotificationFilterView(coreViewModel: CoreNotificationFilterViewModel) { val viewModel = remember { NotificationFilterViewModel(coreViewModel) } - LazyColumn { - item { - HeaderTitle( - title = stringResource(R.string.more_select_filter), - modifier = Modifier.padding(top = 20.dp) - ) - MoreDivider(modifier = Modifier.padding(vertical = 10.dp)) - } + 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( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - 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.toString().formatNotificationFilterString(), - 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 b44cc12a5..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 @@ -19,7 +19,7 @@ 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() 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 index aae8131f1..eb6692583 100644 --- 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 @@ -48,7 +48,7 @@ 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.models.AlertDialogModel +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.viewModels.ViewManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest @@ -178,7 +178,7 @@ fun GarminConnectSSOView(viewModel: GarminConnectViewModel, webView: WebView?) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (webView == null || viewModel.coreViewModel.garminSSOUrl() == null) { MessageAlertDialog( - AlertDialogModel( + AlertDialogModel.fromStrings( title = "Garmin Connect", message = getStringResource(id = R.string.garmin_connect_unavailable), confirmLabel = "Ok", 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 index 882391ef0..28fe691d1 100644 --- 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 @@ -14,10 +14,10 @@ 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.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.web.WebClientListener -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel import io.redlink.more.viewModels.garminConnectOAuth.CoreGarminConnectViewModel import kotlinx.coroutines.runBlocking @@ -57,7 +57,7 @@ class GarminConnectViewModel : WebClientListener { return } else { AlertController.openAlertDialog( - AlertDialogModel( + AlertDialogModel.fromStrings( title = "Garmin Connect", message = "Failed to authenticate with Garmin Connect. Please try again later.", confirmLabel = "Ok", 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 f8c3576db..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 @@ -15,10 +15,10 @@ import android.webkit.WebView import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.redlink.more.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.web.WebClientListener -import io.redlink.more.models.AlertDialogModel +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 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 2f65f970a..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 @@ -15,8 +15,6 @@ 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 @@ -30,6 +28,7 @@ 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 @@ -71,43 +70,42 @@ fun QuestionnaireView(navController: NavController, viewModel: QuestionViewModel NavigationScreen.QUESTION.routeWithParameters() ) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - - DisposableEffect(route) { - onDispose { viewModel.viewDidDisappear() } - } + OnAppearDisappear( + { viewModel.viewDidAppear() }, + { viewModel.viewDidDisappear() }) { + var selectedAnswer by rememberSaveable(route, stateSaver = AnswerValueSaver) { + mutableStateOf(null) + } - var selectedAnswer by rememberSaveable(route, stateSaver = AnswerValueSaver) { - mutableStateOf(null) - } + if (viewModel.hasData.value) { + val observation by viewModel.coreViewModel.questionModel.collectAsStateWithLifecycle( + null + ) + val type = observation?.type ?: QuestionType.NON - 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 } + ) + } - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - QuestionnaireQuestionAnswer( - model = viewModel, + QuestionnaireButtons( + questionType = type, selectedAnswer = selectedAnswer, - onAnswerSelected = { selectedAnswer = it } + onFinish = { + viewModel.finish(type, it) + navController.navigate(NavigationScreen.QUESTIONNAIRE_RESPONSE.routeWithParameters()) + } ) + } else { + ErrorMessage(message = "${getStringResource(id = R.string.data_not_found)}!") } - - 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)}!") } + } \ 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 319b9108f..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 @@ -21,6 +21,7 @@ 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.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 @@ -32,25 +33,29 @@ fun RunningSchedulesView( navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel ) { - 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( + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ScheduleListHeader( + viewModel = viewModel, navController = navController, - 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 c35de0d05..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,48 +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.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 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.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.shared_composables.SmallTextButton import io.redlink.more.app.android.theme.MoreColors @Composable fun SettingsView() { + val context = LocalContext.current val model = remember { SettingsViewModel() } - Column( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth() - ) { - LazyColumn( + 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, + ) + + Spacer(Modifier.height(24.dp)) + } - items(model.permissionModel.value?.consentInfo ?: emptyList()) { consentInfo -> - Accordion( - 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 7a8ab4bcc..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 @@ -22,21 +22,25 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class SettingsViewModel : ViewModel() { - private var coreSettingsViewModel = - CoreSettingsViewModel(MoreApplication.shared!!) + 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 } 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 03d70fd65..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 @@ -27,6 +27,7 @@ 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.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 @@ -45,51 +46,54 @@ fun StudyDetailsView( ) { 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 + ) + } - 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 - ) } + 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/observationDetails/ObservationDetailsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsView.kt index 4daf99fc2..358bae3d9 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsView.kt @@ -18,15 +18,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.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.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.extensions.jvmLocalDate import io.redlink.more.app.android.extensions.jvmLocalDateTime @@ -39,74 +35,66 @@ import io.redlink.more.app.android.theme.MoreColors @Composable fun ObservationDetailsView( - viewModel: ObservationDetailsViewModel, - navController: NavController + viewModel: ObservationDetailsViewModel ) { - val backStackEntry = remember { navController.currentBackStackEntry } - val route = - backStackEntry?.arguments?.getString(NavigationScreen.OBSERVATION_DETAILS.routeWithParameters()) - LaunchedEffect(route) { - viewModel.viewDidAppear() - } - DisposableEffect(route) { - onDispose { - viewModel.viewDidDisappear() - } - } - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - HeaderTitle( - title = viewModel.observationDetailsModel.value.observationTitle, + OnAppearDisappear( + { viewModel.coreViewModel.viewDidAppear() }, + { viewModel.coreViewModel.viewDidDisappear() }) { + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(0.65f) - .padding(vertical = 11.dp) + .fillMaxWidth() + ) { + HeaderTitle( + title = viewModel.observationDetailsModel.value.observationTitle, + modifier = Modifier + .weight(0.65f) + .padding(vertical = 11.dp) + ) + } + BasicText( + text = viewModel.observationDetailsModel.value.observationType, + color = MoreColors.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp) ) - } - BasicText( - text = viewModel.observationDetailsModel.value.observationType, - color = MoreColors.Secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 14.dp) - ) - TimeframeDays( - viewModel.observationDetailsModel.value.start.jvmLocalDate(), - viewModel.observationDetailsModel.value.end.jvmLocalDate(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) - TimeframeHours( - viewModel.observationDetailsModel.value.start.jvmLocalDateTime(), - viewModel.observationDetailsModel.value.end.jvmLocalDateTime(), - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) + TimeframeDays( + viewModel.observationDetailsModel.value.start.jvmLocalDate(), + viewModel.observationDetailsModel.value.end.jvmLocalDate(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) + TimeframeHours( + viewModel.observationDetailsModel.value.start.jvmLocalDateTime(), + viewModel.observationDetailsModel.value.end.jvmLocalDateTime(), + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Accordion( - title = getStringResource(id = R.string.participant_information), - description = viewModel.observationDetailsModel.value.participantInformation, - hasCheck = false, - hasPreview = false, - isOpen = true - ) - Spacer(modifier = Modifier.height(8.dp)) + Accordion( + title = getStringResource(id = R.string.participant_information), + description = viewModel.observationDetailsModel.value.participantInformation, + hasCheck = false, + hasPreview = false, + isOpen = true + ) + Spacer(modifier = Modifier.height(8.dp)) + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt index 68a9606f6..56259a4c8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/studyDetails/observationDetails/ObservationDetailsViewModel.kt @@ -24,7 +24,7 @@ class ObservationDetailsViewModel( observationId: String, ) : ViewModel() { - private val coreViewModel: CoreObservationDetailsViewModel = + val coreViewModel: CoreObservationDetailsViewModel = CoreObservationDetailsViewModel( MoreApplication.shared!!.repositories, observationId @@ -45,18 +45,5 @@ class ObservationDetailsViewModel( } } } - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - coreViewModel.viewDidAppear() - } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() - } - - fun viewDidDisappear() { - coreViewModel.viewDidDisappear() } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt index 58b2aaf8d..2bded98ea 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/subcomponents/ExitButton.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.subcomponents import android.app.Activity @@ -8,7 +19,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.work.WorkManager -import io.redlink.more.AlertController +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.activities.ContentActivity @@ -17,11 +29,11 @@ import io.redlink.more.app.android.extensions.showNewActivityAndClearStack import io.redlink.more.app.android.shared_composables.SmallTextButton import io.redlink.more.app.android.theme.MoreColors import io.redlink.more.app.android.theme.moreImportant -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertController +import io.redlink.more.dialog.AlertDialogModel @Composable -fun ExitButton( -) { +fun ExitButton() { val context = LocalContext.current SmallTextButton( text = getStringResource(id = R.string.more_settings_resign_confirm), @@ -32,10 +44,10 @@ fun ExitButton( ) { AlertController.openAlertDialog( AlertDialogModel( - title = context.getString(R.string.more_settings_withdraw_question_confirm), - message = context.getString(R.string.more_settings_withdraw_statement_long), - confirmLabel = context.getString(R.string.more_settings_resign_confirm), - cancelLabel = context.getString(R.string.more_settings_continue), + title = StringDesc.Raw(context.getString(R.string.more_settings_withdraw_question_confirm)), + message = StringDesc.Raw(context.getString(R.string.more_settings_withdraw_statement_long)), + confirmLabel = StringDesc.Raw(context.getString(R.string.more_settings_resign_confirm)), + cancelLabel = StringDesc.Raw(context.getString(R.string.more_settings_continue)), onConfirm = { WorkManager.getInstance(context).cancelAllWork() MoreApplication.shared!!.exitStudy { 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 index 332fe2a73..fe5248f90 100644 --- 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 @@ -1,13 +1,27 @@ +/* + * 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 @@ -20,37 +34,75 @@ fun ObservationActionButton( 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 + ) + } +} - if (QuestionType().matches(observationType)) { - SmallTextButton( - text = getStringResource(id = R.string.more_questionnaire_start), - enabled = scheduleState.active() - ) { - navController.navigate( - NavigationScreen.QUESTION.navigationRoute(NavigationRouteParameter.SCHEDULE_ID.key to scheduleId) - ) - } - } else if (LimeSurveyType().matches(observationType)) { - SmallTextButton( - text = getStringResource(id = R.string.more_limesurvey_start), - enabled = scheduleState.active() - ) { - navController.navigate( - NavigationScreen.LIMESURVEY.navigationRoute( - NavigationRouteParameter.SCHEDULE_ID.key to scheduleId - ) +@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 { - SmallTextButton( - text = if (scheduleState == ScheduleState.RUNNING) getStringResource( - id = R.string.more_observation_pause - ) else getStringResource( - id = R.string.more_observation_start - ), - enabled = scheduleState.active() && additionalEnableCondition - ) { - onClick() - } + 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 bdac33b93..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 @@ -31,6 +31,7 @@ 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.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 @@ -56,7 +57,6 @@ fun TaskDetailsView( remember { TaskDetailsViewModel( MoreApplication.shared!!.dataRecorder, - MoreApplication.shared!!.observationFactory, scheduleId ) } @@ -64,123 +64,128 @@ fun TaskDetailsView( val dataPoints by viewModel.coreViewModel.dataCount.collectAsStateWithLifecycle() val taskErrors by viewModel.coreViewModel.taskObservationErrors.collectAsStateWithLifecycle() val taskErrorActions by viewModel.coreViewModel.taskObservationErrorActions.collectAsStateWithLifecycle() - Column( - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - taskDetails?.let { taskDetails -> - LazyColumn( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - HeaderTitle( - title = taskDetails.observationTitle, + + OnAppearDisappear( + { viewModel.coreViewModel.viewOpened() }, + { viewModel.coreViewModel.viewClosed() }) { + Column( + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + taskDetails?.let { taskDetails -> + LazyColumn( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + 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 = taskDetails.observationType, + color = MoreColors.Secondary, modifier = Modifier - .weight(0.65f) - .padding(vertical = 11.dp) + .fillMaxWidth() + .padding(bottom = 14.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 = taskDetails.observationType, - color = MoreColors.Secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 14.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) - ) + 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) + ) - 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)) + 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 (!taskDetails.state.completed()) { - DatapointCollectionView( - dataPoints, - taskDetails.state - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + scheduleId?.let { + if (!taskDetails.state.completed()) { + DatapointCollectionView( + dataPoints, + taskDetails.state + ) + } } } - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally + ) { - ObservationErrorListView( - errors = taskErrors, - errorActions = taskErrorActions - ) + 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() + 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 0d555765a..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 @@ -15,7 +15,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication import io.redlink.more.observations.DataRecorder -import io.redlink.more.observations.ObservationFactory import io.redlink.more.services.bluetooth.polar.PolarStates import io.redlink.more.viewModels.tasks.CoreTaskDetailsViewModel import kotlinx.coroutines.Dispatchers @@ -24,7 +23,6 @@ import kotlinx.coroutines.withContext class TaskDetailsViewModel( dataRecorder: DataRecorder, - observationFactory: ObservationFactory, scheduleId: String ) : ViewModel() { val coreViewModel: CoreTaskDetailsViewModel = diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt index 06667fd41..56c52257a 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/web/WebClient.kt @@ -15,7 +15,10 @@ 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 class WebClient : WebViewClient() { private var clientListener: WebClientListener? = null @@ -42,7 +45,7 @@ class WebClient : 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 { 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 805696e29..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 @@ -43,7 +43,11 @@ fun getStringResourceByName(name: String): String { "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/observations/AndroidObservationDataManager.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationDataManager.kt index f3d2fe574..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 @@ -49,7 +49,11 @@ class AndroidObservationDataManager(context: Context, repository: MainRepository if (!immediately && workManager != null) { onCompletion(tryWorkManagerThenFallback()) } else { - Napier.w { "WorkManager not available, falling back to direct upload..." } + 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()) } } 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 9af5138d1..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 @@ -19,28 +19,43 @@ import io.redlink.more.app.android.observations.accelerometer.AccelerometerObser import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.app.android.services.sensorsListener.GPSStateListener 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, - repository: MainRepository + repository: MainRepository, + sharedStorageRepository: SharedStorageRepository, + scope: MoreScope = Scope ) : - ObservationFactory(repository, observationDataManager) { + ObservationFactory( + repository, + sharedStorageRepository, + observationDataManager + ) { init { - observations.addAll( - setOf( - AccelerometerObservation(context, repository), - GPSObservation(context, repository, gpsService = GPSService(context)), - PolarHeartRateObservation(repository) - ) - ) + 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() @@ -48,7 +63,7 @@ class AndroidObservationFactory( } } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { BluetoothStateListener.bluetoothEnabled.collect { withContext(Dispatchers.Main) { super.updateObservationErrors() @@ -56,7 +71,7 @@ class AndroidObservationFactory( } } - Scope.launch(Dispatchers.IO) { + scope.launch(AppDispatchers.io) { super.studyObservationTypes.collect { studyObservationTypes -> val permissions = super.observations.filter { it.observationType.observationType in studyObservationTypes } @@ -82,4 +97,11 @@ class AndroidObservationFactory( } } + 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 c795619d9..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,37 +12,22 @@ 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.os.Build 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.database.repository.MainRepository import io.redlink.more.observations.Observation import io.redlink.more.observations.observationTypes.GPSType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import io.redlink.more.scopes.Scope +import io.redlink.more.services.store.PermissionApprovalState private const val TAG = "GPSObservation" -private val permissions = if (Build.VERSION.SDK_INT >= 34) { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.FOREGROUND_SERVICE_LOCATION - ) -} else { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION - ) -} +private val permissions = setOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION +) class GPSObservation( context: Context, @@ -51,24 +36,15 @@ class GPSObservation( ) : 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() } @@ -81,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 } @@ -91,7 +66,7 @@ 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: Exception) { Log.e(TAG, e.stackTraceToString()) @@ -112,25 +87,11 @@ 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 { 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 173feba18..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 @@ -30,7 +30,7 @@ class GPSService(context: Context) { LocationServices.getFusedLocationProviderClient(context) private val locationRequest = - LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, 1000) + LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) private val locationCallback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { super.onLocationResult(result) @@ -47,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) { 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 22c7b77cf..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,7 +21,6 @@ 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.database.repository.MainRepository import io.redlink.more.extensions.anyNameIn @@ -149,7 +148,6 @@ class PolarHeartRateObservation(repos: MainRepository) : val errors = mutableSetOf() if (!hasPermissions(MoreApplication.appContext!!)) { errors.add("error_access_bluetooth") - showPermissionAlertDialog() PolarStates.hrFeatureReady(false) } if (!BluetoothStateListener.bluetoothEnabled.value) { 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 d1d39ee18..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 @@ -11,21 +11,27 @@ package io.redlink.more.app.android.observations -import io.redlink.more.AlertController +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.models.AlertDialogModel +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() { +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 = stringResource(R.string.required_permissions_not_granted_title), - message = stringResource(R.string.required_permission_not_granted_message), - confirmLabel = stringResource(R.string.proceed_to_settings_button), - cancelLabel = stringResource(R.string.proceed_without_granting_button), + 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 } 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 index 361b0e324..137a96354 100644 --- 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 @@ -50,7 +50,12 @@ suspend fun ObservationManager.startWithPermissionCheck( Napier.d("Observation started with result: $result") } } else { - observation.showPermissionAlertDialog() + observation.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observation, + activity + ) + ) } } 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 index d93789cb2..5671f4518 100644 --- 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 @@ -51,7 +51,12 @@ fun startObservationsWithPermissionCheck( if (granted) { startObservationsWithPermissionCheck(scheduleIds, activity) } else { - observationNeedingPermissions.showPermissionAlertDialog() + observationNeedingPermissions.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observationNeedingPermissions, + activity + ) + ) } } } else { 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 index c81bbbe55..7e39963a3 100644 --- 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 @@ -20,6 +20,8 @@ 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 @@ -74,8 +76,36 @@ object PermissionUtils { * @return True if all permissions are granted, false otherwise */ fun hasAllPermissions(observation: Observation, context: Context): Boolean { - val permissions = observation.observationType.sensorPermissions - return hasAllPermissions(permissions, context) + 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('.') + } } /** @@ -141,7 +171,7 @@ object PermissionUtils { if (permissionLauncher != null) { permissionLauncher.launch(permissionsToRequest) } else { - observation.showPermissionAlertDialog() + observation.showPermissionAlertDialog(getMissingPermissionNames(observation, activity)) callback?.invoke(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 4fede2b4a..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 @@ -10,13 +10,11 @@ */ package io.redlink.more.app.android.observations.accelerometer -import android.Manifest import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager -import android.os.Build import android.util.Log import io.redlink.more.database.repository.MainRepository import io.redlink.more.observations.Observation @@ -31,18 +29,7 @@ class AccelerometerObservation( ) : Observation( repos, observationType = AccelerometerType( - if (Build.VERSION.SDK_INT >= 34) { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.FOREGROUND_SERVICE_LOCATION - ) - } else { - setOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION - ) - } + emptySet() ) ), SensorEventListener { private val sensorManager = context.getSystemService(SensorManager::class.java) 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 a0c6e5b0e..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,6 +10,7 @@ */ package io.redlink.more.app.android.services +import android.Manifest import android.app.Activity import android.app.AlarmManager import android.app.Notification @@ -18,10 +19,14 @@ 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.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.R @@ -31,12 +36,15 @@ 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.SupervisorJob +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ObservationRecordingService : Service() { private var observationManager: ObservationManager? = null @@ -53,18 +61,27 @@ class ObservationRecordingService : Service() { // Critical: Start foreground service immediately to prevent ANR crashes // This must happen within 5 seconds when startForegroundService() is called try { - if (!running) { - startForegroundService() - } + startForegroundService() } catch (e: Exception) { Napier.e("Failed to start foreground service: ${e.message}") try { - val basicNotification = Notification.Builder(this, "default") - .setContentTitle("${MoreApplication.appName} Observation Service") + 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() - startForeground(1001, basicNotification) + 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}") @@ -416,6 +433,47 @@ class ObservationRecordingService : Service() { } } + 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..." } try { @@ -432,11 +490,12 @@ class ObservationRecordingService : Service() { } val notification = buildNotification( - channelId = MoreApplication.DEFAULT_CHANNEL_ID!!, + channelId = MoreApplication.DEFAULT_CHANNEL_ID ?: (packageName + ".observation_service"), notificationTitle = notificationTitle, notificationText = notificationText ) - startForeground(1001, notification) + val type = calculateForegroundServiceType() + startForeground(1001, notification, type) running = true Napier.d { "Foreground service started successfully" } } catch (e: Exception) { @@ -484,8 +543,18 @@ class ObservationRecordingService : Service() { .build() } catch (e: Exception) { Napier.e("Failed to build notification: ${e.message}") - return Notification.Builder(applicationContext, "default") - .setContentTitle("${MoreApplication.appName} Service") + 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) @@ -537,44 +606,59 @@ class ObservationRecordingService : Service() { scheduleIds: Set, activity: Activity ) { - val observations = - MoreApplication.shared?.observationFactory?.observations - ?: emptySet() + 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 + } - if (observations.isEmpty()) { - startWithoutPermissionCheck(scheduleIds) - return - } + withContext(AppDispatchers.main) { + val allPermissionsGranted = observations.all { observation -> + PermissionUtils.hasAllPermissions(observation, activity) + } - val allPermissionsGranted = observations.all { observation -> - PermissionUtils.hasAllPermissions(observation, activity) - } + if (allPermissionsGranted) { + startWithoutPermissionCheck(scheduleIds) + } else { + val observationNeedingPermissions = + observations.firstOrNull { 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) - if (observationNeedingPermissions != null) { - pendingScheduleIds.addAll(scheduleIds) - PermissionUtils.requestPermissions( - observationNeedingPermissions, - activity - ) { granted -> - if (granted) { - checkPermissionsAndStart(scheduleIds, activity) + PermissionUtils.requestPermissions( + observationNeedingPermissions, + activity + ) { granted -> + if (granted) { + checkPermissionsAndStart(scheduleIds, activity) + } else { + observationNeedingPermissions.showPermissionAlertDialog( + PermissionUtils.getMissingPermissionNames( + observationNeedingPermissions, + activity + ) + ) + pendingScheduleIds.removeAll(scheduleIds) + } + } } else { - observationNeedingPermissions.showPermissionAlertDialog() - pendingScheduleIds.removeAll(scheduleIds) + startWithoutPermissionCheck(scheduleIds) } } - } else { - startWithoutPermissionCheck(scheduleIds) } + } + } /** 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 6eed7d5bf..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,17 +18,19 @@ 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.theme.MoreColors -import io.redlink.more.models.AlertDialogModel +import io.redlink.more.dialog.AlertDialogModel @Composable fun MessageAlertDialog(model: AlertDialogModel) { + val context = LocalContext.current MessageAlertDialog( - title = model.title, - message = model.message, - positiveButtonTitle = model.confirmLabel, - negativeButtonTitle = model.cancelLabel, + 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 ) 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 684391361..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 @@ -39,11 +39,12 @@ 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.AlertController import io.redlink.more.app.android.MoreApplication import io.redlink.more.app.android.activities.main.MainTabView 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( @@ -61,12 +62,15 @@ fun MoreBackground( ) { val context = LocalContext.current val alertDialogModel by AlertController.alertDialogModel.collectAsStateWithLifecycle(null) - if (MoreApplication.openSettings.value) { + 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( 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 6a69ddd79..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,6 +6,7 @@ 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 diff --git a/androidApp/src/main/res/values-de/error-strings.xml b/androidApp/src/main/res/values-de/error-strings.xml index cb280befb..7328032ef 100644 --- a/androidApp/src/main/res/values-de/error-strings.xml +++ b/androidApp/src/main/res/values-de/error-strings.xml @@ -14,4 +14,5 @@ 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/observation-type-strings.xml b/androidApp/src/main/res/values-de/observation-type-strings.xml index ec81ad88a..73abff9b1 100644 --- a/androidApp/src/main/res/values-de/observation-type-strings.xml +++ b/androidApp/src/main/res/values-de/observation-type-strings.xml @@ -7,4 +7,6 @@ Accelerometer App Nutzung Polar Verity + Garmin + LimeSurvey \ 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 ea09ebbcf..2bbb54fae 100644 --- a/androidApp/src/main/res/values/alert-dialog-strings.xml +++ b/androidApp/src/main/res/values/alert-dialog-strings.xml @@ -6,6 +6,7 @@ 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 diff --git a/androidApp/src/main/res/values/error-strings.xml b/androidApp/src/main/res/values/error-strings.xml index 5b258725a..bf8a4e0e8 100644 --- a/androidApp/src/main/res/values/error-strings.xml +++ b/androidApp/src/main/res/values/error-strings.xml @@ -14,4 +14,5 @@ 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/observation-type-strings.xml b/androidApp/src/main/res/values/observation-type-strings.xml index 02d43f38a..855579091 100644 --- a/androidApp/src/main/res/values/observation-type-strings.xml +++ b/androidApp/src/main/res/values/observation-type-strings.xml @@ -7,4 +7,6 @@ Accelerometer App Usage Polar Verity + Garmin + LimeSurvey \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index d5fa0bda3..4d6892454 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,13 @@ +# +# 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/). +# #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" #Kotlin @@ -7,4 +17,5 @@ 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/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c5084e0bc..b2898f648 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -112,10 +112,12 @@ 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 */; }; @@ -328,10 +330,12 @@ 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 = ""; }; @@ -506,6 +510,7 @@ 1F34796F29B8AFCB0030CA15 /* Observations */ = { isa = PBXGroup; children = ( + 1FA044452F61AE5400DA3E2E /* IOSObservationPermissionObserver.swift */, 1F34797029B8AFE10030CA15 /* IOSObservationFactory.swift */, 1F34797429B8BECB0030CA15 /* AccelerometerObservation.swift */, 1F80EC4629C83F74004667B1 /* iOSObservationDataManager.swift */, @@ -551,6 +556,13 @@ path = GarminConnect; sourceTree = ""; }; + 1F60BFC92FA8A423007CD41A /* PC_Components */ = { + isa = PBXGroup; + children = ( + ); + path = PC_Components; + sourceTree = ""; + }; 1F6A4E3A29F6C94B00F0247F /* Bluetooth */ = { isa = PBXGroup; children = ( @@ -704,6 +716,7 @@ 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( + 1F60BFC92FA8A423007CD41A /* PC_Components */, 1F43DE1F2EC6136700B6F07B /* GarminConnect */, 1F1E45CF2E7842E300C82016 /* Registration */, 1F8937892BFF1EB20083D20E /* ObservationErrors */, @@ -834,6 +847,7 @@ 1F0026DA29CC8E730034EF65 /* AppDelegate.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, 1F6C31522A13EB7F00EED533 /* Launch Screen.storyboard */, + 1FBCF72A2F699971002ABE61 /* InfoPlist.xcstrings */, ); path = iosApp; sourceTree = ""; @@ -1088,6 +1102,7 @@ 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 */, 1FF6D8AD2BC516270050AF10 /* PrivacyInfo.xcprivacy in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, @@ -1260,6 +1275,7 @@ 1FDC264A29C1CFE80011D8A4 /* NavigationText.swift in Sources */, 1F9C3E90298AACC100B9AC82 /* MoreMainBackgroundView.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 */, diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index eaf127095..426663523 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -32,14 +32,15 @@ class AppDelegate: NSObject, UIApplicationDelegate { static let dataUploadManager = DataUploadManager() static let shared: Shared = { let dataManager = iOSObservationDataManager(repository: repositories, scope: Scope.shared, studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared) + let userDefaults = UserDefaultsRepository() return Shared( localNotificationListener: LocalPushNotifications(), repositories: repositories, - sharedStorageRepository: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: polarConnector, - observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repositories, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: 30, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() @@ -53,6 +54,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { NapierProxyKt.napierDebugBuild(antilog: nil) #endif + EventCollection.shared.addObserver(observer: EventConsumer()) + FirebaseApp.configure() FirebaseConfiguration.shared.setLoggerLevel(.debug) fcmService.register() @@ -120,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/Info.plist b/iosApp/iosApp/Info.plist index ca1439d27..5b0910403 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -46,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 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/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index d15db0bae..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(repos: MainRepository, sensorPermissions: Set) { super.init(repos: repos, observationType: GPSType(sensorPermissions: sensorPermissions)) - manager.delegate = self - manager.desiredAccuracy = kCLLocationAccuracyBest + 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/IOSObservationFactory.swift b/iosApp/iosApp/Observations/IOSObservationFactory.swift index a41c822b5..948ae53c5 100644 --- a/iosApp/iosApp/Observations/IOSObservationFactory.swift +++ b/iosApp/iosApp/Observations/IOSObservationFactory.swift @@ -17,10 +17,22 @@ import Foundation import shared class IOSObservationFactory: ObservationFactory { - override init(repository: MainRepository, dataManager: ObservationDataManager) { - super.init(repository: repository, dataManager: dataManager) - observations.add(GPSObservation(repos: repository, sensorPermissions: ["gpsAlways"])) - observations.add(AccelerometerBackgroundObservation(repos: repository, sensorPermissions: ["cmsensorrecorder"])) - observations.add(PolarVerityHeartRateObservation(repos: repository, 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/Resources/Strings/Localizable.xcstrings b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings index 36213e526..cb4e97cbe 100644 --- a/iosApp/iosApp/Resources/Strings/Localizable.xcstrings +++ b/iosApp/iosApp/Resources/Strings/Localizable.xcstrings @@ -293,10 +293,6 @@ } } }, - "Button to start a limesurvey" : { - "comment" : "A button label that can be translated.", - "isCommentAutoGenerated" : true - }, "camera_to_scan_qr_code" : { "extractionState" : "manual", "localizations" : { diff --git a/iosApp/iosApp/Services/PermissionManager.swift b/iosApp/iosApp/Services/PermissionManager.swift index e5f99c669..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,16 +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.", - 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) - } - })) + 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() } @@ -106,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() { @@ -118,7 +128,6 @@ class PermissionManager: NSObject, ObservableObject { override init() { super.init() - setPermissionValues(observationPermissions: AppDelegate.shared.observationFactory.studySensorPermissions()) } private func requestGpsAuthorization(always: Bool = true) { @@ -127,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 @@ -214,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 { @@ -230,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() { @@ -246,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() @@ -259,6 +298,7 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = bluetoothStatus.resetStatus() cmSensorStatus = cmSensorStatus.resetStatus() cameraStatus = cameraStatus.resetStatus() + appTrackingStatus = appTrackingStatus.resetStatus() permissionsRequested = false } @@ -268,6 +308,7 @@ class PermissionManager: NSObject, ObservableObject { bluetoothStatus = .non cmSensorStatus = .non cameraStatus = .non + appTrackingStatus = .non permissionsRequested = false } } @@ -283,22 +324,25 @@ extension PermissionManager: CLLocationManagerDelegate { 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?", - 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)) + 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/Utils/Napier.swift b/iosApp/iosApp/Utils/Napier.swift index af8f6be4d..cae37a7f7 100644 --- a/iosApp/iosApp/Utils/Napier.swift +++ b/iosApp/iosApp/Utils/Napier.swift @@ -10,35 +10,47 @@ import Foundation import shared enum Napier { - static func i(_ message: String, - file: String = #fileID, - function: String = #function, - line: Int = #line) { + 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) { + 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) { + 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) { + 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/CompletedSchedules/CompletedSchedules.swift b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift index bafd157f7..a0a9776c1 100644 --- a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift +++ b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift @@ -18,7 +18,6 @@ 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 { @@ -27,5 +26,11 @@ struct CompletedSchedules: View { ScheduleView(viewModel: scheduleViewModel) } .customNavigationTitle(with: NavigationScreen.pastObservations.localize(), displayMode: .inline) + .onAppear { + scheduleViewModel.coreModel.viewDidAppear() + } + .onDisappear { + scheduleViewModel.coreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Components/ExitButton.swift b/iosApp/iosApp/Views/Components/ExitButton.swift index f470072bb..f80d4aa29 100644 --- a/iosApp/iosApp/Views/Components/ExitButton.swift +++ b/iosApp/iosApp/Views/Components/ExitButton.swift @@ -20,7 +20,7 @@ struct ExitButton: View { } private func withdraw() { - AlertController.shared.openAlertDialog(model: AlertDialogModel( + AlertController.shared.openAlertDialog(model: AlertDialogModel.companion.fromStrings( title: "sure_message", message: "leave_confirmation_message", confirmLabel: "withdraw", diff --git a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift index 6c4fd772e..29e446e93 100644 --- a/iosApp/iosApp/Views/Components/MoreAlertDialog.swift +++ b/iosApp/iosApp/Views/Components/MoreAlertDialog.swift @@ -6,8 +6,8 @@ // Copyright © 2024 Redlink GmbH. All rights reserved. // -import shared import SwiftUI +import shared struct MoreAlertDialog: View { let alertDialogModel: AlertDialogModel @@ -17,7 +17,7 @@ struct MoreAlertDialog: View { Color.black.opacity(0.5) .ignoresSafeArea(edges: .all) VStack(spacing: 20) { - Text(LocalizedStringKey(alertDialogModel.title)) + Text(alertDialogModel.title.localized()) .foregroundColor(.more.primary) .font(.headline) .multilineTextAlignment(.center) @@ -27,7 +27,7 @@ struct MoreAlertDialog: View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading) { - Text(LocalizedStringKey(alertDialogModel.message)) + Text(alertDialogModel.message.localized()) .foregroundColor(.more.primary) .font(.subheadline) .multilineTextAlignment(.leading) @@ -43,7 +43,7 @@ struct MoreAlertDialog: View { onConfirm() } } label: { - Text(LocalizedStringKey(alertDialogModel.confirmLabel)) + Text(alertDialogModel.confirmLabel.localized()) } if let cancelLabel = alertDialogModel.cancelLabel { @@ -53,10 +53,10 @@ struct MoreAlertDialog: View { } } label: { if #available(iOS 17.0, *) { - Text(LocalizedStringKey(cancelLabel)) + Text(cancelLabel.localized()) .foregroundStyle(Color.more.primary) } else { - Text(LocalizedStringKey(cancelLabel)) + Text(cancelLabel.localized()) .foregroundColor(.more.primary) } } @@ -76,15 +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?", - confirmLabel: "Required Permissions Were Not Granted", - cancelLabel: "Continue without allowing", - onConfirm: { - print("Settings") - }, - onDecline: { - 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/WebView/WebViewViewModel.swift b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift index 0b7f92c1f..c35ec1f2f 100644 --- a/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift +++ b/iosApp/iosApp/Views/Components/WebView/WebViewViewModel.swift @@ -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) { diff --git a/iosApp/iosApp/Views/Consent/ConsentView.swift b/iosApp/iosApp/Views/Consent/ConsentView.swift index b5a56d7d8..30c89ab5c 100644 --- a/iosApp/iosApp/Views/Consent/ConsentView.swift +++ b/iosApp/iosApp/Views/Consent/ConsentView.swift @@ -45,6 +45,7 @@ struct ConsentView: View { .tint(.more.primary) } else { MoreActionButton(disabled: .constant(viewModel.requestedPermissions || registration.isLoading), alertOpen: $viewModel.showErrorAlert) { + Napier.event(.buttonPress, message: "Consent accepted") viewModel.requestPermissions() } label: { VStack { @@ -59,6 +60,7 @@ struct ConsentView: View { } Spacer() MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { + Napier.event(.buttonPress, message: "Consent declined") viewModel.decline() } label: { Text("decline_button") @@ -82,13 +84,14 @@ struct ConsentView: View { 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: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: IOSBluetoothConnector(), - observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() ) diff --git a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index a443209bd..ccd723cda 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -16,8 +16,8 @@ import AVFoundation import Combine import KMPNativeCoroutinesCombine -import shared import UIKit +import shared class ConsentViewModel: ObservableObject { private let coreModel: CoreConsentViewModel @@ -39,20 +39,22 @@ class ConsentViewModel: ObservableObject { 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) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] model in + self?.permissionModel = model + } + .store(in: &cancellables) } func onAppear() { permissionManager.observer = self + coreModel.viewDidAppear() } func onDisappear() { permissionManager.observer = nil + coreModel.viewDidDisappear() } func resetPermissionRequest() { @@ -80,22 +82,23 @@ extension ConsentViewModel: PermissionManagerObserver { func accepted() { Task { @MainActor in 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?", - 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 - })) + 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 diff --git a/iosApp/iosApp/Views/Dashboard/DashboardView.swift b/iosApp/iosApp/Views/Dashboard/DashboardView.swift index de91ddb84..84f678e08 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardView.swift @@ -19,11 +19,9 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject private var navigationModalState: NavigationModalState @StateObject var viewModel: ScheduleViewModel - private let stringTable = "DashboardView" @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, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) @@ -34,6 +32,12 @@ struct DashboardView: View { } } .customNavigationTitle(with: NavigationScreen.dashboard.localize(), displayMode: .inline) + .onAppear { + viewModel.coreModel.viewDidAppear() + } + .onDisappear { + viewModel.coreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift index 807128759..a9b6050c8 100644 --- a/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift +++ b/iosApp/iosApp/Views/GarminConnect/GarminConnectView.swift @@ -33,8 +33,12 @@ struct GarminConnectView: View { } } .onAppear { + viewModel.coreViewModel.viewDidAppear() viewModel.clearAllWebViewData() } + .onDisappear() { + viewModel.coreViewModel.viewDidDisappear() + } .onReceive(viewModel.$shouldClose.removeDuplicates()) { close in if close { dismiss() diff --git a/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift b/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift index f9221fbd5..3ef90142d 100644 --- a/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift +++ b/iosApp/iosApp/Views/GarminConnect/GarminConnectViewModel.swift @@ -74,7 +74,7 @@ class GarminConnectViewModel: ObservableObject { } if failure { await MainActor.run { - let dialog = AlertDialogModel(title: "Error during Callback", message: "Error accessing your Garmin Connect Account! Please try again later!", confirmLabel: "Ok", cancelLabel: nil, onConfirm: { [weak self] in + 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 }) diff --git a/iosApp/iosApp/Views/Info/InfoView.swift b/iosApp/iosApp/Views/Info/InfoView.swift index d4977a089..d58844612 100644 --- a/iosApp/iosApp/Views/Info/InfoView.swift +++ b/iosApp/iosApp/Views/Info/InfoView.swift @@ -16,11 +16,9 @@ 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() @@ -58,6 +56,12 @@ struct InfoView: View { } .padding(.horizontal, 10) .customNavigationTitle(with: NavigationScreen.info.localize()) + .onAppear { + viewModel.studyCoreModel.viewDidAppear() + } + .onDisappear { + viewModel.studyCoreModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Info/InfoViewModel.swift b/iosApp/iosApp/Views/Info/InfoViewModel.swift index 6d9f9730b..a0a125872 100644 --- a/iosApp/iosApp/Views/Info/InfoViewModel.swift +++ b/iosApp/iosApp/Views/Info/InfoViewModel.swift @@ -18,7 +18,7 @@ import KMPNativeCoroutinesCombine import shared class InfoViewModel: ObservableObject { - private let studyCoreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) + let studyCoreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared, customViewIdentifier: NavigationRoute.info.viewIdentifier) @Published var studyTitle: String? @Published var contactInstitute: String? @Published var contactPerson: String? diff --git a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift index bd7f9b554..35e824a72 100644 --- a/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift +++ b/iosApp/iosApp/Views/LeaveStudy/LeaveStudyView.swift @@ -14,9 +14,10 @@ // import SwiftUI +import shared struct LeaveStudyView: View { - @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() + private let viewModel: SettingsViewModel = SettingsViewModel(viewIdentifier: NavigationRoute.leaveStudy.viewIdentifier) @EnvironmentObject var contentViewModel: ContentViewModel @EnvironmentObject private var navigationModalState: NavigationModalState @@ -75,5 +76,11 @@ struct LeaveStudyView: View { LeaveStudyConfirmationView(viewModel: viewModel) } .customNavigationTitle(with: NavigationScreen.withdrawStudy.localize()) + .onAppear { + viewModel.coreViewModel.viewDidAppear() + } + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Login/LoginView.swift b/iosApp/iosApp/Views/Login/LoginView.swift index 00c2e5084..b7a4831d7 100644 --- a/iosApp/iosApp/Views/Login/LoginView.swift +++ b/iosApp/iosApp/Views/Login/LoginView.swift @@ -99,7 +99,7 @@ struct LoginView: View { model.validate() } else { AlertController.shared.openAlertDialog( - model: AlertDialogModel( + model: AlertDialogModel.companion.fromStrings( title: "no_internet_title", message: "no_internet_message", confirmLabel: "Ok", @@ -143,13 +143,14 @@ struct LoginView: View { 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: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: IOSBluetoothConnector(), - observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() ) diff --git a/iosApp/iosApp/Views/Login/LoginViewModel.swift b/iosApp/iosApp/Views/Login/LoginViewModel.swift index 09945795d..792a1a528 100644 --- a/iosApp/iosApp/Views/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Views/Login/LoginViewModel.swift @@ -57,7 +57,7 @@ class LoginViewModel: ObservableObject { if loginModel.valid() { registrationService.sendRegistrationToken(loginModel: loginModel) } else { - AlertController.shared.openAlertDialog(model: AlertDialogModel(title: "Token or Endpoint invalid", message: "login_model_invalid_body", confirmLabel: "Ok", cancelLabel: nil, onConfirm: nil, onDecline: nil)) + 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)) } } diff --git a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift index 84027b0e7..6c567d399 100644 --- a/iosApp/iosApp/Views/Login/ScanQRCodeView.swift +++ b/iosApp/iosApp/Views/Login/ScanQRCodeView.swift @@ -116,13 +116,14 @@ struct ScanQRCodeView: View { studyScope: StudyScope.shared, dispatchers: AppDispatchers.shared ) + let userDefaults = UserDefaultsRepository() let sharedContainer = Shared( localNotificationListener: LocalPushNotifications(), repositories: repos, - sharedStorageRepository: UserDefaultsRepository(), + sharedStorageRepository: userDefaults, observationDataManager: dataManager, mainBluetoothConnector: IOSBluetoothConnector(), - observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager), + observationFactory: IOSObservationFactory(repository: repos, dataManager: dataManager, userDefaults: userDefaults), dataRecorder: IOSDataRecorder(), reminderNotificationSchedulingLimit: nil, connectionStatusFlow: Shared.companion.konnectionInstance().observeHasConnection() diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift index bbb6e377d..77939407b 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterView.swift @@ -17,11 +17,11 @@ import shared import SwiftUI struct NotificationFilterView: View { - @StateObject private var viewModel: NotificationFilterViewModel + private let viewModel: NotificationFilterViewModel @State var filtersChanged = false init(coreVM: CoreNotificationFilterViewModel) { - _viewModel = StateObject(wrappedValue: NotificationFilterViewModel(coreViewModel: coreVM)) + viewModel = NotificationFilterViewModel(coreViewModel: coreVM) } var body: some View { @@ -52,5 +52,11 @@ struct NotificationFilterView: View { } .customNavigationTitle(with: NavigationScreen.notificationFilter.localize()) .navigationBarTitleDisplayMode(.inline) + .onAppear { + viewModel.coreViewModel.viewDidAppear() + } + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift index 4829e9f8b..d849cd285 100644 --- a/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift +++ b/iosApp/iosApp/Views/Notification/Filter/NotificationFilterViewModel.swift @@ -18,7 +18,6 @@ import KMPNativeCoroutinesCombine import shared class NotificationFilterViewModel: ObservableObject { - private let stringTable = "NotificationFilter" let coreViewModel: CoreNotificationFilterViewModel @Published var allFilters: [NotificationFilterTypeModel: Bool] = [:] diff --git a/iosApp/iosApp/Views/Notification/NotificationView.swift b/iosApp/iosApp/Views/Notification/NotificationView.swift index 552477204..4820dffbc 100644 --- a/iosApp/iosApp/Views/Notification/NotificationView.swift +++ b/iosApp/iosApp/Views/Notification/NotificationView.swift @@ -58,5 +58,12 @@ struct NotificationView: View { } .frame(maxWidth: .infinity) .customNavigationTitle(with: NavigationScreen.notifications.localize()) + .onAppear { + notificationViewModel.coreModel.viewDidAppear() + } + .onDisappear { + notificationViewModel.coreModel.viewDidDisappear() + } } } + diff --git a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift index 351ad4c46..3d11c12a3 100644 --- a/iosApp/iosApp/Views/Notification/NotificationViewModel.swift +++ b/iosApp/iosApp/Views/Notification/NotificationViewModel.swift @@ -19,7 +19,7 @@ import shared class NotificationViewModel: ObservableObject { private let filterViewModel: CoreNotificationFilterViewModel - private let coreModel: CoreNotificationViewModel + let coreModel: CoreNotificationViewModel @Published var notificationList: [NotificationModel] = [] diff --git a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift index 35ecbe898..c822745ea 100644 --- a/iosApp/iosApp/Views/Registration/RegistrationObservable.swift +++ b/iosApp/iosApp/Views/Registration/RegistrationObservable.swift @@ -46,10 +46,10 @@ class RegistrationObservable: ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { _ in - } receiveValue: { [weak self] error in - self?.error = error - if error != nil && self?.study != nil { - AlertController.shared.openAlertDialog(model: AlertDialogModel( + } 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", diff --git a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift index 74cf32d05..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(), 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 7465d0f13..b81f9f7ab 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationButton.swift @@ -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 QuestionType_().matches(type: observationType) { - MoreActionButton( - disabled: .constant(disabled), - action: { - navigationModalState.openView(screen: .questionObservation, scheduleId: scheduleId) - } - ) { - VStack { + 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 { + Text("start_observation") } } - } else if observationType == "lime-survey-observation" { - MoreActionButton( - disabled: .constant(disabled), - action: { - navigationModalState.openView(screen: .limeSurvey, scheduleId: scheduleId) - } - ) { - VStack { - Text("Button to start a limesurvey") - } - } + } + } + } + + private func buttonAction() { + var screenToOpen: NavigationScreen? = + if QuestionType_().matches(type: observationType) { + .questionObservation + } else if LimeSurveyType().matches(type: observationType) { + .limeSurvey } else { - MoreActionButton( - disabled: .constant(disabled), - action: { - if state == .running { - observationActionDelegate.pause(scheduleId: scheduleId) - } else { - observationActionDelegate.start(scheduleId: scheduleId) - } - } - ) { - VStack { - if state == ScheduleState.running { - Text("pause_observation") - } else { - Text("start_observation") - } - } - } + 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/ScheduleViewModel.swift b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift index d01900fd8..4b135f230 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -20,7 +20,7 @@ import shared class ScheduleViewModel: ObservableObject { let recorder = AppDelegate.shared.dataRecorder let scheduleListType: ScheduleListType - private let coreModel: CoreScheduleViewModel + let coreModel: CoreScheduleViewModel let filterViewModel: DashboardFilterViewModel = DashboardFilterViewModel() diff --git a/iosApp/iosApp/Views/Settings/SettingsView.swift b/iosApp/iosApp/Views/Settings/SettingsView.swift index de642dee5..f5960d066 100644 --- a/iosApp/iosApp/Views/Settings/SettingsView.swift +++ b/iosApp/iosApp/Views/Settings/SettingsView.swift @@ -13,18 +13,57 @@ // https://commonsclause.com/). // -import shared import SwiftUI +import shared struct SettingsView: View { @StateObject private var viewModel: SettingsViewModel = SettingsViewModel() @State private var exitButton = Color.more.important - private let stringTable = "SettingsView" - private let navigationStrings = "Navigation" - var body: some View { VStack(alignment: .leading) { + 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) @@ -36,6 +75,12 @@ struct SettingsView: View { Spacer() } .customNavigationTitle(with: NavigationScreen.settings.localize()) + .onAppear { + viewModel.coreViewModel.viewDidAppear() + } + .onDisappear { + viewModel.coreViewModel.viewDidDisappear() + } } } diff --git a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift index fd9b05a17..911b53de8 100644 --- a/iosApp/iosApp/Views/Settings/SettingsViewModel.swift +++ b/iosApp/iosApp/Views/Settings/SettingsViewModel.swift @@ -13,37 +13,81 @@ // https://commonsclause.com/). // +import AppTrackingTransparency import Combine import Foundation import KMPNativeCoroutinesCombine +import SwiftUI import shared class SettingsViewModel: ObservableObject { - private let coreSettingsViewModel = CoreSettingsViewModel(shared: AppDelegate.shared) + let coreViewModel: CoreSettingsViewModel @Published var studyTitle: String? @Published var permissionModel: PermissionModel? @Published var showSettings = false + @Published var allowTracking = ATTrackingManager.trackingAuthorizationStatus == .authorized + @Published var needsTracking = false private var cancellables: Set = [] + private let permissionManager = PermissionManager() - init() { - createPublisher(for: coreSettingsViewModel.study) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] studyEntity in - self?.studyTitle = studyEntity?.studyTitle - } - .store(in: &cancellables) - - createPublisher(for: coreSettingsViewModel.permissionModel) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }) { [weak self] permissions in - self?.permissionModel = permissions - } - .store(in: &cancellables) + 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) + } + + 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 leaveStudy() { - coreSettingsViewModel.exitStudy() + coreViewModel.exitStudy() } } diff --git a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift index 4122b6912..72b949f78 100644 --- a/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift +++ b/iosApp/iosApp/Views/StudyDetails/StudyDetailsViewModel.swift @@ -18,7 +18,7 @@ import KMPNativeCoroutinesCombine import shared class StudyDetailsViewModel: ObservableObject { - private let coreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared) + private let coreModel = CoreStudyDetailsViewModel(shared: AppDelegate.shared, customViewIdentifier: nil) @Published var studyDetailsModel: StudyDetailsModel? var studyStart: Date = Date() var studyEnd: Date = Date() diff --git a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift index 5f14a8c2a..101be69dd 100644 --- a/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift +++ b/iosApp/iosApp/Views/StudyStates/StudyClosedView.swift @@ -34,7 +34,7 @@ struct StudyClosedView: View { } } MoreActionButton(disabled: .constant(false)) { - AppDelegate.shared.exitStudy(onDeletion: {}) + AppDelegate.shared.exitStudy(onComplete: {}) } label: { BasicText(text: "Leave Study", color: .more.white, font: .headline) } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 8677d3c50..3b5490bb1 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,6 +1,7 @@ // import BackgroundTasks +import SwiftUI import shared // Copyright © 2023 Ludwig Boltzmann Institute for @@ -13,7 +14,6 @@ import shared // https://commonsclause.com/). // -import SwiftUI @main struct iOSApp: App { @@ -24,25 +24,28 @@ struct iOSApp: App { var body: some Scene { WindowGroup { ContentView(viewModel: contentViewModel) - .onAppear { - } - .onChange(of: scenePhase) { newPhase in - switch newPhase { - case .background: - AppDelegate.shared.updateData(appInForeground: false) - if AppDelegate.shared.credentialRepository.hasCredentialsValue { - appDelegate.scheduleTasks() + .onAppear { + } + .onChange(of: scenePhase) { newPhase in + switch newPhase { + case .background: + ViewManager.shared.appIsInForeground(state: false) + AppDelegate.shared.updateData(appInForeground: false) + if AppDelegate.shared.credentialRepository.hasCredentialsValue { + appDelegate.scheduleTasks() + } + case .inactive: + break + case .active: + ViewManager.shared.appIsInForeground(state: true) + PermissionManager.resetPermissionAlertFlag() + AppDelegate.shared.updateData(appInForeground: true) + appDelegate.cancelBackgroundTasks() + break + default: + break } - case .inactive: - break - case .active: - AppDelegate.shared.updateData(appInForeground: true) - appDelegate.cancelBackgroundTasks() - break - default: - break } - } } } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 0d0e5e82c..02881cd60 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,3 +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/). + */ + +import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.openapitools.generator.gradle.plugin.tasks.GenerateTask @@ -116,7 +128,7 @@ tasks.withType { testLogging { events("passed", "skipped", "failed") showStandardStreams = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + exceptionFormat = FULL } } 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/commonMain/kotlin/io/redlink/more/Shared.kt b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt index 7c29f719e..6b93d699b 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/Shared.kt @@ -17,6 +17,8 @@ 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 @@ -44,6 +46,7 @@ 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 @@ -70,7 +73,7 @@ open class Shared( reminderNotificationSchedulingLimit: Int? = null, val connectionStatusFlow: Flow = konnectionInstance().observeHasConnection() -) : NotificationActionObserver, AutoCloseable { +) : NotificationActionObserver, ExitStudyListener, AutoCloseable { val deeplinkManager: DeeplinkManager = DeeplinkManagerImpl(repositories, observationFactory) val endpointRepository: EndpointRepository = EndpointRepositoryImpl(sharedStorageRepository) val credentialRepository: CredentialRepository = @@ -109,6 +112,8 @@ open class Shared( 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()}" } } @@ -154,7 +159,6 @@ open class Shared( } fun updateData(appInForeground: Boolean) { - ViewManager.appIsInForeground(appInForeground) Scope.launch { Napier.d(tag = "Shared:updateData") { "Updating data, with app in foreground: $appInForeground" } if (appInForeground) { @@ -177,6 +181,7 @@ open class Shared( } else { ViewManager.showBLEView(false) if (repositories.study.studyState.value == StudyState.ACTIVE) { + observationDataManager.store() observationDataManager.sendData(true) } } @@ -184,8 +189,9 @@ open class Shared( } suspend fun updateSchedules() { + observationFactory.observationsWithInterface(EventObserver::class) + .forEach { EventCollection.addObserver(it) } observationManager.updateTaskStates() - observationFactory.updateObservationErrors() observationService.scheduleObservationReminder() notificationManager.downloadMissedNotifications() } @@ -354,9 +360,8 @@ open class Shared( } } - suspend fun newLogin() { + fun newLogin() { notificationManager.newFCMToken() - observationFactory.updateObservationErrors() garminLogin() } @@ -375,7 +380,7 @@ open class Shared( } } - fun exitStudy(onDeletion: () -> Unit) { + override fun exitStudy(onComplete: () -> Unit) { StudyScope.cancel() bluetoothController.resetAll() Scope.launch { @@ -384,10 +389,11 @@ open class Shared( observationService.clearReminders() notificationManager.clearAllNotifications() removeStudyData() - observationFactory.clearNeededObservationTypes() - onDeletion() + observationFactory.onStudyExit() + onComplete() ViewManager.resetAll() clearSharedStorage() + EventCollection.clearQueue() } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt index aa563cf1a..63f54302f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/AppDatabase.kt @@ -1,8 +1,20 @@ +/* + * 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 @@ -10,6 +22,7 @@ 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 @@ -26,9 +39,10 @@ import io.redlink.more.database.entities.StudyEntity ObservationDataEntity::class, NotificationEntity::class, BluetoothDeviceEntity::class, - DataPointEntity::class + DataPointEntity::class, + AggregatedObservationDataEntity::class ], - version = 2 + version = 3 ) @ConstructedBy(AppDatabaseConstructor::class) abstract class AppDatabase : RoomDatabase() { @@ -39,4 +53,5 @@ abstract class AppDatabase : RoomDatabase() { 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 index f0572c85a..c09ffe75e 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/DatabaseManager.kt @@ -14,6 +14,7 @@ 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 @@ -26,5 +27,5 @@ fun getRoomDatabase(builder: RoomDatabase.Builder): AppDatabase = builder .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) - .addMigrations(MIGRATION_1_2) + .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/ScheduleDao.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt index e8f6901fd..54078c0a9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/dao/ScheduleDao.kt @@ -113,6 +113,9 @@ interface ScheduleDao : BaseDao { @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) 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/ObservationDataEntity.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt index a9ab2d5f8..e6c4be7e7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/entities/ObservationDataEntity.kt @@ -66,16 +66,20 @@ data class ObservationDataEntity( ) } - fun fromData(data: Any, timestamp: Long = -1): ObservationDataEntity { + inline fun fromData(data: T, timestamp: Long = -1): ObservationDataEntity { val finalTimestamp = if (timestamp > 0) { - timestamp * 1000 // Convert to milliseconds + 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() ?: "{}" + dataValue = data?.asString() ?: "{}" ) } 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/database/repository/MainRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt index ea41de51a..f0cf9f485 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepository.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.database.repository interface MainRepository { @@ -9,6 +20,7 @@ interface MainRepository { 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 index 33b71d30d..257999397 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/MainRepositoryImpl.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.database.repository import io.redlink.more.database.AppDatabase @@ -14,9 +25,12 @@ class MainRepositoryImpl(appDatabase: AppDatabase) : MainRepository { 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/ScheduleRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt index 054abd2cd..da2e7a913 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepository.kt @@ -41,6 +41,8 @@ interface ScheduleRepository { fun firstScheduleIdAvailableForObservationId(observationId: String): Flow + fun observationTypesForScheduleIds(scheduleIds: Set): Flow> + fun getFirstAndLastDate(observationId: String): Flow> suspend fun setRunningStateFor(id: String, scheduleState: ScheduleState) 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 index ac3126f83..ae237ba52 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/database/repository/ScheduleRepositoryImpl.kt @@ -19,7 +19,9 @@ 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 @@ -102,6 +104,11 @@ class ScheduleRepositoryImpl(private val appDatabase: AppDatabase) : ScheduleRep 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() @@ -136,7 +143,11 @@ class ScheduleRepositoryImpl(private val appDatabase: AppDatabase) : ScheduleRep Napier.i { "Updating Schedule states..." } try { - val schedules = appDatabase.scheduleDao().getByDone(false) + val schedules = appDatabase + .scheduleDao() + .getByStatesFlow(ScheduleState.presentScheduleStates.map { it.name }) + .firstOrNull() + ?: emptyList() val stateUpdates = mutableListOf>() val activeIds = mutableSetOf() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt similarity index 69% rename from shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt rename to shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt index e52d18411..4e67ebd0f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/AlertController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/dialog/AlertController.kt @@ -1,8 +1,18 @@ -package io.redlink.more +/* + * 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 io.redlink.more.models.AlertDialogModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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/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/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/NapierProxy.kt rename to shared/src/commonMain/kotlin/io/redlink/more/logging/NapierProxy.kt index 122195af8..010389118 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/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 +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/models/AlertDialogModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt deleted file mode 100644 index e0806f96f..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/AlertDialogModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.redlink.more.models - -data class AlertDialogModel( - var title: String, - var message: String, - var confirmLabel: String, - var cancelLabel: String? = 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 - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt index 9006d8172..7255cedb9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/models/ScheduleState.kt @@ -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/navigation/model/NavigationRoute.kt b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt index afe78b330..b5c17c9b6 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/navigation/model/NavigationRoute.kt @@ -1,28 +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) { - DASHBOARD("dashboard"), - NOTIFICATIONS("notifications"), - INFO("information"), - SETTINGS("settings"), - SCHEDULE_DETAILS("task-details"), - OBSERVATION_DETAILS("observation-details"), - STUDY_DETAILS("study-details"), - OBSERVATION_FILTER("observation-filter"), - QUESTION(QuestionType().observationType), - QUESTIONNAIRE_RESPONSE("${QuestionType().observationType}_response"), - BLUETOOTH_CONNECTION("devices"), - RUNNING_SCHEDULES("running-observations"), - COMPLETED_SCHEDULES("past-observations"), - NOTIFICATION_FILTER("notification-filter"), - LEAVE_STUDY("leave-study"), - LEAVE_STUDY_CONFIRM("leave-study-confirmation"), - LIMESURVEY(LimeSurveyType().observationType), - GARMIN_CONNECT(GarminType().observationType), - OBSERVATION_ERRORS("observation-errors"), - QR_CODE("scan-qr-code"); +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/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt index 1cc876795..1132f11c4 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/Observation.kt @@ -15,10 +15,12 @@ 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 @@ -29,17 +31,28 @@ 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( - private val repos: MainRepository, - val observationType: ObservationType + protected val repos: MainRepository, + val observationType: ObservationType, ) { private var dataManager: ObservationDataManager? = null private var notificationManager: NotificationManager? = null - private var running = false - private val observationIds = mutableSetOf() + private var permissionObserver: ObservationPermissionObserver? = null + + protected var running = false + protected val observationIds = mutableSetOf() + + protected var longRunningStorage: LongRunningObservationStorage? = null + + private val observationTypes = mutableMapOf() - private val scheduleIds = mutableMapOf() + protected val scheduleIds = mutableMapOf() private val notificationIds = mutableMapOf() private val config = mutableMapOf() private var configChanged = false @@ -48,6 +61,15 @@ abstract class Observation( 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, @@ -80,16 +102,20 @@ abstract class Observation( } configChanged = false return if (!running) { - Napier.i(tag = "Observation::start") { "Observation with type ${observationType.observationType} starting" } + Napier.i(tag = "Observation::start") { "Observation with type ${observationType.observationType} starting..." } applyObservationConfig(config) + permissionObserver?.let { + updateObservationPermissions() + } running = start() - return running + 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 (observationIds.size <= 1) { + if (scheduleIds.size <= 1) { stop { timestampCollectionJob?.cancel() saveAndSend() @@ -122,6 +148,25 @@ abstract class Observation( 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) @@ -154,14 +199,20 @@ abstract class Observation( fun observerAccessible(): Boolean { val errors = observerErrors() Napier.d(tag = "Observation::observerAccessible") { errors.toString() } - Scope.launch { - updateObservationErrors() - } 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()) { @@ -183,7 +234,23 @@ abstract class Observation( open fun ableToAutomaticallyStart() = true - fun storeData(data: Any, timestamp: Long = -1, onCompletion: () -> Unit = {}) { + 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 { @@ -209,9 +276,14 @@ abstract class Observation( } open fun stopAndFinish(scheduleId: String) { - Napier.i(tag = "Observation::stopAndFinish") { "Stopping and finishing observation ${observationType.observationType} for observationIds: $observationIds" } - stop { - timestampCollectionJob?.cancel() + 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) } @@ -223,17 +295,25 @@ abstract class Observation( // 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." } - stop { - timestampCollectionJob?.cancel() - saveAndSend() - scheduleIds.keys.forEach { - StudyScope.launch(Dispatchers.IO) { - repos.schedule.setRunningStateFor(it, state) + 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) } } - scheduleId?.let { - observationShutdown(it) + } else { + saveAndSend() + StudyScope.launch(Dispatchers.IO) { + repos.schedule.setRunningStateFor(scheduleId, state) } + observationShutdown(scheduleId) } Scope.launch { updateObservationErrors() @@ -242,14 +322,27 @@ abstract class Observation( 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 { - StudyScope.launch(Dispatchers.IO) { - repos.schedule.setCompletionStateFor(it, true) + 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) @@ -267,11 +360,13 @@ abstract class Observation( private fun observationShutdown(scheduleId: String) { val observationId = scheduleIds.remove(scheduleId) - observationId?.let { - observationIds.remove(it) - observationTypes.remove(it) + observationId?.let { id -> + if (scheduleIds.values.none { it == id }) { + observationIds.remove(id) + observationTypes.remove(id) + } } - if (observationIds.isEmpty()) { + if (scheduleIds.isEmpty()) { config.clear() configChanged = false running = false @@ -327,7 +422,24 @@ abstract class Observation( 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" diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt index 3bb85c989..f8402ad17 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationBulkModel.kt @@ -11,6 +11,6 @@ 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/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt index 7253e264a..2e5f49329 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationFactory.kt @@ -12,46 +12,145 @@ 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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +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, - private val dataManager: ObservationDataManager + 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 { - observations.add(QuestionObservation(repository)) - observations.add(LimeSurveyObservation(repository)) - observations.add(GarminObservation(repository)) - Scope.launch(Dispatchers.IO) { - repository.observation.observationTypes().collect { + 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() } @@ -93,17 +192,63 @@ abstract class ObservationFactory( 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" } - return observations.firstOrNull { + val observation = observations.firstOrNull { it.observationType.matches(type) - }?.apply { + } ?: 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) @@ -111,6 +256,16 @@ abstract class ObservationFactory( } } + 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) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt index 67da29706..e3126fbb8 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/ObservationManager.kt @@ -110,7 +110,7 @@ class ObservationManager( 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" } 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/observations/appUsage/model/EventModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt new file mode 100644 index 000000000..ca5676890 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/EventModel.kt @@ -0,0 +1,20 @@ +/* + * 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.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/observations/appUsage/model/InstantObservationPayload.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt new file mode 100644 index 000000000..bd4159e8b --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/appUsage/model/InstantObservationPayload.kt @@ -0,0 +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 + * 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 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/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/observations/observationTypes/AppUsageObservationType.kt b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.kt new file mode 100644 index 000000000..cf4214c93 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/observations/observationTypes/AppUsageObservationType.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 AppUsageObservationType : ObservationType("app-usage-observation", setOf("appTracking")) { +} \ 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/util/KMMLogger.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt deleted file mode 100644 index c5760bd92..000000000 --- a/shared/src/commonMain/kotlin/io/redlink/more/util/KMMLogger.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.redlink.more.util - -import io.github.aakira.napier.Napier - -// Object for Swift to access the Napier Logger -object KMMLogger { - 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) - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt b/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt new file mode 100644 index 000000000..8aff0e3e3 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/util/PlatformUtils.kt @@ -0,0 +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.util + +expect fun openSystemSettings() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt index a01307912..27c8e9dbe 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/CoreViewModel.kt @@ -10,7 +10,10 @@ */ 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 @@ -22,10 +25,22 @@ import kotlin.coroutines.CoroutineContext abstract class CoreViewModel : Closeable { protected val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - open fun viewDidAppear() {} + 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() { - close() + viewClosed() } fun launchScope( diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt index b5e06286d..c533a374f 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/ViewManager.kt @@ -1,7 +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 + * 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 @@ -17,6 +31,7 @@ object ViewManager { 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) @@ -37,6 +52,9 @@ object ViewManager { @NativeCoroutines val showGarminConnectView: StateFlow = _showGarminConnectView + @NativeCoroutines + val showSettingsView: StateFlow = _showSettingsView + @NativeCoroutines val activeStudy: StateFlow = _activeStudy @@ -114,6 +132,12 @@ object ViewManager { } fun appIsInForeground(state: Boolean) { + if (state) { + Napier.event(LogEvent.APP_IN_FOREGROUND) + Observation.resetRequestedPermissions() + } else { + Napier.event(LogEvent.APP_IN_BACKGROUND) + } _appInForeground.value = state } @@ -137,10 +161,15 @@ object ViewManager { 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() 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 index 155ea0508..2eecf559c 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/bluetoothConnection/BluetoothController.kt @@ -15,6 +15,7 @@ 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 @@ -111,6 +112,10 @@ class BluetoothController( bleManager.disableBgScanning() } + override fun viewIdentifier(): String { + return NavigationRoute.BLUETOOTH_CONNECTION.viewIdentifier + } + override fun viewDidAppear() { bleManager.uiOverrides(true) } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt index d12937988..c74e37de0 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/dashboard/CoreDashboardFilterViewModel.kt @@ -16,6 +16,7 @@ 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 @@ -110,4 +111,8 @@ open class CoreDashboardFilterViewModel(repository: MainRepository) : CoreViewMo 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 index ca332efa5..243b6fdb1 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/garminConnectOAuth/CoreGarminConnectViewModel.kt @@ -1,9 +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 + * 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 @@ -73,6 +85,10 @@ class CoreGarminConnectViewModel( ViewManager.requestGarminConnectView(false) } + override fun viewIdentifier(): String { + return NavigationRoute.GARMIN_CONNECT.viewIdentifier + } + companion object { const val GARMIN_CONNECT_SUCCESSFUL_LOGIN = "GARMIN_CONNECT_SUCCESSFUL_LOGIN" 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 index f24bf9c13..1df355cf9 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/limeSurvey/CoreLimeSurveyViewModel.kt @@ -14,7 +14,10 @@ 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 @@ -43,6 +46,9 @@ class CoreLimeSurveyViewModel( @NativeCoroutines val dataLoading: StateFlow = _dataLoading + override fun viewIdentifier(): String { + return NavigationRoute.LIMESURVEY.viewIdentifier + } init { viewModelScope.launch { @@ -83,6 +89,10 @@ class CoreLimeSurveyViewModel( fun finish() { scheduleId?.let { observation.storeData() + Napier.event( + LogEvent.OBSERVATION_EVENT, + "Limesurvey Study answered for: $limeSurveyLink" + ) observation.stopAndSetDone(it) } clear() 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 index 7f6ed2e77..9450b417d 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/login/CoreLoginViewModel.kt @@ -11,6 +11,7 @@ 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 @@ -30,4 +31,8 @@ open class CoreLoginViewModel(private val registrationService: RegistrationServi 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/viewModels/notifications/CoreNotificationFilterViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt index 2cdf57f98..c763f7bbf 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationFilterViewModel.kt @@ -15,6 +15,7 @@ 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 @@ -84,4 +85,8 @@ open class CoreNotificationFilterViewModel : CoreViewModel() { private fun getEnumAsList(): List { return NotificationFilterTypeModel.entries } + + 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/viewModels/notifications/CoreNotificationViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt index 9bd6bfc4e..3d26a1dee 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/notifications/CoreNotificationViewModel.kt @@ -14,6 +14,7 @@ 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 @@ -69,4 +70,8 @@ open class CoreNotificationViewModel( 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/viewModels/observationDetails/CoreObservationDetailsViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt index 2f15b8f68..e02be5587 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/observationDetails/CoreObservationDetailsViewModel.kt @@ -14,6 +14,7 @@ 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.ObservationDetailsModel +import io.redlink.more.navigation.model.NavigationRoute import io.redlink.more.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.cancellable @@ -24,6 +25,9 @@ class CoreObservationDetailsViewModel( private val observationId: String ) : CoreViewModel() { val observationDetailsModel = MutableStateFlow(null) + override fun viewIdentifier(): String { + return NavigationRoute.OBSERVATION_DETAILS.viewIdentifier + } override fun viewDidAppear() { launchScope { 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 index 30573396e..8f9f99c21 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/permission/CoreConsentViewModel.kt @@ -13,6 +13,7 @@ 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 @@ -26,4 +27,8 @@ class CoreConsentViewModel( 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 index 11e832734..41bd41e42 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/schedules/CoreScheduleViewModel.kt @@ -19,6 +19,7 @@ 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 @@ -184,5 +185,14 @@ open class CoreScheduleViewModel( 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 index 14376c680..2e573dcf7 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/settings/CoreSettingsViewModel.kt @@ -11,18 +11,41 @@ package io.redlink.more.viewModels.settings import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import io.redlink.more.Shared +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( - private val shared: Shared + mainRepository: MainRepository, + sharedStorageRepository: SharedStorageRepository, + private val customViewIdentifier: String? = null ) : CoreViewModel() { + private val permissionRepository = PermissionRepositoryImpl(sharedStorageRepository) private val _dataDeleted = MutableStateFlow(false) @NativeCoroutines @@ -37,25 +60,84 @@ class CoreSettingsViewModel( @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 { - shared.repositories.study.study.combine(shared.repositories.observation.observations()) { study, observations -> + mainRepository.study.study.combine(mainRepository.observation.observations()) { study, observations -> Pair(study, observations) - }.cancellable().collect { - if (it.first?.active == true) { - _study.value = it.first - it.first?.let { study -> - _permissionModel.value = - PermissionModel.createFromSchema(study, it.second) - } + }.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() { - shared.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 index 88116d84c..118f81a48 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/simpleQuestion/QuestionCoreViewModel.kt @@ -11,10 +11,14 @@ 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 @@ -65,6 +69,10 @@ class QuestionCoreViewModel( 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, @@ -72,6 +80,10 @@ class QuestionCoreViewModel( 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) } @@ -79,4 +91,8 @@ class QuestionCoreViewModel( } } } + + override fun viewIdentifier(): String { + return NavigationRoute.QUESTION.viewIdentifier + } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt index 31f0ccae2..260608747 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/startupConnection/CoreBluetoothViewModel.kt @@ -12,6 +12,7 @@ package io.redlink.more.viewModels.startupConnection 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 @@ -23,6 +24,7 @@ class CoreBluetoothViewModel( ) : CoreViewModel() { @NativeCoroutines val devicesNeededToConnectTo: StateFlow> = observationFactory.studyObservationTypes + override fun viewIdentifier(): String = NavigationRoute.BLUETOOTH_CONNECTION.viewIdentifier override fun viewDidAppear() { coreBluetooth.viewDidAppear() 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 index 22e19cf86..0d5a027be 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/studydetails/CoreStudyDetailsViewModel.kt @@ -13,6 +13,7 @@ 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 @@ -20,11 +21,15 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull -class CoreStudyDetailsViewModel(shared: Shared) : CoreViewModel() { +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 { 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 index f1409e7fa..c89085146 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/taskCompletionBar/CoreTaskCompletionBarViewModel.kt @@ -29,6 +29,9 @@ class CoreTaskCompletionBarViewModel( @NativeCoroutines val taskCompletion: StateFlow = _taskCompletion + override fun viewIdentifier(): String { + return "Task Completion Sub View" + } init { launchScope(dispatcher) { 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 index 341dc1172..c02612c32 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/viewModels/tasks/CoreTaskDetailsViewModel.kt @@ -14,6 +14,7 @@ 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 @@ -97,4 +98,8 @@ class CoreTaskDetailsViewModel( 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 index 4a820b8f9..2a021ec59 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -1,9 +1,24 @@ - 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 diff --git a/shared/src/commonMain/moko-resources/de/strings.xml b/shared/src/commonMain/moko-resources/de/strings.xml index 6ce0698fe..f6270691f 100644 --- a/shared/src/commonMain/moko-resources/de/strings.xml +++ b/shared/src/commonMain/moko-resources/de/strings.xml @@ -1,6 +1,26 @@ + 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 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/mocks/DatabaseMock.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt index 62acc6753..9f632913e 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/DatabaseMock.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.mocks import io.redlink.more.database.AppDatabase @@ -20,6 +31,7 @@ class AppDatabase_Impl : AppDatabase(), DB { 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(), "") diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt index 17c6c0b89..5664a9603 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/RepositoryMocks.kt @@ -1,6 +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.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 @@ -8,6 +20,7 @@ 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 @@ -278,6 +291,12 @@ class MockScheduleRepository : ScheduleRepository { 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) @@ -382,7 +401,68 @@ class MockStudyRepository : StudyRepository { } } -class MockMainRepository : MainRepository { + +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() @@ -390,6 +470,7 @@ class MockMainRepository : MainRepository { 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 @@ -398,6 +479,7 @@ class MockMainRepository : MainRepository { 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 @@ -405,14 +487,24 @@ class MockMainRepository : MainRepository { val mockObservationData: MockObservationDataRepository get() = _mockObservationData val mockDataPointCount: MockDataPointCountRepository get() = _mockDataPointCount val mockStudy: MockStudyRepository get() = _mockStudy + val mockAggregatedObservationData: MockAggregatedObservationDataRepository get() = _mockAggregatedObservationData - override suspend fun deleteAll() {} + override suspend fun deleteAll() { + _mockStudy.deleteStudy() + _mockNotification.deleteAll() + _mockAggregatedObservationData.deleteAll() + } } class MockObservationFactory( repository: MainRepository = MockMainRepository(), + sharedStorageRepository: MockSharedStorageRepository = MockSharedStorageRepository(), dataManager: ObservationDataManager? = null -) : ObservationFactory(repository, dataManager ?: mockObservationDataManager(repository)) { +) : ObservationFactory( + repository, + sharedStorageRepository, + dataManager ?: mockObservationDataManager(repository) +) { var matchingObservationTypes: Set = emptySet() override fun getMatchingObservationTypes(types: Set): Set = diff --git a/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt b/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt index a9c83a38d..6e81f1f2d 100644 --- a/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.kt +++ b/shared/src/commonTest/kotlin/io/redlink/more/mocks/ServiceMocks.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.mocks import dev.tmapps.konnection.Konnection @@ -5,6 +16,8 @@ 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 @@ -12,3 +25,42 @@ fun mockObservationDataManager(repository: MainRepository = MockMainRepository() 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/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/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/iosMain/kotlin/io/redlink/more/extensions/toString.kt b/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt index 2b63ea041..f96815906 100644 --- a/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt +++ b/shared/src/iosMain/kotlin/io/redlink/more/extensions/toString.kt @@ -21,7 +21,7 @@ import platform.Foundation.create 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/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 + ) + } +}