From f5f435a26925208858b698a5365cab4f25739145 Mon Sep 17 00:00:00 2001 From: Jan Cortiel Date: Tue, 30 Apr 2024 15:04:47 +0200 Subject: [PATCH] #200: Rebuilt the BLE Core, Controller, implemented a few warning displays and extended the Coroutine System to differentiate between app critical coroutines and study critical ones --- .../android/activities/ContentViewModel.kt | 3 +- .../bluetooth/BluetoothViewModel.kt | 8 +- .../activities/consent/ConsentViewModel.kt | 25 ++- .../dashboard/schedule/ScheduleViewModel.kt | 53 +++--- .../schedule/list/ScheduleListItem.kt | 9 + .../android/activities/main/MainActivity.kt | 1 - .../android/activities/main/MainViewModel.kt | 46 +++-- .../limeSurvey/LimeSurveyViewModel.kt | 10 +- .../observations/GPS/GPSObservation.kt | 22 ++- .../HR/PolarHeartRateObservation.kt | 17 +- .../observations/ObservationExtension.kt | 7 +- .../accelerometer/AccelerometerObservation.kt | 8 +- .../bluetooth/AndroidBluetoothConnector.kt | 4 +- .../services/bluetooth/PolarConnector.kt | 2 +- .../android/workers/ScheduleUpdateWorker.kt | 3 +- iosApp/iosApp.xcodeproj/project.pbxproj | 4 + iosApp/iosApp/ContentView.swift | 4 +- iosApp/iosApp/ContentViewModel.swift | 28 ++- iosApp/iosApp/Extensions/ArrayExtension.swift | 6 +- .../Extensions/BluetoothDeviceExtension.swift | 2 +- .../Extensions/ObservationExtension.swift | 10 +- iosApp/iosApp/Extensions/SetExtension.swift | 20 +++ .../AccelerometerBackgroundObservation.swift | 11 +- .../AccelerometerObservation.swift | 8 +- .../iosApp/Observations/GPSObservation.swift | 28 +-- .../PolarVerityHeartRateObservation.swift | 101 ++++++----- .../Bluetooth/IOSBluetoothConnector.swift | 9 +- .../Services/Bluetooth/PolarConnector.swift | 131 +++++++------- .../iosApp/Services/PermissionManager.swift | 6 +- .../Bluetooth/BluetoothConnectionView.swift | 5 +- .../BluetoothConnectionViewModel.swift | 11 +- .../Views/Consent/ConsentViewModel.swift | 14 +- .../Schedule/List/ObservationDetails.swift | 16 +- .../Schedule/List/ScheduleListItem.swift | 2 +- .../Views/Schedule/ScheduleViewModel.swift | 7 + .../Views/TaskDetails/TaskDetailsView.swift | 35 ++-- .../TaskDetails/TaskDetailsViewModel.swift | 24 ++- ...ContentViewModel.kt => AlertController.kt} | 19 +- .../more/more_app_mutliplatform/Shared.kt | 55 +++--- .../database/RealmDatabase.kt | 10 +- .../repository/BluetoothDeviceRepository.kt | 167 +++--------------- .../repository/DataPointCountRepository.kt | 14 +- .../repository/ObservationDataRepository.kt | 26 +-- .../repository/ObservationRepository.kt | 12 +- .../database/repository/Repository.kt | 8 +- .../database/repository/ScheduleRepository.kt | 7 +- .../models/ScheduleModel.kt | 1 - .../observations/Observation.kt | 19 +- .../observations/ObservationDataManager.kt | 12 +- .../observations/ObservationFactory.kt | 44 ++++- .../observations/ObservationManager.kt | 18 +- .../limesurvey/LimeSurveyObservation.kt | 5 - .../SimpleQuestionObservation.kt | 6 +- .../services/bluetooth/BluetoothDevice.kt | 16 +- .../bluetooth/BluetoothDeviceManager.kt | 53 ++++-- .../services/network/NetworkService.kt | 4 +- .../services/network/RegistrationService.kt | 14 +- .../notification/NotificationManager.kt | 3 +- .../more_app_mutliplatform/util/StudyScope.kt | 68 +++++++ .../viewModels/CoreViewModel.kt | 16 +- .../viewModels/ViewManager.kt | 42 +++++ .../BluetoothController.kt | 94 ++++------ .../dashboard/CoreDashboardFilterViewModel.kt | 12 +- .../CoreNotificationFilterViewModel.kt | 32 ++-- .../schedules/CoreScheduleViewModel.kt | 17 +- .../CoreBluetoothViewModel.kt | 2 +- 66 files changed, 824 insertions(+), 672 deletions(-) create mode 100644 iosApp/iosApp/Extensions/SetExtension.swift rename shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/{viewModels/CoreContentViewModel.kt => AlertController.kt} (67%) create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StudyScope.kt create mode 100644 shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt 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 f8b3d94b..3360ca1e 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 @@ -30,6 +30,7 @@ import io.redlink.more.app.android.extensions.applicationId import io.redlink.more.app.android.extensions.showNewActivityAndClearStack import io.redlink.more.app.android.extensions.stringResource import io.redlink.more.app.android.workers.ScheduleUpdateWorker +import io.redlink.more.more_app_mutliplatform.AlertController import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel import io.redlink.more.more_app_mutliplatform.services.network.RegistrationService import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Study @@ -59,7 +60,7 @@ class ContentViewModel : ViewModel(), LoginViewModelListener, ConsentViewModelLi init { viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.mainContentCoreViewModel.alertDialogModel.collect { + AlertController.alertDialogModel.collect { withContext(Dispatchers.Main) { alertDialogOpen.value = it } 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 2865561f..78f8ab4c 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 @@ -15,10 +15,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.redlink.more.app.android.MoreApplication +import io.redlink.more.more_app_mutliplatform.AlertController import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel import io.redlink.more.more_app_mutliplatform.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.viewModels.ViewManager import io.redlink.more.more_app_mutliplatform.viewModels.startupConnection.CoreBluetoothViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -27,7 +29,7 @@ import kotlinx.coroutines.withContext class BluetoothViewModel : ViewModel() { private val coreBluetoothViewModel = CoreBluetoothViewModel( MoreApplication.shared!!.observationFactory, - MoreApplication.shared!!.coreBluetooth + MoreApplication.shared!!.bluetoothController ) val discoveredDevices = mutableStateListOf() val connectedDevices = mutableStateListOf() @@ -42,7 +44,7 @@ class BluetoothViewModel : ViewModel() { init { viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.mainContentCoreViewModel.alertDialogModel.collect { + AlertController.alertDialogModel.collect { withContext(Dispatchers.Main) { alertDialogOpen.value = it } @@ -90,9 +92,11 @@ class BluetoothViewModel : ViewModel() { fun viewDidAppear() { coreBluetoothViewModel.viewDidAppear() + ViewManager.bleViewOpen(true) } fun viewDidDisappear() { + ViewManager.bleViewOpen(false) coreBluetoothViewModel.viewDidDisappear() connectingDevices.clear() connectedDevices.clear() 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 eb15e474..fe29127b 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 @@ -18,6 +18,7 @@ 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 @@ -36,11 +37,19 @@ class ConsentViewModel( registrationService: RegistrationService, private val consentViewModelListener: ConsentViewModelListener ) : ViewModel() { - private val coreModel = CorePermissionViewModel(registrationService, stringResource(R.string.consent_information)) + 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())) + mutableStateOf( + PermissionModel( + "Title", + "Participation Info", + "Study Consent Info", + emptyList() + ) + ) val loading = mutableStateOf(false) val error = mutableStateOf(null) val permissions = mutableSetOf() @@ -83,34 +92,34 @@ class ConsentViewModel( } fun openPermissionDeniedAlertDialog(context: Context) { - MoreApplication.shared!!.mainContentCoreViewModel.openAlertDialog(AlertDialogModel( + 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 - MoreApplication.shared!!.mainContentCoreViewModel.closeAlertDialog() + AlertController.closeAlertDialog() }, onNegative = { acceptConsent(context) - MoreApplication.shared!!.mainContentCoreViewModel.closeAlertDialog() + AlertController.closeAlertDialog() } )) } fun openNotificationPermissionDeniedAlertDialog() { - MoreApplication.shared!!.mainContentCoreViewModel.openAlertDialog(AlertDialogModel( + 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 - MoreApplication.shared!!.mainContentCoreViewModel.closeAlertDialog() + AlertController.closeAlertDialog() }, onNegative = { - MoreApplication.shared!!.mainContentCoreViewModel.closeAlertDialog() + AlertController.closeAlertDialog() } )) } 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 fef12de0..196d250c 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,39 +15,43 @@ 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.DataRecorder 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( - coreFilterModel: CoreDashboardFilterViewModel, - dataRecorder: DataRecorder, val scheduleListType: ScheduleListType ) : ViewModel() { + private val coreDashboardFilterViewModel = CoreDashboardFilterViewModel() private val coreViewModel = CoreScheduleViewModel( - dataRecorder, - coreFilterModel = coreFilterModel, + MoreApplication.shared!!.dataRecorder, + coreFilterModel = coreDashboardFilterViewModel, scheduleListType = scheduleListType ) val polarHrReady: MutableState = mutableStateOf(false) val schedulesByDate = mutableStateMapOf>() + val observationErrors = mutableStateMapOf>() - val filterModel = DashboardFilterViewModel(coreFilterModel) + val filterModel = DashboardFilterViewModel(coreDashboardFilterViewModel) - init { - viewModelScope.launch { + private val jobs = mutableListOf() + + 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) -> @@ -61,34 +65,28 @@ class ScheduleViewModel( ).sortedBy { it.start } } } - } - viewModelScope.launch(Dispatchers.IO) { + }) + jobs.add(viewModelScope.launch(Dispatchers.IO) { PolarHeartRateObservation.hrReady.collect { withContext(Dispatchers.Main) { polarHrReady.value = it -// val polarSchedules = schedulesByDate.values.flatten().filter { it.observationType == "polar-verity-observation" } -// if (!it) { -// polarSchedules.filter { it.scheduleState == ScheduleState.RUNNING } -// .forEach { -// pauseObservation(it.scheduleId) -// } -// } else { -// polarSchedules.filter { it.scheduleState == ScheduleState.PAUSED } -// .forEach { -// startObservation(it.scheduleId) -// } -// } } } - } - } - - fun viewDidAppear() { - coreViewModel.viewDidAppear() + }) + jobs.add(viewModelScope.launch(Dispatchers.IO) { + MoreApplication.shared!!.observationFactory.observationErrors.collect { + withContext(Dispatchers.Main) { + observationErrors.clear() + observationErrors.putAll(it) + } + } + }) } fun viewDidDisappear() { coreViewModel.viewDidDisappear() + jobs.forEach { it.cancel() } + jobs.clear() } fun startObservation(scheduleId: String) { @@ -103,6 +101,7 @@ class ScheduleViewModel( coreViewModel.stop(scheduleId) } + private fun mergeSchedules( first: Set, second: Set 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 14f6ec55..0dd523f1 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 @@ -21,7 +21,9 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -47,6 +49,8 @@ fun ScheduleListItem( viewModel: ScheduleViewModel, showButton: Boolean ) { + val observationErrors = + remember { viewModel.observationErrors[scheduleModel.observationType] ?: emptySet() } Column( verticalArrangement = Arrangement.SpaceEvenly, modifier = Modifier @@ -69,6 +73,9 @@ fun ScheduleListItem( .width(15.dp) ) } + if (observationErrors.isEmpty()) { + Icon(Icons.Default.Warning, contentDescription = null, tint = MoreColors.Primary) + } } Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -99,6 +106,7 @@ fun ScheduleListItem( ) } } + "lime-survey-observation" -> { SmallTextButton( text = getStringResource(id = R.string.more_limesurvey_start), @@ -107,6 +115,7 @@ fun ScheduleListItem( navController.navigate(NavigationScreen.LIMESURVEY.navigationRoute("scheduleId" to scheduleModel.scheduleId)) } } + else -> { SmallTextButton( text = if (scheduleModel.scheduleState == ScheduleState.RUNNING) getStringResource( 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 9cb9291a..fcd59828 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 @@ -81,7 +81,6 @@ class MainActivity : ComponentActivity() { LaunchedEffect(Unit) { navHostController.addOnDestinationChangedListener(destinationChangeListener) - viewModel.viewDidAppear() } if (viewModel.studyIsUpdating.value) { StudyUpdateView() 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 7b4b9346..ba76ec38 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 @@ -14,6 +14,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import androidx.activity.result.ActivityResultLauncher +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -32,17 +33,18 @@ 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.dashboard.CoreDashboardFilterViewModel +import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager import io.redlink.more.more_app_mutliplatform.viewModels.notifications.CoreNotificationFilterViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class MainViewModel(context: Context) : ViewModel() { - val tabIndex = mutableStateOf(0) + val tabIndex = mutableIntStateOf(0) val showBackButton = mutableStateOf(false) val navigationBarTitle = mutableStateOf("") @@ -54,26 +56,23 @@ class MainViewModel(context: Context) : ViewModel() { val notificationViewModel: NotificationViewModel val notificationFilterViewModel: NotificationFilterViewModel - val manualTasks = + val manualTasks: ScheduleViewModel by lazy { ScheduleViewModel( - CoreDashboardFilterViewModel(), - MoreApplication.shared!!.dataRecorder, ScheduleListType.MANUALS ) + } + val runningSchedulesViewModel: ScheduleViewModel by lazy { ScheduleViewModel( - CoreDashboardFilterViewModel(), - MoreApplication.shared!!.dataRecorder, ScheduleListType.RUNNING ) } val completedSchedulesViewModel: ScheduleViewModel by lazy { ScheduleViewModel( - CoreDashboardFilterViewModel(), - MoreApplication.shared!!.dataRecorder, ScheduleListType.COMPLETED ) } + val dashboardViewModel = DashboardViewModel(manualTasks) val settingsViewModel: SettingsViewModel by lazy { SettingsViewModel() } val studyDetailsViewModel: StudyDetailsViewModel by lazy { StudyDetailsViewModel() } @@ -93,18 +92,19 @@ class MainViewModel(context: Context) : ViewModel() { TaskDetailsViewModel(MoreApplication.shared!!.dataRecorder) } val alertDialogOpen = mutableStateOf(null) + private var lastBleViewState = false init { viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.mainContentCoreViewModel.alertDialogModel.collect { + AlertController.alertDialogModel.collect { withContext(Dispatchers.Main) { alertDialogOpen.value = it } } } viewModelScope.launch { - MoreApplication.shared!!.studyIsUpdating.collect { + ViewManager.studyIsUpdating.collect { studyIsUpdating.value = it } } @@ -112,34 +112,32 @@ class MainViewModel(context: Context) : ViewModel() { MoreApplication.shared!!.currentStudyState.collect { finishText.value = MoreApplication.shared!!.finishText studyState.value = it - if (it == StudyState.ACTIVE && initFinished) { - showBLESetup(context) - } } } + val coreNotificationFilterViewModel = CoreNotificationFilterViewModel() notificationViewModel = NotificationViewModel(coreNotificationFilterViewModel) notificationFilterViewModel = NotificationFilterViewModel(coreNotificationFilterViewModel) - showBLESetup(context) + initFinished = true - } - private fun showBLESetup(context: Context) { - MoreApplication.shared!!.showBleSetup().let { (firstTime, hasBLEObservations) -> - if (hasBLEObservations) { - if (firstTime) { + viewModelScope.launch { + ViewManager.showBluetoothView.collect { + if (it && !lastBleViewState) { openBLESetupActivity(context) } + lastBleViewState = it } } } + private fun showBLESetup() { + MoreApplication.shared!!.showBLESetupOnFirstStartup() + } + fun getTaskDetailsVM(scheduleId: String) = taskDetailsViewModel.apply { setSchedule(scheduleId) } - fun viewDidAppear() { - } - fun openLimesurvey( context: Context, activityResultLauncher: ActivityResultLauncher, @@ -190,4 +188,4 @@ class MainViewModel(context: Context) : ViewModel() { it.startActivity(intent) } } -} \ No newline at end of file +} 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 e983bde2..4d7c2e4c 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,8 +15,8 @@ import android.webkit.WebView import androidx.compose.runtime.mutableStateOf 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.AlertController import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel import io.redlink.more.more_app_mutliplatform.viewModels.limeSurvey.CoreLimeSurveyViewModel import kotlinx.coroutines.Dispatchers @@ -35,7 +35,7 @@ class LimeSurveyViewModel : ViewModel(), WebClientListener { init { viewModelScope.launch(Dispatchers.IO) { - MoreApplication.shared!!.mainContentCoreViewModel.alertDialogModel.collect { + AlertController.alertDialogModel.collect { withContext(Dispatchers.Main) { alertDialogOpen.value = it } @@ -60,7 +60,11 @@ class LimeSurveyViewModel : ViewModel(), WebClientListener { } - fun setModel(scheduleId: String? = null, observationId: String? = null, notificationId: String? = null) { + fun setModel( + scheduleId: String? = null, + observationId: String? = null, + notificationId: String? = null + ) { if (!scheduleId.isNullOrBlank()) { coreViewModel.setScheduleId(scheduleId, notificationId) } else if (!observationId.isNullOrBlank()) { 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 bb1e99d2..c1036616 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 @@ -44,7 +44,7 @@ class GPSObservation( override fun start(): Boolean { Napier.d { "Trying to start GPS..." } - if (this.activate()) { + if (this.hasPermission()) { val listener = this scope.launch { Napier.d { "Registering GPS Service..." } @@ -61,10 +61,18 @@ class GPSObservation( onCompletion() } - override fun observerAccessible(): Boolean { - return locationManager != null - && locationManager.isLocationEnabled - && locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + override fun observerErrors(): Set { + val errors = mutableSetOf() + if (locationManager == null) { + errors.add("Location Services return an unknown error!") + } + if (locationManager.isLocationEnabled) { + errors.add("Location Servies are disabled!") + } + if (!hasPermission()) { + errors.add("No Permission were granted to access the location services!") + } + return errors } override fun applyObservationConfig(settings: Map) { @@ -93,11 +101,11 @@ class GPSObservation( Napier.d { "Location available: $available" } } - private fun activate(): Boolean { + private fun hasPermission(): Boolean { return this.hasPermissions(MoreApplication.appContext!!) } - private fun hasPermissions(context: Context): Boolean { + private fun hasPermissions(context: Context): Boolean { getPermission().forEach { permission -> if (ActivityCompat.checkSelfPermission( context, 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 c32e4151..4ec7794e 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 @@ -93,10 +93,17 @@ class PolarHeartRateObservation : onCompletion() } - override fun observerAccessible(): Boolean { - return hasPermissions(MoreApplication.appContext!!) && MoreApplication.shared!!.coreBluetooth.observerDeviceAccessible( - deviceIdentifier - ) + override fun observerErrors(): Set { + val errors = mutableSetOf() + if (hasPermissions(MoreApplication.appContext!!)) { + errors.add("No permission to access bluetooth!") + } else if (MoreApplication.shared!!.bluetoothController.observerDeviceAccessible( + deviceIdentifier + ) + ) { + errors.add("No viable device connected!") + } + return errors } override fun bleDevicesNeeded(): Set { @@ -128,7 +135,7 @@ class PolarHeartRateObservation : private fun listenToDeviceConnection(): Job { return Scope.launch { BluetoothDeviceManager.connectedDevices.collect { devices -> - if (deviceIdentifier.anyNameIn(devices)) { + if (!deviceIdentifier.anyNameIn(devices)) { pauseObservation(PolarVerityHeartRateType(emptySet())) hrReady.set(false) Napier.d(tag = "PolarHeartRateObservation::Companion::listenToDeviceConnection") { "HR Feature removed!" } 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 84364bbc..d6c52db3 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 @@ -3,22 +3,23 @@ package io.redlink.more.app.android.observations 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 fun Observation.showPermissionAlertDialog() { - MoreApplication.shared!!.mainContentCoreViewModel.openAlertDialog(AlertDialogModel( + 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 - MoreApplication.shared!!.mainContentCoreViewModel.closeAlertDialog() + AlertController.closeAlertDialog() }, onNegative = { - MoreApplication.shared!!.mainContentCoreViewModel.closeAlertDialog() + AlertController.closeAlertDialog() } )) } 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 8658e51d..258127d9 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 @@ -56,8 +56,12 @@ class AccelerometerObservation( onCompletion() } - override fun observerAccessible(): Boolean { - return this.sensor != null + override fun observerErrors(): Set { + val errors = mutableSetOf() + if (this.sensor == null) { + errors.add("Cannot access Accelerometer sensor!") + } + return errors } override fun applyObservationConfig(settings: Map) { 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 e85f65de..fb3af53f 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 @@ -231,7 +231,7 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { @SuppressLint("MissingPermission") override fun scan() { - if (bluetoothState == BluetoothState.ON && observer != null && !scanning && !isConnecting) { + if (bluetoothState == BluetoothState.ON && !scanning && !isConnecting) { val scanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() @@ -393,5 +393,5 @@ class AndroidBluetoothConnector(context: Context) : BluetoothConnector { @SuppressLint("MissingPermission") fun AndroidBluetoothDevice.toBluetoothDevice(): BluetoothDevice { - return BluetoothDevice.create(this.address, this.name, this.address, true) + return BluetoothDevice.create(this.address, this.name, this.address) } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/PolarConnector.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/bluetooth/PolarConnector.kt index da1932f5..255fede8 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 @@ -237,5 +237,5 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe } fun PolarDeviceInfo.toBluetoothDevice(): BluetoothDevice { - return BluetoothDevice.create(this.deviceId, this.name, this.address, this.isConnectable) + return BluetoothDevice.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/ScheduleUpdateWorker.kt b/androidApp/src/main/java/io/redlink/more/app/android/workers/ScheduleUpdateWorker.kt index 81157db0..4392d3b7 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 @@ -20,7 +20,8 @@ import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleReposi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class ScheduleUpdateWorker(context: Context, workerParameters: WorkerParameters): CoroutineWorker(context, workerParameters) { +class ScheduleUpdateWorker(context: Context, workerParameters: WorkerParameters) : + CoroutineWorker(context, workerParameters) { private val shared: Shared diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 8f43bc2c..83a66e87 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -138,6 +138,7 @@ 1FF5B28D2A8275790076EF8E /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF5B28C2A8275790076EF8E /* Bundle.swift */; }; 1FF6D8AD2BC516270050AF10 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1F9283C02BC512E500D459A7 /* PrivacyInfo.xcprivacy */; }; 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 */; }; B72C646029DC812900F5A7C6 /* MoreTextFieldHL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B72C645E29DC812900F5A7C6 /* MoreTextFieldHL.swift */; }; B72C646129DC812900F5A7C6 /* MoreTextFieldSmBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = B72C645F29DC812900F5A7C6 /* MoreTextFieldSmBottom.swift */; }; @@ -356,6 +357,7 @@ 1FF5B28A2A8274C10076EF8E /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; 1FF5B28C2A8275790076EF8E /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 3007C5E9B53BC561D105D2B3 /* SetExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetExtension.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* More.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = More.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -690,6 +692,7 @@ 1F6A4E3D29F6D0D200F0247F /* BluetoothDeviceExtension.swift */, 1F9B81BC2A28CBF70013738A /* KotlinMutableSetExtension.swift */, 1FF5B28C2A8275790076EF8E /* Bundle.swift */, + 3007C5E9B53BC561D105D2B3 /* SetExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -1271,6 +1274,7 @@ EDBB2B4829B8CFE400CA973E /* SettingsViewModel.swift in Sources */, B79DBA5129D3431100A1F547 /* DashboardFilterView.swift in Sources */, 1F6A4E3C29F6C95B00F0247F /* PolarConnector.swift in Sources */, + 3007C4E152846B66C9FD5A69 /* SetExtension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 1ab38e9b..1dafe156 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -29,7 +29,9 @@ struct ContentView: View { } else { MainTabView() .sheet(isPresented: $viewModel.showBleView) { - BluetoothConnectionView(viewModel: viewModel.bluetoothViewModel, viewOpen: $viewModel.showBleView, showAsSeparateView: true) + MoreMainBackgroundView(contentPadding: 8) { + BluetoothConnectionView(viewModel: viewModel.bluetoothViewModel, viewOpen: $viewModel.showBleView, showAsSeparateView: true) + } } } } else { diff --git a/iosApp/iosApp/ContentViewModel.swift b/iosApp/iosApp/ContentViewModel.swift index e5062e95..d40ffd24 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -72,34 +72,30 @@ class ContentViewModel: ObservableObject { notificationFilterViewModel = NotificationFilterViewModel(coreViewModel: coreNotificationFilterViewModel) hasCredentials = AppDelegate.shared.credentialRepository.hasCredentials() - AppDelegate.shared.onStudyIsUpdatingChange { kBool in + ViewManager.shared.studyIsUpdatingAsClosure { kBool in AppDelegate.navigationScreenHandler.studyIsUpdating(kBool.boolValue) } + ViewManager.shared.showBluetoothViewAsClosure { [weak self] kBool in + self?.showBleView = kBool.boolValue + } + AppDelegate.shared.onStudyStateChange { [weak self] studyState in self?.finishText = AppDelegate.shared.finishText AppDelegate.navigationScreenHandler.setStudyState(studyState) } - AppDelegate.shared.mainContentCoreViewModel.onNewAlertDialogModel { [weak self] alertDialogModel in + AlertController.shared.onNewAlertDialogModel { [weak self] alertDialogModel in self?.alertDialogModel = alertDialogModel } - if hasCredentials { - scanBluetooth() - } +// if hasCredentials { +// showBLESetup() +// } } - func scanBluetooth() { - let pair = AppDelegate.shared.showBleSetup() - - if let hasBleObservations = pair.second, hasBleObservations.boolValue { - if let firstStartup = pair.first, firstStartup.boolValue { - DispatchQueue.main.async { - self.showBleView = true - } - } - } + func showBLESetup() { + AppDelegate.shared.showBLESetupOnFirstStartup() } func showLoginView() { @@ -172,7 +168,7 @@ extension ContentViewModel: ConsentViewModelListener { DispatchQueue.main.async { [weak self] in if let self { self.hasCredentials = true - self.scanBluetooth() + self.showBLESetup() } } AppDelegate.shared.doNewLogin() diff --git a/iosApp/iosApp/Extensions/ArrayExtension.swift b/iosApp/iosApp/Extensions/ArrayExtension.swift index c8aa4e5f..5350521e 100644 --- a/iosApp/iosApp/Extensions/ArrayExtension.swift +++ b/iosApp/iosApp/Extensions/ArrayExtension.swift @@ -17,7 +17,7 @@ import Foundation extension Array where Element: Collection { func flatten() -> [Element.Element] { - return reduce([],+) + return reduce([], +) } } @@ -27,7 +27,7 @@ extension Array where Element: Equatable { self.remove(at: i) } } - + mutating func pop(_ elementToPop: Element) -> Int { if let index = self.lastIndex(of: elementToPop) { self.remove(at: index) @@ -35,4 +35,4 @@ extension Array where Element: Equatable { } return -1 } -} +} \ No newline at end of file diff --git a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift index 7dbf786c..94ac7955 100644 --- a/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift +++ b/iosApp/iosApp/Extensions/BluetoothDeviceExtension.swift @@ -19,6 +19,6 @@ import PolarBleSdk extension BluetoothDevice { static func fromPolarDevice(polarInfo: PolarDeviceInfo) -> BluetoothDevice { - BluetoothDevice.Companion().create(deviceId: polarInfo.deviceId, deviceName: polarInfo.name, address: polarInfo.address.uuidString, isConnectable: polarInfo.connectable) + BluetoothDevice.Companion().create(deviceId: polarInfo.deviceId, deviceName: polarInfo.name, address: polarInfo.address.uuidString) } } diff --git a/iosApp/iosApp/Extensions/ObservationExtension.swift b/iosApp/iosApp/Extensions/ObservationExtension.swift index 3ad1b808..b56b628e 100644 --- a/iosApp/iosApp/Extensions/ObservationExtension.swift +++ b/iosApp/iosApp/Extensions/ObservationExtension.swift @@ -23,13 +23,17 @@ protocol ObservationCollector { extension Observation_ { func showPermissionAlert() { - AppDelegate.shared.mainContentCoreViewModel.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: { + 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) } - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() }, onNegative: { - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() })) } + + func pauseObservation(_ observationType: ObservationType) { + AppDelegate.shared.observationManager.pauseObservationType(type: observationType.observationType) + } } diff --git a/iosApp/iosApp/Extensions/SetExtension.swift b/iosApp/iosApp/Extensions/SetExtension.swift new file mode 100644 index 00000000..d3b0141b --- /dev/null +++ b/iosApp/iosApp/Extensions/SetExtension.swift @@ -0,0 +1,20 @@ +import Foundation +import shared + +extension Set where Element == String { + func anyNameIn(items: Set) -> Bool { + contains { name in + items.contains { item in + item.deviceName?.contains(name) ?? false + } + } + } +} + +extension Set where Element == BluetoothDevice { + func deviceWithNameIn(nameSet: Set) -> [BluetoothDevice] { + filter { device in + nameSet.contains { device.deviceName?.contains($0) ?? false } + } + } +} diff --git a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift index 81eb847b..f6e546d4 100644 --- a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift @@ -87,8 +87,15 @@ class AccelerometerBackgroundObservation: Observation_ { } } - override func observerAccessible() -> Bool { - return CMSensorRecorder.isAccelerometerRecordingAvailable() && CMSensorRecorder.authorizationStatus() == .authorized + override func observerErrors() -> Set { + var errors: Set = [] + if !CMSensorRecorder.isAccelerometerRecordingAvailable() { + errors.insert("Accelerometer Recording is not available!") + } + if CMSensorRecorder.authorizationStatus() != .authorized { + errors.insert("Permission not granted to access Sensor recording service!") + } + return errors } } diff --git a/iosApp/iosApp/Observations/AccelerometerObservation.swift b/iosApp/iosApp/Observations/AccelerometerObservation.swift index bb7416e6..edd0ca15 100644 --- a/iosApp/iosApp/Observations/AccelerometerObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerObservation.swift @@ -47,8 +47,12 @@ class AccelerometerObservation: Observation_ { onCompletion() } - override func observerAccessible() -> Bool { - motion.isAccelerometerAvailable + override func observerErrors() -> Set { + var errors: Set = [] + if !motion.isAccelerometerAvailable { + errors.insert("Accelerometer Sensor not available!") + } + return errors } override func applyObservationConfig(settings: Dictionary){ diff --git a/iosApp/iosApp/Observations/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index 9363ba73..75433ae0 100644 --- a/iosApp/iosApp/Observations/GPSObservation.swift +++ b/iosApp/iosApp/Observations/GPSObservation.swift @@ -35,14 +35,8 @@ class GPSObservation: Observation_ { manager.showsBackgroundLocationIndicator = true manager.startUpdatingLocation() return true - } else { - if manager.authorizationStatus == .notDetermined { - manager.requestWhenInUseAuthorization() - } else { - self.showPermissionAlert() - } - return false } + return false } override func stop(onCompletion: @escaping () -> Void) { @@ -50,11 +44,21 @@ class GPSObservation: Observation_ { running = false onCompletion() } - - override func observerAccessible() -> Bool { - return CLLocationManager.locationServicesEnabled() - && (manager.authorizationStatus == .authorizedWhenInUse - || manager.authorizationStatus == .authorizedAlways) + + override func observerErrors() -> Set { + var errors: Set = [] + if !CLLocationManager.locationServicesEnabled() { + errors.insert("Location Services not enabled!") + } + 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!") + self.showPermissionAlert() + } + return errors } override func applyObservationConfig(settings: Dictionary) {} diff --git a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift index 649d4920..3b3559de 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -22,90 +22,89 @@ import UIKit class PolarVerityHeartRateObservation: Observation_ { static var hrReady = false - - static func polarDeviceDisconnected() { - let polarDevices = AppDelegate.shared.mainBluetoothConnector.connected.filter{ - if let deviceName = ($0 as? BluetoothDevice)?.deviceName { - return deviceName.lowercased().contains("polar") - } - return false - } - if polarDevices.isEmpty { - hrReady = false - AppDelegate.shared.observationManager.pauseObservationType(type: PolarVerityHeartRateType(sensorPermissions: Set()).observationType) - } - } - + static func setHRReady() { if !hrReady { hrReady = true - AppDelegate.shared.observationManager.startObservationType(type: PolarVerityHeartRateType(sensorPermissions: Set()).observationType) + AppDelegate.shared.observationManager.startObservationType(type: PolarVerityHeartRateType(sensorPermissions: []).observationType) } } - - private let deviceIdentificer = "Polar" + + private let deviceIdentificer: Set = ["Polar"] private let polarConnector = AppDelegate.polarConnector - private let bluetoothRepository = BluetoothDeviceRepository(bluetoothConnector: nil) + private var connectedDevices: [BluetoothDevice] = [] private var hrObservation: Disposable? = nil + private let deviceManager = BluetoothDeviceManager.shared + + private var deviceListener: Ktor_ioCloseable? + init(sensorPermissions: Set) { super.init(observationType: PolarVerityHeartRateType(sensorPermissions: sensorPermissions)) - bluetoothRepository.listenForConnectedDevices() - bluetoothRepository.getConnectedDevices { [weak self] deviceList in - if let self { - self.connectedDevices = deviceList.filter{$0.deviceName?.lowercased().contains(self.deviceIdentificer.lowercased()) ?? false} - } - } } - + override func start() -> Bool { - if self.observerAccessible(){ - if let address = (self.polarConnector.connected.allObjects.first as? BluetoothDevice)?.address { - hrObservation = self.polarConnector.polarApi.startHrStreaming(address).subscribe(onNext: { [weak self] data in + if self.observerAccessible() { + let acceptableDevices = deviceManager.connectedDevicesAsValue().deviceWithNameIn(nameSet: deviceIdentificer) + if !acceptableDevices.isEmpty, let firstAddres = acceptableDevices[0].address { + listenToDeviceConnection() + hrObservation = self.polarConnector.polarApi.startHrStreaming(firstAddres).subscribe(onNext: { [weak self] data in if let self, let hrData = data.first { - self.storeData(data: ["hr": hrData.hr], timestamp: -1) {} + self.storeData(data: ["hr": hrData.hr], timestamp: -1) { + } } - }, onError: { error in + }, onError: { [weak self] error in print(error) + if let self { + self.pauseObservation(self.observationType) + } }) return true } } - self.showPermissionAlert() return false } - + override func stop(onCompletion: @escaping () -> Void) { - hrObservation?.dispose() + self.hrObservation?.dispose() + self.deviceListener?.close() onCompletion() } - override func observerAccessible() -> Bool { - if let hasBleObservations = AppDelegate.shared.showBleSetup().second?.boolValue, hasBleObservations { - if self.polarConnector.connected.count > 0 { - AppDelegate.shared.coreBluetooth.disableBackgroundScanner() - return true - } else { - AppDelegate.shared.coreBluetooth.enableBackgroundScanner() - return false - } - } else { - return false + override func observerErrors() -> Set { + var errors: Set = [] + if CBManager.authorization != .allowedAlways { + errors.insert("Access to Bluetooth not granted!") + self.showPermissionAlert() } + if !AppDelegate.shared.bluetoothController.observerDeviceAccessible(bleDevices: deviceIdentificer) { + errors.insert("No polar device connected!") + } + return errors } - - override func applyObservationConfig(settings: Dictionary){ - + + override func applyObservationConfig(settings: Dictionary) { + } - + override func bleDevicesNeeded() -> Set { - print("Polar device needed \(Set([deviceIdentificer]))") - return Set([deviceIdentificer]) + print("Polar device needed \(deviceIdentificer)") + return deviceIdentificer } - + override func ableToAutomaticallyStart() -> Bool { observerAccessible() } + + private func listenToDeviceConnection() { + self.deviceListener = deviceManager.connectedDevicesAsClosure { [weak self] devices in + if let self, !self.deviceIdentificer.anyNameIn(items: devices) { + self.pauseObservation(self.observationType) + PolarVerityHeartRateObservation.hrReady = false + self.deviceListener?.close() + } + } + } } diff --git a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift index dcbc4085..bfed049c 100644 --- a/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/IOSBluetoothConnector.swift @@ -29,9 +29,6 @@ typealias BluetoothDeviceList = [BluetoothDevice: CBPeripheral] class IOSBluetoothConnector: NSObject, BluetoothConnector { let specificBluetoothConnectors: KotlinMutableDictionary = KotlinMutableDictionary() - let connected: KotlinMutableSet = KotlinMutableSet() - let discovered: KotlinMutableSet = KotlinMutableSet() - private lazy var centralManager: CBCentralManager = { CBCentralManager(delegate: self, queue: nil) }() @@ -102,8 +99,6 @@ class IOSBluetoothConnector: NSObject, BluetoothConnector { } func replayStates() { - self.connectedDevices.keys.forEach{didConnectToDevice(bluetoothDevice: $0)} - self.discoveredDevices.keys.forEach{ didDiscoverDevice(device: $0)} isScanning(boolean: scanning) } @@ -249,7 +244,6 @@ extension IOSBluetoothConnector: CBCentralManagerDelegate { if peripheral.name != nil { let device = peripheral.toBluetoothDevice() if peripheral.state == .connected { - device.connected = true connectedDevices[device] = peripheral self.didConnectToDevice(bluetoothDevice: device) } else { @@ -269,8 +263,7 @@ extension CBPeripheral { BluetoothDevice.Companion().create( deviceId: self.identifier.uuidString, deviceName: self.name ?? "Unknown", - address: self.identifier.uuidString, - isConnectable: true + address: self.identifier.uuidString ) } } diff --git a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift index 3f7e5199..faab4efc 100644 --- a/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift +++ b/iosApp/iosApp/Services/Bluetooth/PolarConnector.swift @@ -24,24 +24,24 @@ class PolarConnector: NSObject, BluetoothConnector { let deviceManager = BluetoothDeviceManager.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, - ]) - + 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) @@ -49,18 +49,18 @@ class PolarConnector: NSObject, BluetoothConnector { api.deviceFeaturesObserver = self api.powerStateObserver = self } - + return api }() - + var observer: KotlinMutableSet = KotlinMutableSet() - + var scanning = false { didSet { isScanning(boolean: scanning) } } - + func addSpecificBluetoothConnector(key: String, connector: BluetoothConnector) { specificBluetoothConnectors[key] = connector } @@ -87,19 +87,18 @@ class PolarConnector: NSObject, BluetoothConnector { } } } - + func scan() { if CBManager.authorization == .restricted || CBManager.authorization == .denied { - AppDelegate.shared.mainContentCoreViewModel.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: { + 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) } - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() }, onNegative: { - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() })) - } - else if !scanning && self.observer.count > 0 && bluetoothState == BluetoothState.on { + } else if !scanning && self.observer.count > 0 && bluetoothState == BluetoothState.on { print("Polar: Starting the scan...") DispatchQueue.main.async { [weak self] in if let self { @@ -129,81 +128,89 @@ class PolarConnector: NSObject, BluetoothConnector { } func close() { - + } - + func isConnectingToDevice(bluetoothDevice: BluetoothDevice) { - updateObserver { $0.isConnectingToDevice(bluetoothDevice: bluetoothDevice) } + updateObserver { + $0.isConnectingToDevice(bluetoothDevice: bluetoothDevice) + } } - + func didConnectToDevice(bluetoothDevice: BluetoothDevice) { - if let address = bluetoothDevice.address, !address.isEmpty { - deviceManager.addConnectedDevices(devices: [bluetoothDevice]) - removeDiscoveredDevice(device: bluetoothDevice) + updateObserver { + $0.didConnectToDevice(bluetoothDevice: bluetoothDevice) } - updateObserver{ $0.didConnectToDevice(bluetoothDevice: bluetoothDevice)} } - + func didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { - deviceManager.removeConnectedDevices(devices: [bluetoothDevice]) - PolarVerityHeartRateObservation.polarDeviceDisconnected() - updateObserver{ $0.didDisconnectFromDevice(bluetoothDevice: bluetoothDevice)} + updateObserver { + $0.didDisconnectFromDevice(bluetoothDevice: bluetoothDevice) + } } - + func didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { - updateObserver{ $0.didFailToConnectToDevice(bluetoothDevice: bluetoothDevice)} + updateObserver { + $0.didFailToConnectToDevice(bluetoothDevice: bluetoothDevice) + } } - + func removeDiscoveredDevice(device: BluetoothDevice) { - deviceManager.removeDiscoveredDevices(devices: [device]) - updateObserver{ $0.removeDiscoveredDevice(device: device)} + updateObserver { + $0.removeDiscoveredDevice(device: device) + } } - + func didDiscoverDevice(device: BluetoothDevice) { - deviceManager.addConnectedDevices(devices: [device]) - updateObserver{ $0.didDiscoverDevice(device: device)} + updateObserver { + $0.didDiscoverDevice(device: device) + } } - + func isScanning(boolean: Bool) { if boolean != scanning { scanning = boolean } - updateObserver{ $0.isScanning(boolean: boolean)} + updateObserver { + $0.isScanning(boolean: boolean) + } } - + func onBluetoothStateChange(bluetoothState: BluetoothState) { self.bluetoothState = bluetoothState - updateObserver{ $0.onBluetoothStateChange(bluetoothState: bluetoothState)} + updateObserver { + $0.onBluetoothStateChange(bluetoothState: bluetoothState) + } } - + func addObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { self.observer.add(bluetoothConnectorObserver) if self.observer.count > 0 { replayStates() } } - + func removeObserver(bluetoothConnectorObserver: BluetoothConnectorObserver) { self.observer.remove(bluetoothConnectorObserver) if self.observer.count == 0 { stopScanning() } } - + func updateObserver(action: @escaping (BluetoothConnectorObserver) -> Void) { - observer.forEach{ + observer.forEach { if let observer = $0 as? BluetoothConnectorObserver { action(observer) } } } - + func replayStates() { print("Polar Connector: Replaying states...") onBluetoothStateChange(bluetoothState: self.bluetoothState) isScanning(boolean: scanning) } - + } extension PolarConnector: PolarBleApiObserver { @@ -211,12 +218,12 @@ extension PolarConnector: PolarBleApiObserver { print("Polar disconnected: \(identifier.name). Had paring error: \(pairingError)") self.didDisconnectFromDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) } - + func deviceConnecting(_ identifier: PolarBleSdk.PolarDeviceInfo) { print("Polar connecting: \(identifier.name)") self.isConnectingToDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) } - + func deviceConnected(_ identifier: PolarDeviceInfo) { print("Polar connected: \(identifier.name)") self.didConnectToDevice(bluetoothDevice: BluetoothDevice.fromPolarDevice(polarInfo: identifier)) @@ -233,7 +240,7 @@ extension PolarConnector: PolarBleApiPowerStateObserver { self?.stopScanning() } } - + func blePowerOff() { print("Polar power off") self.onBluetoothStateChange(bluetoothState: .off) @@ -253,36 +260,36 @@ extension PolarConnector: PolarBleApiDeviceFeaturesObserver { 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.setHRReady() } } - - + + } extension PolarConnector: PolarBleApiDeviceInfoObserver { func batteryLevelReceived(_ identifier: String, batteryLevel: UInt) { print("Battery level for \(identifier): \(batteryLevel)") } - + func disInformationReceived(_ identifier: String, uuid: CBUUID, value: String) { print("Disinformation received by \(identifier): \(uuid); \(value)") } - + } extension PolarConnector: PolarBleApiLogger { diff --git a/iosApp/iosApp/Services/PermissionManager.swift b/iosApp/iosApp/Services/PermissionManager.swift index e9f27158..7eb98cde 100644 --- a/iosApp/iosApp/Services/PermissionManager.swift +++ b/iosApp/iosApp/Services/PermissionManager.swift @@ -82,13 +82,13 @@ class PermissionManager: NSObject, ObservableObject { if notificationStatus == .accepted { AppDelegate.registerForNotifications() } else { - AppDelegate.shared.mainContentCoreViewModel.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: { + 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) } - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() }, onNegative: { - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() })) } requestPermission() diff --git a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift index ee109a6c..edcbfd5c 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionView.swift @@ -89,15 +89,14 @@ struct BluetoothConnectionView: View { } } - // .padding(.bottom, 8) 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))!") } else { - ForEach(viewModel.discoveredDevices, id: \.self.address) { device in + ForEach(viewModel.discoveredDevices, id: \.address) { device in if let deviceName = device.deviceName { VStack(alignment: .leading) { - HStack(alignment: .firstTextBaseline) { + HStack { DetailsTitle(text: deviceName) if let address = device.address, viewModel.connectingDevices.contains(address) { Spacer() diff --git a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift index a1f97423..a357544f 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift @@ -17,7 +17,7 @@ import Foundation import shared class BluetoothConnectionViewModel: ObservableObject { - private let coreViewModel: CoreBluetoothViewModel = CoreBluetoothViewModel(observationFactory: AppDelegate.shared.observationFactory, coreBluetooth: AppDelegate.shared.coreBluetooth) + private let coreViewModel: CoreBluetoothViewModel = CoreBluetoothViewModel(observationFactory: AppDelegate.shared.observationFactory, coreBluetooth: AppDelegate.shared.bluetoothController) private let deviceManager = BluetoothDeviceManager.shared @Published var discoveredDevices: [BluetoothDevice] = [] @@ -28,15 +28,8 @@ class BluetoothConnectionViewModel: ObservableObject { @Published var neededDevices: [String] = [] - init() { - coreViewModel.devicesNeededChange { [weak self] deviceList in - DispatchQueue.main.async { - self?.neededDevices = Array(AppDelegate.shared.observationFactory.bleDevicesNeeded(types: deviceList)) - } - } - } - func viewDidAppear() { + self.neededDevices = Array(AppDelegate.shared.observationFactory.bleDevicesNeeded()) deviceManager.discoveredDevicesAsClosure { [weak self] deviceSet in if let self { DispatchQueue.main.async { diff --git a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift index 0a68bb14..52493c47 100644 --- a/iosApp/iosApp/Views/Consent/ConsentViewModel.swift +++ b/iosApp/iosApp/Views/Consent/ConsentViewModel.swift @@ -47,11 +47,15 @@ class ConsentViewModel: NSObject, ObservableObject { coreModel = CorePermissionViewModel(registrationService: registrationService, studyConsentTitle: String.localize(forKey: "study_consent", withComment: "Consent of the study", inTable: stringTable)) super.init() coreModel.onConsentModelChange { model in - self.permissionModel = model + DispatchQueue.main.async { + self.permissionModel = model + } } coreModel.onLoadingChange { loading in if let loading = loading as? Bool { - self.isLoading = loading + DispatchQueue.main.async { + self.isLoading = loading + } } } } @@ -114,16 +118,16 @@ class ConsentViewModel: NSObject, ObservableObject { extension ConsentViewModel: PermissionManagerObserver { func accepted() { if permissionManager.anyNeededPermissionDeclined() { - AppDelegate.shared.mainContentCoreViewModel.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: { + 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) } - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() self.resetPermissionRequest() }, onNegative: { self.acceptConsent() self.requestedPermissions = false - AppDelegate.shared.mainContentCoreViewModel.closeAlertDialog() + AlertController.shared.closeAlertDialog() })) } else { self.acceptConsent() diff --git a/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift b/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift index 291265c4..3c5a8b59 100644 --- a/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift +++ b/iosApp/iosApp/Views/Schedule/List/ObservationDetails.swift @@ -16,8 +16,9 @@ import SwiftUI struct ObservationDetails: View { - @State var observationTitle: String - @State var observationType: String + let observationTitle: String + let observationType: String + let observerHasErrors: Bool var action: () -> Void = {} var body: some View { @@ -26,11 +27,18 @@ struct ObservationDetails: View { BasicText(text: observationTitle) .font(Font.more.headline) .foregroundColor(Color.more.primary) - .padding(0.5) + .padding(.bottom, 1) Text(observationType) .foregroundColor(Color.more.secondary) } + .padding(4) Spacer() + if observerHasErrors { + Image(systemName: "exclamationmark.triangle") + .font(.more.headline) + .foregroundColor(.more.primary) + .padding(.trailing, 4) + } Image(systemName: "chevron.forward") } } @@ -38,6 +46,6 @@ struct ObservationDetails: View { struct ObservationDetails_Previews: PreviewProvider { static var previews: some View { - ObservationDetails(observationTitle:"Observation Title", observationType: "Observation Type") + ObservationDetails(observationTitle:"Observation Title", observationType: "Observation Type", observerHasErrors: true) } } diff --git a/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift b/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift index 7835b439..ad643ff5 100644 --- a/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift +++ b/iosApp/iosApp/Views/Schedule/List/ScheduleListItem.swift @@ -33,7 +33,7 @@ struct ScheduleListItem: View { navigationModalState.openView(screen: .taskDetails, scheduleId: scheduleModel.scheduleId) } label: { VStack(alignment: .leading) { - ObservationDetails(observationTitle: scheduleModel.observationTitle, observationType: scheduleModel.observationType) + ObservationDetails(observationTitle: scheduleModel.observationTitle, observationType: scheduleModel.observationType, observerHasErrors: viewModel.observationErrors[scheduleModel.observationType]?.isEmpty ?? false) .padding(.bottom, 4) ObservationTimeDetails(start: scheduleModel.start, end: scheduleModel.end) } diff --git a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift index 5d8c7717..a533368f 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -23,6 +23,7 @@ class ScheduleViewModel: ObservableObject { let filterViewModel: DashboardFilterViewModel = DashboardFilterViewModel() @Published var schedulesByDate: [Date: [ScheduleModel]] = [:] + @Published var observationErrors: [String: Set] = [:] init(scheduleListType: ScheduleListType) { @@ -70,6 +71,12 @@ class ScheduleViewModel: ObservableObject { } } } + + AppDelegate.shared.observationFactory.observationErrorsAsClosure { [weak self] errors in + DispatchQueue.main.async { + self?.observationErrors = errors + } + } } func viewDidAppear() { diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift index 4c94036a..2669fba4 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -18,10 +18,9 @@ import SwiftUI struct TaskDetailsView: View { @StateObject var viewModel: TaskDetailsViewModel - + @EnvironmentObject var navigationModalState: NavigationModalState - - + private let stringTable = "TaskDetail" private let scheduleStringTable = "ScheduleListView" private let navigationStrings = "Navigation" @@ -41,17 +40,15 @@ struct TaskDetailsView: View { } } .frame(height: 40) - - HStack( - ) { + + HStack { BasicText(text: viewModel.taskDetailsModel?.observationType ?? "", color: .more.secondary) Spacer() } } - - + 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 ?? "") } @@ -61,7 +58,7 @@ struct TaskDetailsView: View { DatapointsCollection(datapoints: $viewModel.dataCount, running: detailsModel.state == .running) } Spacer() - + if !detailsModel.hidden { if let scheduleId = navigationModalState.navigationState(for: .taskDetails)?.scheduleId { ObservationButton( @@ -72,6 +69,13 @@ struct TaskDetailsView: View { disabled: !detailsModel.state.active()) } } + if !viewModel.observationErrors.isEmpty { + ScrollView { + List(viewModel.observationErrors, id: \.self) { error in + BasicText(text: error) + } + } + } } Spacer() } @@ -86,8 +90,9 @@ struct TaskDetailsView: View { } } -extension TaskDetailsView: SimpleQuestionObservationListener { - func onQuestionAnswered() { - // self.presentationMode.wrappedValue.dismiss() +struct TaskDetailsViewPreview_Provider: PreviewProvider { + static var previews: some View { + TaskDetailsView(viewModel: TaskDetailsViewModel(dataRecorder: AppDelegate.shared.dataRecorder)) + .environmentObject(NavigationModalState()) } } diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift index 9ee95be3..90b70634 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift @@ -19,17 +19,33 @@ import SwiftUI class TaskDetailsViewModel: ObservableObject { private let coreModel: CoreTaskDetailsViewModel - @Published var taskDetailsModel: TaskDetailsModel? + @Published var taskDetailsModel: TaskDetailsModel? { + didSet { + if self.taskDetailsModel == nil { + self.observationErrors = [] + } + } + } @Published var dataCount: Int64 = 0 + @Published var observationErrors: [String] = [] var simpleQuestionObservationVM: SimpleQuestionObservationViewModel + private var observationErrorsJob: Ktor_ioCloseable? + init(dataRecorder: DataRecorder) { self.coreModel = CoreTaskDetailsViewModel(dataRecorder: dataRecorder) self.simpleQuestionObservationVM = SimpleQuestionObservationViewModel() - coreModel.onLoadTaskDetails { taskDetails in - if let taskDetails { - self.taskDetailsModel = taskDetails + coreModel.onLoadTaskDetails { [weak self] taskDetails in + if let self { + if let taskDetails { + self.taskDetailsModel = taskDetails + self.observationErrorsJob = AppDelegate.shared.observationFactory.observationErrorsAsClosure { errors in + self.observationErrors = Array(errors[taskDetails.observationType] ?? []) + } + } else { + self.observationErrorsJob?.close() + } } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreContentViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt similarity index 67% rename from shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreContentViewModel.kt rename to shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt index 8902d773..75435646 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreContentViewModel.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/AlertController.kt @@ -1,17 +1,17 @@ -package io.redlink.more.more_app_mutliplatform.viewModels +package io.redlink.more.more_app_mutliplatform import io.redlink.more.more_app_mutliplatform.extensions.asNullableClosure import io.redlink.more.more_app_mutliplatform.extensions.set import io.redlink.more.more_app_mutliplatform.extensions.setNullable import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -class CoreContentViewModel: CoreViewModel() { - val alertDialogModel = MutableStateFlow(null); - private var alertDialogQueue = mutableListOf() +object AlertController { + private val _alertDialogModel = MutableStateFlow(null) - override fun viewDidAppear() { - } + val alertDialogModel: StateFlow = _alertDialogModel + private var alertDialogQueue = mutableListOf() fun openAlertDialog(model: AlertDialogModel) { if (model.onPositive == {}) { @@ -25,15 +25,16 @@ class CoreContentViewModel: CoreViewModel() { } } if (this.alertDialogQueue.isEmpty() && this.alertDialogModel.value == null) { - this.alertDialogModel.set(model) + this._alertDialogModel.set(model) } else { this.alertDialogQueue.add(model) } } fun closeAlertDialog() { - this.alertDialogModel.setNullable(alertDialogQueue.removeFirstOrNull()) + this._alertDialogModel.setNullable(alertDialogQueue.removeFirstOrNull()) } - fun onNewAlertDialogModel(provideNewState: ((AlertDialogModel?) -> Unit)) = alertDialogModel.asNullableClosure(provideNewState) + 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 1e31e79f..f1e63b87 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,7 +21,6 @@ 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.extensions.set 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 @@ -37,14 +36,12 @@ 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.viewModels.CoreContentViewModel +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.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch class Shared( localNotificationListener: LocalNotificationListener, @@ -54,21 +51,19 @@ class Shared( val observationFactory: ObservationFactory, val dataRecorder: DataRecorder ) { + private val viewManager = ViewManager val deeplinkManager = DeeplinkManager(observationFactory) val endpointRepository: EndpointRepository = EndpointRepository(sharedStorageRepository) val credentialRepository: CredentialRepository = CredentialRepository(sharedStorageRepository) val studyStateRepository: StudyStateRepository = StudyStateRepository(sharedStorageRepository) val networkService: NetworkService = NetworkService(endpointRepository, credentialRepository) val observationManager = ObservationManager(observationFactory, dataRecorder) - val coreBluetooth = BluetoothController(mainBluetoothConnector) + val bluetoothController = BluetoothController(mainBluetoothConnector) val notificationManager = NotificationManager(localNotificationListener, networkService, deeplinkManager) - val mainContentCoreViewModel = CoreContentViewModel() var appIsInForeGround = false - val studyIsUpdating = MutableStateFlow(false) - val showBluetoothView = MutableStateFlow(false) val currentStudyState = studyStateRepository.currentState() var finishText: String? = null @@ -91,6 +86,8 @@ class Shared( notificationManager.clearAllNotifications() updateTaskStates() updateStudyBlocking() + } else { + ViewManager.showBLEView(false) } } @@ -98,11 +95,12 @@ class Shared( if (appIsInForeGround && credentialRepository.hasCredentials()) { observationManager.updateTaskStates() notificationManager.downloadMissedNotifications() + bluetoothController.startScanningForDevices(observationFactory.bleDevicesNeeded()) } } private fun activateObservationWatcher(overwriteCheck: Boolean = false) { - Scope.launch { + StudyScope.launch { if (overwriteCheck || StudyRepository().getStudy().firstOrNull()?.active == true) { observationDataManager.listenToDatapointCountChanges() updateTaskStates() @@ -132,17 +130,16 @@ class Shared( } else false } - fun showBleSetup(): Pair { - return Pair( - firstStartUp(), - observationFactory.bleDevicesNeeded() - .isNotEmpty() + fun showBLESetupOnFirstStartup() { + ViewManager.showBLEView( + firstStartUp() + && observationFactory.bleDevicesNeeded().isNotEmpty() ) } fun updateStudyBlocking(oldStudyState: StudyState? = null, newStudyState: StudyState? = null) { - Scope.launch { + Scope.launch(Dispatchers.IO) { updateStudy(oldStudyState, newStudyState) } } @@ -163,11 +160,12 @@ class Shared( if (newStudyState == StudyState.CLOSED || newStudyState == StudyState.PAUSED) { Napier.d(tag = "Shared::updateStudy") { "New study State is $newStudyState" } studyStateRepository.storeState(newStudyState) - studyIsUpdating.emit(true) + viewManager.studyIsUpdating(true) + StudyScope.cancel() stopObservations() removeStudyData() notificationManager.clearAllNotifications() - studyIsUpdating.emit(false) + viewManager.studyIsUpdating(false) } else { val (study, error) = networkService.getStudyConfig() if (error != null) { @@ -182,7 +180,8 @@ class Shared( } } if (studyHasChanged || currentStudy == null) { - studyIsUpdating.emit(true) + viewManager.studyIsUpdating(true) + StudyScope.cancel() stopObservations() removeStudyData() if (study.studyState?.let { StudyState.getState(it) } != StudyState.CLOSED) { @@ -200,7 +199,7 @@ class Shared( } ?: if (study.active == true) StudyState.ACTIVE else StudyState.PAUSED) } - studyIsUpdating.emit(false) + viewManager.studyIsUpdating(false) } } if (newStudyState != null) { @@ -212,24 +211,25 @@ class Shared( fun newLogin() { notificationManager.newFCMToken() studyStateRepository.storeState(StudyState.ACTIVE) - Scope.launch { + StudyScope.launch { finishText = StudyRepository().getStudy().firstOrNull()?.finishText } activateObservationWatcher() } fun exitStudy(onDeletion: () -> Unit) { - CoroutineScope(Job() + Dispatchers.Default).launch { - stopObservations() + StudyScope.cancel() + stopObservations() + bluetoothController.resetAll() + Scope.launch { networkService.deleteParticipation() notificationManager.clearAllNotifications() notificationManager.deleteFCMToken() clearSharedStorage() DatabaseManager.deleteAll() - coreBluetooth.resetAll() onDeletion() observationFactory.clearNeededObservationTypes() - studyIsUpdating.set(false) + viewManager.resetAll() studyStateRepository.storeState(StudyState.NONE) } } @@ -258,9 +258,6 @@ class Shared( ) } - fun onStudyIsUpdatingChange(providedState: (Boolean) -> Unit) = - studyIsUpdating.asClosure(providedState) - fun onStudyStateChange(providedState: (StudyState) -> Unit) = currentStudyState.asClosure(providedState) 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 513a3e03..3309a80a 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 @@ -20,24 +20,20 @@ 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.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import io.redlink.more.more_app_mutliplatform.util.Scope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.transform -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.reflect.KClass -private const val DB_SCHEMA_VERSION: Long = 3 +private const val DB_SCHEMA_VERSION: Long = 4 object RealmDatabase { var realm: Realm? = null private set - private val scope = CoroutineScope(Job() + Dispatchers.Default) val mutex = Mutex() fun open(realmObjects: Set>) { @@ -61,7 +57,7 @@ object RealmDatabase { updatePolicy: UpdatePolicy = UpdatePolicy.ALL ) { if (realmObjects.isNotEmpty()) { - scope.launch { + Scope.launch { mutex.withLock { realm?.write { realmObjects.forEach { 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 a5073ee8..ea2d5b51 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,32 +10,23 @@ */ 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.extensions.asClosure -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.launch -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -class BluetoothDeviceRepository(private val bluetoothConnector: BluetoothConnector? = null) : - Repository(), BluetoothConnectorObserver { +class BluetoothDeviceRepository : + Repository() { private val deviceManager = BluetoothDeviceManager init { - launch { - getDevices().cancellable().collect { + Scope.launch { + pairedDevices().cancellable().collect { deviceManager.addPairedDeviceIds(it.toSet()) } } @@ -43,30 +34,14 @@ class BluetoothDeviceRepository(private val bluetoothConnector: BluetoothConnect override fun count(): Flow = realmDatabase().count() - fun listenForConnectedDevices() { - launch { - getConnectedDevices().cancellable().collect { - deviceManager.addConnectedDevices(it.toSet()) - } - } - } - - fun setConnectionState(bluetoothDevice: BluetoothDevice, connected: Boolean) { - if (connected) { - deviceManager.addConnectingDevices(setOf(bluetoothDevice)) - } - launch { - realm()?.write { - bluetoothDevice.address?.let { - val device = this.query("address = $0", it).first().find() - if (device != null) { - Napier.i { "Setting state for device: $device to $connected" } - device.connected = connected - device.shouldAutomaticallyReconnect = connected - } else { - Napier.i { "Adding device $bluetoothDevice and setting state to $connected" } - bluetoothDevice.connected = connected - bluetoothDevice.shouldAutomaticallyReconnect = connected + fun storePairedDevice(bluetoothDevice: BluetoothDevice) { + if (bluetoothDevice.address != null) { + StudyScope.launch { + realm()?.write { + val device = + this.query("address = $0", bluetoothDevice.address).first() + .find() + if (device == null) { this.copyToRealm(bluetoothDevice, updatePolicy = UpdatePolicy.ALL) } } @@ -74,116 +49,20 @@ class BluetoothDeviceRepository(private val bluetoothConnector: BluetoothConnect } } - fun setAllConnectionStates(connected: Boolean) { - realm()?.writeBlocking { - this.query() - .find() - .map { - it.connected = connected - } - } - } - - fun setAutoReconnect(bluetoothDevice: BluetoothDevice, shouldAutoReconnect: Boolean) { - launch { - realm()?.write { - bluetoothDevice.address?.let { - val device = this.query("address = $0", it).first().find() + 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) { - Napier.i { "Setting auto reconnect state for device: $device to $shouldAutoReconnect" } - device.shouldAutomaticallyReconnect = shouldAutoReconnect + this.delete(device) } } } } } - fun getAllDevicesWithAutoReconnectEnabled() = - realmDatabase().query(query = "shouldAutomaticallyReconnect = true") - - fun setConnectionState(deviceIds: Set, connected: Boolean) { - realm()?.writeBlocking { - this.query() - .find() - .filter { it.deviceId in deviceIds } - .map { - it.connected = connected - } - } - } - - fun getDevices() = realmDatabase().query() - - fun getConnectedDevices(connected: Boolean = true) = realmDatabase().query( - query = "connected = $0", - queryArgs = arrayOf(connected) - ) - - fun connectedDevicesChange( - connected: Boolean, - provideNewState: (List) -> Unit - ) = getConnectedDevices(connected).asClosure(provideNewState) - - fun removeDevices(deviceIds: Set) { - realmDatabase().deleteItems(deviceIds) - } - - fun updateConnectedDevices(listenForTimeInMillis: Long = 5000) { - bluetoothConnector?.let { bluetoothConnector -> - CoroutineScope(Job() + Dispatchers.Default).launch { - realmDatabase().query("connected == true").firstOrNull()?.let { - deviceManager.addPairedDeviceIds(it.toSet()) - } - } - } - } - - override fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) { - } - - override fun didConnectToDevice(bluetoothDevice: BluetoothDevice) { - setConnectionState(bluetoothDevice, true) - } - - override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { - } - - override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { - } - - override fun didDiscoverDevice(device: BluetoothDevice) { - - } - - override fun removeDiscoveredDevice(device: BluetoothDevice) { - } - - override fun isScanning(boolean: Boolean) { - } - - override fun onBluetoothStateChange(bluetoothState: BluetoothState) { - } - - fun shouldConnectToDiscoveredDevice(device: BluetoothDevice, onResult: (Boolean) -> Unit) { - launch { - device.address?.let { address -> - getAllDevicesWithAutoReconnectEnabled().firstOrNull()?.let { - val addresses = it.filter { it.shouldAutomaticallyReconnect && !it.connected } - .mapNotNull { it.address } - Napier.i { "Connected Device List with automatic reconnection: $it" } - if (address in addresses) { - onResult(true) - } else { - onResult(false) - } - } - } ?: kotlin.run { - onResult(false) - } - } - } - - fun resetAll() { - bluetoothConnector?.removeObserver(this) - } + fun pairedDevices() = realmDatabase().query() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/DataPointCountRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/DataPointCountRepository.kt index e443c259..b77e05bc 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.realm.kotlin.ext.query import io.redlink.more.more_app_mutliplatform.database.schemas.DataPointCountSchema -import io.redlink.more.more_app_mutliplatform.util.Scope.launch +import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -25,14 +25,14 @@ class DataPointCountRepository : Repository() { fun incrementCount(scheduleIdSet: Set, addCount: Long = 1) { if (scheduleIdSet.isNotEmpty()) { - launch { + StudyScope.launch { mutex.withLock { realm()?.write { val dataPointCounts = this.query() .find() .filter { it.scheduleId in scheduleIdSet } val dataPointScheduleIds = dataPointCounts.map { it.scheduleId }.toSet() - val (existing, nonExisting) = scheduleIdSet.partition { it in dataPointScheduleIds} + val (existing, nonExisting) = scheduleIdSet.partition { it in dataPointScheduleIds } existing.map { id -> dataPointCounts.firstOrNull { it.scheduleId == id }?.let { it.count += addCount @@ -51,13 +51,17 @@ class DataPointCountRepository : Repository() { } fun get(scheduleId: String): Flow { - return realmDatabase().queryFirst(query = "scheduleId == $0", queryArgs = arrayOf(scheduleId)) + return realmDatabase().queryFirst( + query = "scheduleId == $0", + queryArgs = arrayOf(scheduleId) + ) } fun delete(scheduleId: String) { realm()?.writeBlocking { val existingObject: DataPointCountSchema? = this.query( - query = "scheduleId == $0", scheduleId).first().find() + query = "scheduleId == $0", scheduleId + ).first().find() existingObject?.let { this.delete(it) } 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 be862acf..f67bbded 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 @@ -16,22 +16,20 @@ import io.realm.kotlin.ext.query import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSchema import io.redlink.more.more_app_mutliplatform.extensions.mapAsBulkData import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.DataBulk -import io.redlink.more.more_app_mutliplatform.util.Scope.launch -import kotlinx.coroutines.CoroutineScope +import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.mongodb.kbson.ObjectId -class ObservationDataRepository: Repository() { +class ObservationDataRepository : Repository() { private var queue = mutableSetOf() private val mutex = Mutex() fun addData(dataList: List) { - launch { + StudyScope.launch { mutex.withLock { queue.addAll(dataList) } @@ -43,7 +41,7 @@ class ObservationDataRepository: Repository() { fun store() { if (queue.isNotEmpty()) { - launch { + StudyScope.launch { val queueCopy = mutex.withLock { val queueCopy = queue.toSet() queue = mutableSetOf() @@ -59,12 +57,13 @@ class ObservationDataRepository: Repository() { suspend fun allAsBulk(): DataBulk? { return mutex().withLock { - realmDatabase().query(limit = 5000).firstOrNull()?.mapAsBulkData() + realmDatabase().query(limit = 5000).firstOrNull() + ?.mapAsBulkData() } } fun allAsBulk(completionHandler: (DataBulk?) -> Unit) { - CoroutineScope(Job() + Dispatchers.Default).launch { + StudyScope.launch(Dispatchers.IO) { allAsBulk()?.let { completionHandler(it) } } } @@ -72,12 +71,13 @@ class ObservationDataRepository: Repository() { fun deleteAllWithId(idSet: Set) { Napier.i { "Deleting ${idSet.size} elements..." } val objectIdSet = idSet.map { ObjectId(it) }.toSet() - launch { + StudyScope.launch { mutex().withLock { realm()?.write { - this.query().find().filter { it.dataId in objectIdSet }.forEach { - delete(it) - } + this.query().find().filter { it.dataId in objectIdSet } + .forEach { + delete(it) + } } } } 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 d03ea4ab..a7232777 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 @@ -52,24 +52,24 @@ class ObservationRepository : Repository() { ) } - fun collectAllTimestamps() = observations().transform { emit(it.associate { it.observationType to it.collectionTimestamp }) } + fun collectAllTimestamps() = + observations().transform { emit(it.associate { it.observationType to it.collectionTimestamp }) } fun collectTimestampOfType(type: String, newState: (RealmInstant?) -> Unit): Closeable { return collectionTimestamp(type).asClosure(newState) } fun collectAllTimestamps(newState: (Map) -> Unit): Closeable { - return collectAllTimestamps().transform { emit(it.mapValues { it.value.epochSeconds }) }.asClosure(newState) + return collectAllTimestamps().transform { emit(it.mapValues { it.value.epochSeconds }) } + .asClosure(newState) } fun collectObservationsWithUndoneSchedules(newState: (Map>) -> Unit): Closeable { return observationWithUndoneSchedules().asClosure(newState) } - fun observationTypes(): Flow> { - return observations().transform { observationList -> - emit(observationList.map { it.observationType }.toSet()) - } + fun observationTypes(): Flow> = observations().transform { observationList -> + emit(observationList.map { it.observationType }.toSet()) } fun observationById(observationId: String) = realmDatabase().queryFirst( 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 index 579be788..f8f67ac8 100644 --- 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 @@ -16,15 +16,10 @@ 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 { +abstract class Repository : Closeable { private val database = DatabaseManager private var cache: T? = null - -// init { -// database.open() -// } - fun realm() = database.database.realm fun realmDatabase() = database.database @@ -40,6 +35,5 @@ abstract class Repository : Closeable { } override fun close() { - // database.close() } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ScheduleRepository.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/database/repository/ScheduleRepository.kt index 00668aee..5d8ae17c 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 @@ -22,7 +22,7 @@ import io.redlink.more.more_app_mutliplatform.extensions.firstAsFlow 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.util.Scope.launch +import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.firstOrNull @@ -137,7 +137,7 @@ class ScheduleRepository : Repository() { ) fun updateTaskStates(observationFactory: ObservationFactory, dataRecorder: DataRecorder) { - launch { + StudyScope.launch { updateTaskStatesSync(observationFactory, dataRecorder) } } @@ -168,6 +168,9 @@ class ScheduleRepository : Repository() { } }?.toSet() ?: emptySet() if (activeIds.isNotEmpty()) { + StudyScope.launch { + observationFactory.updateObservationErrors() + } dataRecorder.startMultiple(activeIds) } } 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 6d5a67c5..8a891e5a 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 @@ -35,7 +35,6 @@ data class ScheduleModel( companion object { - fun createModel(schedule: ScheduleSchema): ScheduleModel? { val start = schedule.start ?: return null val end = schedule.end ?: return null 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 affc1884..55dcb2d5 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 @@ -16,6 +16,9 @@ import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationDataSc import io.redlink.more.more_app_mutliplatform.models.ScheduleState import io.redlink.more.more_app_mutliplatform.observations.observationTypes.ObservationType import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -32,6 +35,14 @@ abstract class Observation(val observationType: ObservationType) { protected var lastCollectionTimestamp: Instant = Clock.System.now() + 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) scheduleIds[scheduleId] = observationId @@ -102,7 +113,13 @@ abstract class Observation(val observationType: ObservationType) { protected abstract fun stop(onCompletion: () -> Unit) - abstract fun observerAccessible(): Boolean + fun observerAccessible(): Boolean { + val errors = observerErrors() + this._observationErrors.update { Pair(observationType.observationType, errors) } + return errors.isEmpty() + } + + open fun observerErrors(): Set = emptySet() protected abstract fun applyObservationConfig(settings: Map) 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 5278f7ba..50bc59b1 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 @@ -16,7 +16,9 @@ import io.redlink.more.more_app_mutliplatform.database.repository.ObservationDat import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository 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.flow.cancellable import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.sync.Mutex @@ -47,7 +49,7 @@ abstract class ObservationDataManager { if (scheduleCountJob == null) { listenToDatapointCountChanges() } - Scope.launch(Dispatchers.Default) { + StudyScope.launch(Dispatchers.IO) { mutex.withLock { if (countJob == null) { listenToDatapointCountChanges() @@ -57,7 +59,7 @@ abstract class ObservationDataManager { } } } - Scope.launch { + StudyScope.launch { val now = Clock.System.now().epochSeconds val ids = dataList.map { it.observationId }.toSet() if (observationTimestampJob == null) { @@ -74,7 +76,7 @@ abstract class ObservationDataManager { fun saveAndSend() { Napier.i(tag = "ObservationDataManager::saveAndSend") { "Saving and sending observations" } - Scope.launch { + StudyScope.launch { observationDataRepository.store() observationDataRepository.count().firstOrNull()?.let { if (it in 1 until DATA_COUNT_THRESHOLD) { @@ -94,7 +96,7 @@ abstract class ObservationDataManager { private fun storeCount() { if (scheduleCount.isNotEmpty()) { Napier.i(tag = "ObservationDataManager::storeCount") { "Storing count of observations" } - Scope.launch { + StudyScope.launch { val copiedScheduleCount = mutex.withLock { val copiedScheduleCount = scheduleCount.toMap() scheduleCount = mutableMapOf() @@ -110,7 +112,7 @@ abstract class ObservationDataManager { private fun storeTimestamps() { if (observationCollectionTimestamp.isNotEmpty()) { Napier.d(tag = "ObservationDataManager::storeTimestamps") { "Storing timestamps of observations" } - Scope.launch { + StudyScope.launch { val copiedMap = timeStampMutex.withLock { val copiedMap = observationCollectionTimestamp.toMap() observationCollectionTimestamp = mutableMapOf() 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 e445d5be..04385337 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 @@ -13,38 +13,54 @@ package io.redlink.more.more_app_mutliplatform.observations import io.github.aakira.napier.Napier import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRepository import io.redlink.more.more_app_mutliplatform.extensions.appendAll +import io.redlink.more.more_app_mutliplatform.extensions.asClosure import io.redlink.more.more_app_mutliplatform.extensions.clear +import io.redlink.more.more_app_mutliplatform.extensions.set import io.redlink.more.more_app_mutliplatform.observations.limesurvey.LimeSurveyObservation import io.redlink.more.more_app_mutliplatform.observations.simpleQuestionObservation.SimpleQuestionObservation import io.redlink.more.more_app_mutliplatform.services.notification.NotificationManager import io.redlink.more.more_app_mutliplatform.util.Scope +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.firstOrNull abstract class ObservationFactory(private val dataManager: ObservationDataManager) { val observations = mutableSetOf() - val studyObservationTypes: MutableStateFlow> = MutableStateFlow(emptySet()) + private val _studyObservationTypes: MutableStateFlow> = MutableStateFlow(emptySet()) + val studyObservationTypes: StateFlow> = _studyObservationTypes + private val _observationErrors: MutableStateFlow>> = + MutableStateFlow(emptyMap()) + val observationErrors: StateFlow>> = _observationErrors + + private var observationErrorWatcher: Job? = null init { observations.add(SimpleQuestionObservation()) observations.add(LimeSurveyObservation()) + updateObservationErrors() Scope.launch { ObservationRepository().observationTypes().firstOrNull()?.let { Napier.i(tag = "ObservationFactory::init") { "Observation types fetched: $it" } - studyObservationTypes.appendAll(it) + _studyObservationTypes.clear() + _studyObservationTypes.appendAll(it) } } } fun addNeededObservationTypes(observationTypes: Set) { Napier.i(tag = "ObservationFactory::addNeededObservationTypes") { "Adding observation types to studyObservationTypes: $observationTypes" } - studyObservationTypes.appendAll(observationTypes) + _studyObservationTypes.appendAll(observationTypes) } fun clearNeededObservationTypes() { - studyObservationTypes.clear() + _studyObservationTypes.clear() + observationErrorWatcher?.cancel() + observationErrorWatcher = null } fun studySensorPermissions() = @@ -76,6 +92,19 @@ abstract class ObservationFactory(private val dataManager: ObservationDataManage return autoStartTypes } + fun updateObservationErrors() { + val flowList = studyObservations().map { it.observationErrors } + val combinedFlow = combine(flowList) { values -> + values.toMap() + } + Scope.launch { + combinedFlow.cancellable().collect { + _observationErrors.set(it) + Napier.d(tag = "ObservationFactory::updateObservationErrors") { observationErrors.value.toString() } + } + } + } + fun observation(type: String): Observation? { Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } return observations.firstOrNull { it.observationType.observationType == type }?.apply { @@ -85,4 +114,11 @@ abstract class ObservationFactory(private val dataManager: ObservationDataManage } } } + + 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 50483c3e..52bd2766 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 @@ -18,7 +18,7 @@ import io.redlink.more.more_app_mutliplatform.database.repository.ObservationRep 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.Scope +import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.firstOrNull @@ -41,8 +41,8 @@ class ObservationManager( private var upToDateTimestamps: Map = emptyMap() fun activateScheduleUpdate() { - Napier.i(tag = "ObservationManager::activateScheduleUpdate") { "ObservationManager: ScheduleUpdater activating..."} - Scope.launch { + Napier.i(tag = "ObservationManager::activateScheduleUpdate") { "ObservationManager: ScheduleUpdater activating..." } + StudyScope.launch { scheduleRepository.allSchedulesWithStatus(true).cancellable().collect { list -> if (runningObservations.isNotEmpty()) { list.filter { it.scheduleId.toHexString() in runningObservations.keys } @@ -54,13 +54,13 @@ class ObservationManager( } } val firstCall = ceil(Clock.System.now().toEpochMilliseconds() / 60_000.0).toLong() * 60_000 - Scope.launch { + StudyScope.launch { delay(firstCall - Clock.System.now().toEpochMilliseconds()) - Scope.repeatedLaunch(30000L) { + StudyScope.repeatedLaunch(30000L) { updateTaskStates() } } - Scope.launch { + StudyScope.launch { observationRepository.collectAllTimestamps().cancellable().collect { upToDateTimestamps = it } @@ -168,7 +168,7 @@ class ObservationManager( } fun startObservationType(type: String) { - Scope.launch { + StudyScope.launch { Napier.d(tag = "ObservationManager::startObservationType") { "Restarting Observations with type: $type" } scheduleRepository.allSchedulesWithStatus(false) .firstOrNull() @@ -229,8 +229,8 @@ class ObservationManager( } } - fun collectAllData(onCompletion: (Boolean) -> Unit){ - Scope.launch { + fun collectAllData(onCompletion: (Boolean) -> Unit) { + StudyScope.launch { restartStillRunning() if (!hasRunningTasks()) { onCompletion(true) 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 c0851958..c2f709b9 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,7 +14,6 @@ import io.github.aakira.napier.Napier import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol import io.ktor.http.parametersOf -import io.redlink.more.more_app_mutliplatform.extensions.set 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 @@ -32,10 +31,6 @@ class LimeSurveyObservation : Observation(observationType = LimeSurveyType()) { onCompletion() } - override fun observerAccessible(): Boolean { - return true - } - override fun applyObservationConfig(settings: Map) { val limeSurveyId = settings[LIMESURVEY_ID]?.toString()?.trim('\"') val token = settings[LIMESURVEY_TOKEN]?.toString()?.trim('\"') 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 39716ea8..ff3e9079 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 @@ -13,7 +13,7 @@ package io.redlink.more.more_app_mutliplatform.observations.simpleQuestionObserv 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 : Observation(observationType = SimpleQuestionType()) { override fun start(): Boolean { return true } @@ -22,10 +22,6 @@ class SimpleQuestionObservation : Observation(observationType = SimpleQuestionTy onCompletion() } - override fun observerAccessible(): Boolean { - return true - } - override fun ableToAutomaticallyStart(): Boolean { return false } 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 index b4648a9f..2ee7103f 100644 --- 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 @@ -18,28 +18,30 @@ class BluetoothDevice : RealmObject { var deviceId: String? = null var deviceName: String? = null var address: String? = null - var isConnectable: Boolean = true - var connected: Boolean = false - var shouldAutomaticallyReconnect: Boolean = false - override fun toString(): String { - return "BluetoothDevice {deviceId: $deviceId, name: $deviceName, address: $address, connected: $connected}" + 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, - isConnectable: Boolean = true ): BluetoothDevice { return BluetoothDevice().apply { this.deviceId = deviceId this.deviceName = deviceName this.address = address - this.isConnectable = isConnectable } } } 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 6e116241..553dbd9e 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 @@ -5,54 +5,67 @@ import io.redlink.more.more_app_mutliplatform.extensions.asClosure import io.redlink.more.more_app_mutliplatform.extensions.clear import io.redlink.more.more_app_mutliplatform.extensions.removeAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + object BluetoothDeviceManager { - val connectedDevices: MutableStateFlow> = MutableStateFlow(emptySet()) - val discoveredDevices: MutableStateFlow> = MutableStateFlow(emptySet()) - val pairedDevices: MutableStateFlow> = MutableStateFlow(emptySet()) - val devicesCurrentlyConnecting: MutableStateFlow> = MutableStateFlow( - emptySet() - ) + private val _connectedDevices: MutableStateFlow> = + MutableStateFlow(emptySet()) + val connectedDevices: StateFlow> = _connectedDevices + private val _discoveredDevices: MutableStateFlow> = + MutableStateFlow(emptySet()) + val discoveredDevices: StateFlow> = _discoveredDevices + private val _pairedDevices: MutableStateFlow> = + MutableStateFlow(emptySet()) + val pairedDevices: StateFlow> = _pairedDevices + private val _devicesCurrentlyConnecting: MutableStateFlow> = + MutableStateFlow( + emptySet() + ) + + val devicesCurrentlyConnecting: StateFlow> = _devicesCurrentlyConnecting fun addConnectedDevices(devices: Set) { - connectedDevices.appendAll(devices) + _connectedDevices.appendAll(devices) addPairedDeviceIds(devices) removeDiscoveredDevices(devices) removeConnectingDevices(devices) } fun removeConnectedDevices(devices: Set) { - connectedDevices.removeAll(devices) + _connectedDevices.removeAll(devices) } fun addDiscoveredDevices(devices: Set) { - discoveredDevices.appendAll(devices) + _discoveredDevices.appendAll(devices) } fun removeDiscoveredDevices(devices: Set) { - discoveredDevices.removeAll(devices) + _discoveredDevices.removeAll(devices) } fun addPairedDeviceIds(deviceIds: Set) { - pairedDevices.appendAll(deviceIds) + _pairedDevices.appendAll(deviceIds) } fun removePairedDeviceIds(deviceIds: Set) { - pairedDevices.removeAll(deviceIds) + _pairedDevices.removeAll(deviceIds) } fun addConnectingDevices(devices: Set) { val connectedIds = devices.mapNotNull { it.address }.toSet() - devicesCurrentlyConnecting.appendAll(devices.filter { it.address !in connectedIds }) + _devicesCurrentlyConnecting.appendAll(devices.filter { it.address !in connectedIds }) } fun removeConnectingDevices(devices: Set) { - devicesCurrentlyConnecting.removeAll(devices) + _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) @@ -71,9 +84,13 @@ object BluetoothDeviceManager { } fun resetAll() { - this.connectedDevices.clear() - this.discoveredDevices.clear() - this.devicesCurrentlyConnecting.clear() - this.pairedDevices.clear() + this._connectedDevices.clear() + this._discoveredDevices.clear() + this._devicesCurrentlyConnecting.clear() + } + + fun clearCurrent() { + this._connectedDevices.clear() + this._discoveredDevices.clear() } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/services/network/NetworkService.kt index 2028a4ee..408665bc 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,7 +32,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.CredentialRepository import io.redlink.more.more_app_mutliplatform.services.store.EndpointRepository -import io.redlink.more.more_app_mutliplatform.util.Scope +import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.cancel import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -287,7 +287,7 @@ class NetworkService( data: DataBulk, completionHandler: (Pair, NetworkServiceError?>) -> Unit ) { - Scope.launch { + StudyScope.launch { completionHandler( sendData(data) ) 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 f438ca30..a3b364f2 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 @@ -21,11 +21,7 @@ import io.redlink.more.more_app_mutliplatform.services.network.openapi.model.Obs 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.Scope -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import io.redlink.more.more_app_mutliplatform.util.StudyScope class RegistrationService( private val shared: Shared @@ -36,8 +32,6 @@ class RegistrationService( var participationToken: String? = null private var endpoint: String? = null - private val scope = CoroutineScope(Job() + Dispatchers.Default) - fun getEndpointRepository(): EndpointRepository = shared.endpointRepository fun sendRegistrationToken( @@ -48,7 +42,7 @@ class RegistrationService( onFinish: () -> Unit ) { if (token.isNotEmpty()) { - Scope.launch { + StudyScope.launch { val (result, networkError) = shared.networkService.validateRegistrationToken( token.uppercase(), manualEndpoint @@ -75,7 +69,7 @@ class RegistrationService( onError: ((NetworkServiceError?) -> Unit), onFinish: () -> Unit ) { - Scope.launch { + StudyScope.launch { shared.credentialRepository.remove() shared.endpointRepository.removeEndpoint() DatabaseManager.deleteAll() @@ -103,7 +97,7 @@ class RegistrationService( onError: ((NetworkServiceError?) -> Unit), onFinish: () -> Unit ) { - scope.launch { + StudyScope.launch { val (config, networkError) = shared.networkService.sendConsent( token, studyConsent, 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 a91112fa..e4367d74 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 @@ -22,6 +22,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.util.Scope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext @@ -186,7 +187,7 @@ class NotificationManager( } private fun storeAndUploadToken(newToken: String) { - Scope.launch(Dispatchers.Default) { + Scope.launch(Dispatchers.IO) { networkService.sendNotificationToken(newToken) } } 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 new file mode 100644 index 00000000..0b774bc1 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/util/StudyScope.kt @@ -0,0 +1,68 @@ +package io.redlink.more.more_app_mutliplatform.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext + +object StudyScope { + private val mutex = Mutex() + private val jobs = mutableSetOf() + + fun launch( + coroutineContext: CoroutineContext = Dispatchers.Default, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val result = Scope.launch(coroutineContext, start, block) + Scope.launch { + mutex.withLock { + jobs.add(result.first) + + } + } + result.second.invokeOnCompletion { + Scope.launch { + mutex.withLock { + jobs.remove(result.first) + } + } + } + return result + } + + fun repeatedLaunch( + intervalMillis: Long, + block: suspend CoroutineScope.() -> Unit + ): Pair { + val result = Scope.repeatedLaunch(intervalMillis, block) + Scope.launch { + mutex.withLock { + jobs.add(result.first) + } + } + result.second.invokeOnCompletion { + Scope.launch { + mutex.withLock { + jobs.remove(result.first) + } + } + } + return result + } + + fun cancel(uuid: String) { + Scope.cancel(uuid) + } + + fun cancel(uuids: Collection) { + Scope.cancel(uuids) + } + + fun cancel() { + Scope.cancel(this.jobs) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/CoreViewModel.kt index d7c479ee..babd2900 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 @@ -15,8 +15,6 @@ import io.redlink.more.more_app_mutliplatform.util.Scope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.coroutines.CoroutineContext @@ -24,7 +22,6 @@ import kotlin.coroutines.CoroutineContext abstract class CoreViewModel : Closeable { private val mutex = Mutex() private var viewJobs = mutableSetOf() - private val scope = CoroutineScope(Job() + Dispatchers.Default) abstract fun viewDidAppear() @@ -37,10 +34,17 @@ abstract class CoreViewModel : Closeable { start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ) { - scope.launch{ - val uuid = Scope.launch(coroutineContext, start, block).first + val result = Scope.launch(coroutineContext, start, block) + Scope.launch { mutex.withLock { - viewJobs.add(uuid) + viewJobs.add(result.first) + } + } + result.second.invokeOnCompletion { + Scope.launch { + mutex.withLock { + viewJobs.remove(result.first) + } } } } 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 new file mode 100644 index 00000000..d8bec911 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt @@ -0,0 +1,42 @@ +package io.redlink.more.more_app_mutliplatform.viewModels + +import io.redlink.more.more_app_mutliplatform.extensions.asClosure +import io.redlink.more.more_app_mutliplatform.extensions.set +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object ViewManager { + private val _studyIsUpdating = MutableStateFlow(false) + private val _showBluetoothView = MutableStateFlow(false) + val studyIsUpdating: StateFlow = _studyIsUpdating + + val showBluetoothView: StateFlow = _showBluetoothView + + private var bleViewOpen = false + + + fun studyIsUpdating(state: Boolean) { + _studyIsUpdating.set(state) + } + + fun showBLEView(state: Boolean): Boolean { + if (!state || !bleViewOpen) { + _showBluetoothView.set(state) + return state + } + return false + } + + fun bleViewOpen(state: Boolean) { + bleViewOpen = state + } + + fun showBluetoothViewAsClosure(state: (Boolean) -> Unit) = showBluetoothView.asClosure(state) + + fun studyIsUpdatingAsClosure(state: (Boolean) -> Unit) = studyIsUpdating.asClosure(state) + + fun resetAll() { + _studyIsUpdating.set(false) + _showBluetoothView.set(false) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt index 854ee8fb..b455b874 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 @@ -17,16 +17,15 @@ import io.redlink.more.more_app_mutliplatform.extensions.anyNameIn import io.redlink.more.more_app_mutliplatform.extensions.areAllNamesIn import io.redlink.more.more_app_mutliplatform.extensions.asClosure import io.redlink.more.more_app_mutliplatform.extensions.set -import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnector import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothConnectorObserver import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothState import io.redlink.more.more_app_mutliplatform.util.Scope -import io.redlink.more.more_app_mutliplatform.util.Scope.launch -import io.redlink.more.more_app_mutliplatform.util.Scope.repeatedLaunch +import io.redlink.more.more_app_mutliplatform.util.StudyScope import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel +import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull @@ -36,9 +35,8 @@ class BluetoothController( private val scanDuration: Long = 10000, private val scanInterval: Long = 5000 ) : CoreViewModel(), BluetoothConnectorObserver, Closeable { - val showBluetoothView = MutableStateFlow(false); private val deviceManager = BluetoothDeviceManager - private val bluetoothDeviceRepository = BluetoothDeviceRepository(bluetoothConnector) + private val bluetoothDeviceRepository = BluetoothDeviceRepository() val isScanning = MutableStateFlow(false) @@ -49,6 +47,8 @@ class BluetoothController( val bluetoothPower = MutableStateFlow(BluetoothState.ON) + private var bleViewHasBeenOpened = false + init { bluetoothConnector.addObserver(this) bluetoothConnector.replayStates() @@ -65,43 +65,34 @@ class BluetoothController( enableBackgroundScanner() } } else { - showBluetoothView.value = true + if (!bleViewHasBeenOpened) { + bleViewHasBeenOpened = ViewManager.showBLEView(true) + } } return false } - fun observationDeviceConnector(observationFactory: ObservationFactory) { - val neededBLEDeviceIdentifier = observationFactory.bleDevicesNeeded() + fun startScanningForDevices(bleDeviceSet: Set) { val pairedDevices = deviceManager.pairedDevices.value - if (neededBLEDeviceIdentifier.isNotEmpty() - && neededBLEDeviceIdentifier.any { name -> - pairedDevices.any { - it.deviceName?.contains( - name - ) == true - } - } - ) { - launch { - deviceManager.connectedDevices.collect { connectedDevices -> - if (!neededBLEDeviceIdentifier.areAllNamesIn(connectedDevices)) { - enableBackgroundScanner() - } - } - } - launch { - deviceManager.discoveredDevices.collect { discoveredDevices -> - + if (bleDeviceSet.isNotEmpty()) { + if (bleDeviceSet.areAllNamesIn(pairedDevices)) { + val connectedDevices = deviceManager.connectedDevices.value + if (bleDeviceSet.areAllNamesIn(connectedDevices)) { + disableBackgroundScanner() + } else { + enableBackgroundScanner(); } + } else { + ViewManager.showBLEView(true) } } } - fun enableBackgroundScanner() { + private fun enableBackgroundScanner() { if (!backgroundScanningEnabled) { backgroundScanningEnabled = true - Scope.launch { - if (!viewActive && bluetoothDeviceRepository.getDevices().firstOrNull() + StudyScope.launch { + if (!viewActive && bluetoothDeviceRepository.pairedDevices().firstOrNull() ?.isNotEmpty() == true ) { delay(2000) @@ -111,7 +102,7 @@ class BluetoothController( } } - fun disableBackgroundScanner() { + private fun disableBackgroundScanner() { backgroundScanningEnabled = false if (!viewActive) { stopPeriodicScan() @@ -144,16 +135,18 @@ class BluetoothController( override fun viewDidDisappear() { super.viewDidDisappear() viewActive = false - scanJob?.let { Scope.cancel(it) } + scanJob?.let { StudyScope.cancel(it) } scanJob = null stopPeriodicScan() if (backgroundScanningEnabled) { - Scope.launch { + StudyScope.launch { delay(10000L) if (backgroundScanningEnabled) { periodicScan(BACKGROUND_SCAN_DURATION, BACKGROUND_SCAN_INTERVAL) } } + } else { + deviceManager.clearCurrent() } } @@ -163,7 +156,7 @@ class BluetoothController( ) { if (scanJob == null) { Napier.i(tag = "BluetoothController::startPeriodicScan") { "Starting period scanner with Duration= $customScanDuration; Interval= $customScanInterval" } - scanJob = repeatedLaunch(customScanInterval) { + scanJob = StudyScope.repeatedLaunch(customScanInterval) { Napier.i(tag = "BluetoothController::startPeriodicScan") { "Scanning..." } scanForDevices() delay(customScanDuration) @@ -173,39 +166,32 @@ class BluetoothController( } } - fun stopPeriodicScan() { + private fun stopPeriodicScan() { Napier.i(tag = "BluetoothController::stopPeriodicScan") { "Stopping period scanner!" } - scanJob?.let { Scope.cancel(it) } + scanJob?.let { StudyScope.cancel(it) } scanJob = null bluetoothConnector.stopScanning() } - fun scanForDevices() { + private fun scanForDevices() { bluetoothConnector.scan() } fun stopScanning() { bluetoothConnector.stopScanning() - deviceManager.foreachConnectedDevice { - didDisconnectFromDevice(it) - } - deviceManager.foreachDiscoveredDevice { - removeDiscoveredDevice(it) - } } fun connectToDevice(device: BluetoothDevice): Boolean { Napier.i(tag = "BluetoothController::connectToDevice") { "Connecting to $device" } deviceManager.addConnectingDevices(setOf(device)) - val hasError = bluetoothConnector.connect(device) == null - return hasError + return bluetoothConnector.connect(device) == null } - fun disconnectFromDevice(device: BluetoothDevice) { + fun unpairFromDevice(device: BluetoothDevice) { Napier.i(tag = "BluetoothController::disconnectFromDevice") { "Disconnecting from $device" } deviceManager.removeConnectedDevices(setOf(device)) bluetoothConnector.disconnect(device) - bluetoothDeviceRepository.setAutoReconnect(device, false) + bluetoothDeviceRepository.unpairDevice(device) } override fun isConnectingToDevice(bluetoothDevice: BluetoothDevice) { @@ -214,33 +200,32 @@ class BluetoothController( override fun didConnectToDevice(bluetoothDevice: BluetoothDevice) { deviceManager.addConnectedDevices(setOf(bluetoothDevice)) - bluetoothDeviceRepository.setConnectionState(bluetoothDevice, true) + bluetoothDeviceRepository.storePairedDevice(bluetoothDevice) } override fun didDisconnectFromDevice(bluetoothDevice: BluetoothDevice) { Napier.i(tag = "BluetoothController::didDisconnectFromDevice") { "Disconnected from $bluetoothDevice" } deviceManager.removeConnectedDevices(setOf(bluetoothDevice)) - bluetoothDeviceRepository.setConnectionState(bluetoothDevice, false) } override fun didFailToConnectToDevice(bluetoothDevice: BluetoothDevice) { Napier.e(tag = "BluetoothController::didFailToConnectToDevice") { "Failed to connect to $bluetoothDevice" } deviceManager.removeConnectingDevices(setOf(bluetoothDevice)) - bluetoothDeviceRepository.setConnectionState(bluetoothDevice, false) } override fun onBluetoothStateChange(bluetoothState: BluetoothState) { Napier.i(tag = "BluetoothController::onBluetoothStateChange") { "Bluetooth state changed to $bluetoothState" } bluetoothPower.set(bluetoothState) + if (bluetoothState == BluetoothState.OFF) { + deviceManager.clearCurrent() + } } override fun didDiscoverDevice(device: BluetoothDevice) { Napier.i(tag = "BluetoothController::didDiscoverDevice") { "Discovered device: $device" } deviceManager.addDiscoveredDevices(setOf(device)) - bluetoothDeviceRepository.shouldConnectToDiscoveredDevice(device) { - if (it) { - connectToDevice(device) - } + if (deviceManager.pairedDevices.value.contains(device)) { + connectToDevice(device) } } @@ -266,7 +251,6 @@ class BluetoothController( backgroundScanningEnabled = false viewActive = false deviceManager.resetAll() - bluetoothDeviceRepository.resetAll() scanJob = null } 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 a5dfdbd8..bc31a2d8 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 @@ -25,10 +25,10 @@ import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.plus -class CoreDashboardFilterViewModel: CoreViewModel() { +class CoreDashboardFilterViewModel : CoreViewModel() { val currentTypeFilter = MutableStateFlow(emptyMap()) val currentDateFilter = MutableStateFlow( - DateFilterModel.values().associateWith { it == DateFilterModel.ENTIRE_TIME }) + DateFilterModel.entries.associateWith { it == DateFilterModel.ENTIRE_TIME }) init { Scope.launch { @@ -37,6 +37,7 @@ class CoreDashboardFilterViewModel: CoreViewModel() { } } } + override fun viewDidAppear() { } @@ -106,8 +107,11 @@ class CoreDashboardFilterViewModel: CoreViewModel() { return schedules } - fun onNewTypeFilter(provideNewState: (Map) -> Unit) = currentTypeFilter.asClosure(provideNewState) + fun onNewTypeFilter(provideNewState: (Map) -> Unit) = + currentTypeFilter.asClosure(provideNewState) - fun onNewDateFilter(provideNewState: (Map) -> Unit) = currentDateFilter.transform { emit(it.mapKeys { it.key.asDataClass() }) }.asClosure(provideNewState) + fun onNewDateFilter(provideNewState: (Map) -> Unit) = + currentDateFilter.transform { emit(it.mapKeys { it.key.asDataClass() }) } + .asClosure(provideNewState) } \ 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 a6f71e07..fcffe3c2 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 @@ -17,10 +17,11 @@ import io.redlink.more.more_app_mutliplatform.models.NotificationModel import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import kotlinx.coroutines.flow.MutableStateFlow -class CoreNotificationFilterViewModel: CoreViewModel() { +class CoreNotificationFilterViewModel : CoreViewModel() { private var highPriority: Long = 2 val filters = MutableStateFlow>(mapOf()) + init { val map = getEnumAsList().associateWith { false }.toMutableMap() map[NotificationFilterTypeModel.ALL] = true @@ -32,12 +33,10 @@ class CoreNotificationFilterViewModel: CoreViewModel() { if (filter == NotificationFilterTypeModel.ALL) { filterMap = filterMap.mapValues { false }.toMutableMap() filterMap[NotificationFilterTypeModel.ALL] = true - } - else if (filterMap[NotificationFilterTypeModel.ALL] == true){ + } else if (filterMap[NotificationFilterTypeModel.ALL] == true) { filterMap[NotificationFilterTypeModel.ALL] = false filterMap[filter] = true - } - else if (filterMap[filter] == true) { + } else if (filterMap[filter] == true) { if (filterMap.values.filter { it }.size == 1) { filterMap = filterMap.mapValues { false }.toMutableMap() filterMap[NotificationFilterTypeModel.ALL] = true @@ -61,15 +60,15 @@ class CoreNotificationFilterViewModel: CoreViewModel() { fun applyFilter(notificationList: List): List { return if (filterActive()) { notificationList.filter { notification -> - if (filters.value[NotificationFilterTypeModel.IMPORTANT] == true) { - notification.priority == highPriority - } else { - true - } && if (filters.value[NotificationFilterTypeModel.UNREAD] == true) { - !notification.read - } else { - true - } + if (filters.value[NotificationFilterTypeModel.IMPORTANT] == true) { + notification.priority == highPriority + } else { + true + } && if (filters.value[NotificationFilterTypeModel.UNREAD] == true) { + !notification.read + } else { + true + } } } else notificationList } @@ -77,10 +76,11 @@ class CoreNotificationFilterViewModel: CoreViewModel() { fun filterActive() = filters.value[NotificationFilterTypeModel.ALL] == false private fun getEnumAsList(): List { - return NotificationFilterTypeModel.values().toList() + return NotificationFilterTypeModel.entries } fun getActiveTypes() = filters.value.filter { it.value }.map { it.key.type }.toSet() - fun onFilterChange(provideNewstate: (Map) -> Unit) = filters.asClosure(provideNewstate) + 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/schedules/CoreScheduleViewModel.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/schedules/CoreScheduleViewModel.kt index 38d5df92..570b8d97 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,6 +13,7 @@ package io.redlink.more.more_app_mutliplatform.viewModels.schedules import io.redlink.more.more_app_mutliplatform.database.repository.ScheduleRepository import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema import io.redlink.more.more_app_mutliplatform.extensions.asClosure +import io.redlink.more.more_app_mutliplatform.extensions.set 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 @@ -21,6 +22,7 @@ import io.redlink.more.more_app_mutliplatform.observations.DataRecorder import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine import kotlinx.datetime.Clock @@ -28,12 +30,12 @@ import kotlinx.datetime.Clock class CoreScheduleViewModel( private val dataRecorder: DataRecorder, private val scheduleListType: ScheduleListType, - private val coreFilterModel: CoreDashboardFilterViewModel + private val coreFilterModel: CoreDashboardFilterViewModel, ) : CoreViewModel() { private val scheduleRepository = ScheduleRepository() private var originalScheduleList = emptySet() - val scheduleListState = MutableStateFlow( + private val _scheduleListState = MutableStateFlow( Triple( emptySet(), emptySet(), @@ -41,6 +43,9 @@ class CoreScheduleViewModel( ) ) + val scheduleListState: StateFlow, Set, Set>> = + _scheduleListState + init { launchScope { coreFilterModel.currentTypeFilter @@ -81,10 +86,6 @@ class CoreScheduleViewModel( } } - override fun viewDidDisappear() { - super.viewDidDisappear() - } - fun start(scheduleId: String) { dataRecorder.start(scheduleId) } @@ -97,7 +98,7 @@ class CoreScheduleViewModel( dataRecorder.stop(scheduleId) } - private suspend fun updateList(newList: Set) { + private fun updateList(newList: Set) { val oldIds = originalScheduleList.map { it.scheduleId }.toSet() val newIds = newList.map { it.scheduleId }.toSet() val addedIds = newIds - oldIds @@ -123,7 +124,7 @@ class CoreScheduleViewModel( } if (added.isNotEmpty() || removedIds.isNotEmpty() || updated.isNotEmpty()) { - scheduleListState.emit(Triple(added, removedIds, updated)) + _scheduleListState.set(Triple(added, removedIds, updated)) } originalScheduleList = newList.toSet() } 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 8e0602cb..9705f880 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 @@ -37,7 +37,7 @@ class CoreBluetoothViewModel( } fun disconnectFromDevice(device: BluetoothDevice) { - coreBluetooth.disconnectFromDevice(device) + coreBluetooth.unpairFromDevice(device) } fun devicesNeededChange(providedState: (Set) -> Unit) =