diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt index c7f69742..6d289bea 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/NavigationScreen.kt @@ -81,7 +81,8 @@ enum class NavigationScreen( NavType.StringType, "" ) ), stringResource = R.string.nav_limesurvey - ); + ), + OBSERVATION_ERRORS("observation-errors", stringResource = R.string.nav_observation_errors); private var cachedNavArguments: List? = null private var cachedRoute: String? = null @@ -172,6 +173,8 @@ enum class NavigationScreen( fun byRoute(route: String) = entries.firstOrNull { it.route == route } - fun allDeepLinks(deepLinkHost: String) = entries.flatMap { it.createDeepLinkRoute(deepLinkHost).mapNotNull { it.uriPattern } }.toSet() + fun allDeepLinks(deepLinkHost: String) = + entries.flatMap { it.createDeepLinkRoute(deepLinkHost).mapNotNull { it.uriPattern } } + .toSet() } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt index b0686014..36cd4e67 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/bluetooth/BluetoothActivity.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.redlink.more.app.android.R import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.app.android.services.sensorsListener.GPSStateListener import io.redlink.more.app.android.shared_composables.BasicText import io.redlink.more.app.android.shared_composables.EmptyListView @@ -53,6 +54,7 @@ import io.redlink.more.app.android.ui.theme.MoreColors class BLEConnectionActivity : ComponentActivity() { val viewModel = BluetoothViewModel() private var gpsListenerSelfActivated = false + private var bluetoothListenerSelfActivated = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -65,6 +67,10 @@ class BLEConnectionActivity : ComponentActivity() { override fun onResume() { super.onResume() viewModel.viewDidAppear() + if (!BluetoothStateListener.listenerActive) { + bluetoothListenerSelfActivated = true + BluetoothStateListener.startListening(this) + } if (!GPSStateListener.listenerActive) { gpsListenerSelfActivated = true GPSStateListener.startListening(this) @@ -78,6 +84,10 @@ class BLEConnectionActivity : ComponentActivity() { gpsListenerSelfActivated = false GPSStateListener.stopListening(this) } + if (bluetoothListenerSelfActivated) { + bluetoothListenerSelfActivated = false + BluetoothStateListener.stopListening(this) + } } companion object { @@ -198,11 +208,11 @@ fun LoginBLESetupView(viewModel: BluetoothViewModel, showDescrPart2: Boolean) { } else { itemsIndexed(viewModel.discoveredDevices) { _, device -> Column( - horizontalAlignment = Alignment.Start, + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() - .height(55.dp) + .height(60.dp) .clickable { viewModel.connectToDevice(device) } @@ -210,7 +220,8 @@ fun LoginBLESetupView(viewModel: BluetoothViewModel, showDescrPart2: Boolean) { MoreDivider() Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxSize() ) { SmallTitle( text = device.deviceName @@ -218,10 +229,11 @@ fun LoginBLESetupView(viewModel: BluetoothViewModel, showDescrPart2: Boolean) { ) if (device.address in viewModel.connectingDevices) { CircularProgressIndicator( - strokeWidth = 1.dp, + strokeWidth = 2.dp, + color = MoreColors.Primary, modifier = Modifier - .width(20.dp) - .height(20.dp) + .width(progressSize) + .height(progressSize) ) } } 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 56a540c8..ea922ccf 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,12 +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.app.android.services.sensorsListener.BluetoothStateListener import io.redlink.more.app.android.services.sensorsListener.GPSStateListener import io.redlink.more.more_app_mutliplatform.AlertController import io.redlink.more.more_app_mutliplatform.models.AlertDialogModel import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDevice import io.redlink.more.more_app_mutliplatform.services.bluetooth.BluetoothDeviceManager -import io.redlink.more.more_app_mutliplatform.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 @@ -36,7 +36,7 @@ class BluetoothViewModel : ViewModel() { val connectedDevices = mutableStateListOf() val connectingDevices = mutableStateListOf() val isScanning = mutableStateOf(false) - val bluetoothPowerState = mutableStateOf(false) + val bluetoothPowerState = mutableStateOf(BluetoothStateListener.bluetoothEnabled.value) val neededDevices = mutableStateListOf() @@ -60,40 +60,52 @@ class BluetoothViewModel : ViewModel() { } viewModelScope.launch(Dispatchers.IO) { BluetoothDeviceManager.discoveredDevices.collect { - discoveredDevices.clear() - discoveredDevices.addAll(it) + withContext(Dispatchers.Main) { + discoveredDevices.clear() + discoveredDevices.addAll(it) + } } } - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { BluetoothDeviceManager.connectedDevices.collect { - connectedDevices.clear() - connectedDevices.addAll(it) + withContext(Dispatchers.Main) { + connectedDevices.clear() + connectedDevices.addAll(it) + } } } - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { coreBluetoothViewModel.coreBluetooth.isScanning.collect { - isScanning.value = it + withContext(Dispatchers.Main) { + isScanning.value = it + } } } - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { coreBluetoothViewModel.devicesNeededToConnectTo.collect { - neededDevices.clear() - neededDevices.addAll(MoreApplication.shared!!.observationFactory.bleDevicesNeeded()) + withContext(Dispatchers.Main) { + neededDevices.clear() + neededDevices.addAll(MoreApplication.shared!!.observationFactory.bleDevicesNeeded()) + } } } - viewModelScope.launch { - coreBluetoothViewModel.coreBluetooth.bluetoothPower.collect { - bluetoothPowerState.value = it == BluetoothState.ON + viewModelScope.launch(Dispatchers.IO) { + BluetoothStateListener.bluetoothEnabled.collect { + withContext(Dispatchers.Main) { + bluetoothPowerState.value = it + } } } - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { BluetoothDeviceManager.devicesCurrentlyConnecting.collect { - connectingDevices.clear() - connectingDevices.addAll(it.mapNotNull { it.address }) + withContext(Dispatchers.Main) { + connectingDevices.clear() + connectingDevices.addAll(it.mapNotNull { it.address }) + } } } } 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 d5d524de..0b5e0455 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 @@ -21,6 +21,7 @@ import io.redlink.more.app.android.extensions.jvmLocalDate import io.redlink.more.app.android.observations.HR.PolarHeartRateObservation import io.redlink.more.more_app_mutliplatform.models.ScheduleListType import io.redlink.more.more_app_mutliplatform.models.ScheduleModel +import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.viewModels.dashboard.CoreDashboardFilterViewModel import io.redlink.more.more_app_mutliplatform.viewModels.schedules.CoreScheduleViewModel import kotlinx.coroutines.Dispatchers @@ -44,6 +45,7 @@ class ScheduleViewModel( val schedulesByDate = mutableStateMapOf>() val observationErrors = mutableStateMapOf>() + val observationErrorActions = mutableStateMapOf>() val filterModel = DashboardFilterViewModel(coreDashboardFilterViewModel) @@ -52,9 +54,17 @@ class ScheduleViewModel( init { viewModelScope.launch(Dispatchers.IO) { MoreApplication.shared!!.observationFactory.observationErrors.collect { + val actions = it.mapValues { entry -> + entry.value.filter { it == Observation.ERROR_DEVICE_NOT_CONNECTED }.toSet() + } + val errors = it.mapValues { entry -> + entry.value.filter { it != Observation.ERROR_DEVICE_NOT_CONNECTED }.toSet() + } withContext(Dispatchers.Main) { observationErrors.clear() - observationErrors.putAll(it) + observationErrors.putAll(errors) + observationErrorActions.clear() + observationErrorActions.putAll(actions) } } } @@ -104,6 +114,9 @@ class ScheduleViewModel( coreViewModel.stop(scheduleId) } + fun numberOfObservationErrors(): Int = observationErrors.values.flatten().toSet().count() + .let { if (it > 0) it else observationErrorActions.values.flatten().toSet().count() } + private fun mergeSchedules( first: Set, 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 deff5987..e2133570 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListItem.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/dashboard/schedule/list/ScheduleListItem.kt @@ -23,7 +23,6 @@ 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 @@ -49,8 +48,6 @@ fun ScheduleListItem( viewModel: ScheduleViewModel, showButton: Boolean ) { - val observationErrors = - remember { viewModel.observationErrors[scheduleModel.observationType] ?: emptySet() } Column( verticalArrangement = Arrangement.SpaceEvenly, modifier = Modifier @@ -80,8 +77,10 @@ fun ScheduleListItem( ) { BasicText(text = scheduleModel.observationType, color = MoreColors.Secondary) Row(horizontalArrangement = Arrangement.End) { - if (observationErrors.isNotEmpty()) { - BasicText(text = "${observationErrors.count()}") + if ((viewModel.observationErrors[scheduleModel.observationType]?.count() + ?: 0) > 0 + ) { + BasicText(text = "${viewModel.observationErrors[scheduleModel.observationType]?.count() ?: 0}") Icon( Icons.Default.Warning, contentDescription = null, diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainActivity.kt index fcd59828..e09c79c1 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 @@ -40,6 +40,7 @@ import io.redlink.more.app.android.activities.dashboard.filter.DashboardFilterVi import io.redlink.more.app.android.activities.info.InfoView import io.redlink.more.app.android.activities.notification.NotificationView import io.redlink.more.app.android.activities.notification.filter.NotificationFilterView +import io.redlink.more.app.android.activities.observationErrors.ObservationErrorView import io.redlink.more.app.android.activities.observations.questionnaire.QuestionnaireResponseView import io.redlink.more.app.android.activities.observations.questionnaire.QuestionnaireView import io.redlink.more.app.android.activities.runningSchedules.RunningSchedulesView @@ -440,6 +441,19 @@ fun MainView( LeaveStudyConfirmView(navController, viewModel = viewModel.leaveStudyViewModel) } } + + NavigationScreen.OBSERVATION_ERRORS.let { screen -> + composable( + screen.routeWithParameters(), + screen.createListOfNavArguments(), + screen.createDeepLinkRoute() + ) { + viewModel.navigationBarTitle.value = + screen.stringRes() + viewModel.showBackButton.value = true + ObservationErrorView() + } + } } } } \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/main/MainViewModel.kt index ba76ec38..72d16682 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 @@ -131,10 +131,6 @@ class MainViewModel(context: Context) : ViewModel() { } } - private fun showBLESetup() { - MoreApplication.shared!!.showBLESetupOnFirstStartup() - } - fun getTaskDetailsVM(scheduleId: String) = taskDetailsViewModel.apply { setSchedule(scheduleId) } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorListView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorListView.kt new file mode 100644 index 00000000..5c8024be --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorListView.kt @@ -0,0 +1,85 @@ +package io.redlink.more.app.android.activities.observationErrors + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Watch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.bluetooth.BLEConnectionActivity +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.extensions.getStringResourceByName +import io.redlink.more.app.android.extensions.showNewActivity +import io.redlink.more.app.android.shared_composables.BasicText +import io.redlink.more.app.android.shared_composables.SmallTextIconButton +import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.more_app_mutliplatform.observations.Observation + +@Composable +fun ObservationErrorListView( + errors: SnapshotStateList, + errorActions: SnapshotStateList +) { + val context = LocalContext.current + if (errors.isNotEmpty()) { + LazyColumn( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + items(errors.toList()) { error -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Error", + tint = MoreColors.Important, + modifier = Modifier.padding(end = 4.dp) + ) + BasicText( + text = "${getStringResourceByName(error)}!", + fontSize = 16.sp, + color = MoreColors.Important + ) + } + } + if (errorActions.isNotEmpty()) { + item { + if (errorActions.contains(Observation.ERROR_DEVICE_NOT_CONNECTED)) { + SmallTextIconButton( + text = NavigationScreen.BLUETOOTH_CONNECTION.stringRes(), + imageText = getStringResource(id = R.string.more_ble_icon_description), + image = Icons.Default.Watch, + imageTint = MoreColors.White + ) { + (context as? Activity)?.let { + showNewActivity(it, BLEConnectionActivity::class.java) + } + } + } + } + } + } + Divider(thickness = 1.dp) + } +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorView.kt new file mode 100644 index 00000000..68747937 --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorView.kt @@ -0,0 +1,15 @@ +package io.redlink.more.app.android.activities.observationErrors + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +fun ObservationErrorView() { + val viewModel = remember { + ObservationErrorViewModel() + } + ObservationErrorListView( + errors = viewModel.observationErrors, + errorActions = viewModel.observationErrorActions + ) +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt new file mode 100644 index 00000000..002c4e8e --- /dev/null +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/observationErrors/ObservationErrorViewModel.kt @@ -0,0 +1,34 @@ +package io.redlink.more.app.android.activities.observationErrors + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.aakira.napier.Napier +import io.redlink.more.app.android.MoreApplication +import io.redlink.more.more_app_mutliplatform.observations.Observation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ObservationErrorViewModel : ViewModel() { + val observationErrors = mutableStateListOf() + val observationErrorActions = mutableStateListOf() + + init { + viewModelScope.launch(Dispatchers.IO) { + MoreApplication.shared!!.observationFactory.observationErrors.collect { + Napier.d { it.toString() } + val (actions, errors) = it.values.flatten().toSet() + .partition { it == Observation.ERROR_DEVICE_NOT_CONNECTED } + withContext(Dispatchers.Main) { + observationErrors.clear() + observationErrors.addAll(errors) + observationErrorActions.clear() + observationErrorActions.addAll(actions) + } + } + } + } + + +} \ No newline at end of file diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt index 24dbf6ac..bcb53fa0 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsView.kt @@ -18,12 +18,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.TabRowDefaults.Divider import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.rounded.Square import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -31,11 +27,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.navigation.NavController import io.redlink.more.app.android.R import io.redlink.more.app.android.activities.NavigationScreen +import io.redlink.more.app.android.activities.observationErrors.ObservationErrorListView import io.redlink.more.app.android.extensions.getStringResource import io.redlink.more.app.android.extensions.jvmLocalDate import io.redlink.more.app.android.extensions.jvmLocalDateTime @@ -64,6 +61,7 @@ fun TaskDetailsView( val backStackEntry = remember { navController.currentBackStackEntry } val route = backStackEntry?.arguments?.getString(NavigationScreen.SCHEDULE_DETAILS.routeWithParameters()) + val context = LocalContext.current LaunchedEffect(route) { viewModel.viewDidAppear() } @@ -152,29 +150,12 @@ fun TaskDetailsView( } Spacer(modifier = Modifier.height(20.dp)) - if (viewModel.taskObservationErrors.isNotEmpty()) { - LazyColumn { - items(viewModel.taskObservationErrors.toList()) { error -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(8.dp) - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = "Error", - tint = MoreColors.Important, - modifier = Modifier.padding(end = 4.dp) - ) - BasicText( - text = error, - fontSize = 16.sp, - color = MoreColors.Important - ) - } - } - } - Divider(thickness = 1.dp) - } + + ObservationErrorListView( + errors = viewModel.taskObservationErrors, + errorActions = viewModel.taskObservationErrorActions + ) + if (!viewModel.taskDetailsModel.value.hidden) { SmallTextButton( text = if (viewModel.taskDetailsModel.value.state == ScheduleState.RUNNING) getStringResource( diff --git a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt index 3808aaa9..d4b9aba8 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/activities/tasks/TaskDetailsViewModel.kt @@ -19,6 +19,7 @@ import io.redlink.more.app.android.observations.HR.PolarHeartRateObservation import io.redlink.more.more_app_mutliplatform.models.ScheduleState import io.redlink.more.more_app_mutliplatform.models.TaskDetailsModel import io.redlink.more.more_app_mutliplatform.observations.DataRecorder +import io.redlink.more.more_app_mutliplatform.observations.Observation import io.redlink.more.more_app_mutliplatform.viewModels.tasks.CoreTaskDetailsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -38,6 +39,7 @@ class TaskDetailsViewModel( ) val taskObservationErrors = mutableStateListOf() + val taskObservationErrorActions = mutableStateListOf() private var observationErrors: Map> = emptyMap() init { @@ -56,8 +58,11 @@ class TaskDetailsViewModel( isEnabled.value = it.state.active() withContext(Dispatchers.Main) { taskObservationErrors.clear() + taskObservationErrorActions.clear() observationErrors[taskDetailsModel.value.observationType]?.let { - taskObservationErrors.addAll(it) + val (actions, messages) = it.partition { it == Observation.ERROR_DEVICE_NOT_CONNECTED } + taskObservationErrors.addAll(messages) + taskObservationErrorActions.addAll(actions) } } } @@ -77,8 +82,11 @@ class TaskDetailsViewModel( observationErrors = it if (taskDetailsModel.value.observationType != "") { taskObservationErrors.clear() + taskObservationErrorActions.clear() observationErrors[taskDetailsModel.value.observationType]?.let { - taskObservationErrors.addAll(it) + val (actions, messages) = it.partition { it == Observation.ERROR_DEVICE_NOT_CONNECTED } + taskObservationErrors.addAll(messages) + taskObservationErrorActions.addAll(actions) } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt index ebe54ae4..1c2d2727 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/extensions/ComposableExtensions.kt @@ -35,6 +35,17 @@ import androidx.compose.ui.res.painterResource fun getStringResource(@StringRes id: Int): String = LocalContext.current.resources.getText(id).toString() +@Composable +@ReadOnlyComposable +fun getStringResourceByName(name: String): String { + val resourceId = LocalContext.current.resources.getIdentifier( + name, + "string", + LocalContext.current.packageName + ) + return getStringResource(resourceId) +} + @Composable @ReadOnlyComposable fun color(@ColorRes id: Int) = colorResource(id = id) @@ -59,7 +70,12 @@ fun Image( colorFilter ) -fun showNewActivityAndClearStack(context: Context, cls: Class<*>, forwardExtras: Boolean = false, forwardDeepLink: Boolean = false) { +fun showNewActivityAndClearStack( + context: Context, + cls: Class<*>, + forwardExtras: Boolean = false, + forwardDeepLink: Boolean = false +) { (context as? Activity)?.let { val intent = Intent(context, cls) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK diff --git a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt index 9abfdc96..0ef9f2bb 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/observations/AndroidObservationFactory.kt @@ -22,6 +22,7 @@ import io.redlink.more.more_app_mutliplatform.observations.ObservationDataManage import io.redlink.more.more_app_mutliplatform.observations.ObservationFactory import io.redlink.more.more_app_mutliplatform.util.Scope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class AndroidObservationFactory(context: Context, observationDataManager: ObservationDataManager) : ObservationFactory(observationDataManager) { @@ -34,6 +35,22 @@ class AndroidObservationFactory(context: Context, observationDataManager: Observ ) ) + Scope.launch(Dispatchers.IO) { + GPSStateListener.gpsEnabled.collect { + withContext(Dispatchers.Main) { + super.updateObservationErrors() + } + } + } + + Scope.launch(Dispatchers.IO) { + BluetoothStateListener.bluetoothEnabled.collect { + withContext(Dispatchers.Main) { + super.updateObservationErrors() + } + } + } + Scope.launch(Dispatchers.IO) { super.studyObservationTypes.collect { studyObservationTypes -> val permissions = @@ -41,6 +58,7 @@ class AndroidObservationFactory(context: Context, observationDataManager: Observ .flatMap { it.observationType.sensorPermissions }.toSet() if (permissions.contains(Manifest.permission.ACCESS_COARSE_LOCATION) || permissions.contains(Manifest.permission.ACCESS_FINE_LOCATION) + || permissions.contains(Manifest.permission.BLUETOOTH_SCAN) ) { GPSStateListener.startListening(context) } else { 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 b3b28627..b95131ec 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 @@ -53,7 +53,6 @@ class GPSObservation( } return true } - showPermissionAlertDialog() return false } @@ -65,13 +64,14 @@ class GPSObservation( override fun observerErrors(): Set { val errors = mutableSetOf() if (locationManager == null) { - errors.add("Location Services return an unknown error!") + errors.add("error_location_services") } if (!GPSStateListener.gpsEnabled.value) { - errors.add("Location Servies are disabled!") + errors.add("location_disabled") } if (!hasPermission()) { - errors.add("No Permission were granted to access the location services!") + errors.add("location_permission_not_granted") + showPermissionAlertDialog() } return errors } 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 87128af7..a1b7c35c 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 @@ -71,6 +71,7 @@ class PolarHeartRateObservation : message = "HR Recording error: ${error.stackTraceToString()}" ) pauseObservation(PolarVerityHeartRateType(emptySet())) + updateObservationErrors() }) deviceConnectionListener = listenToDeviceConnection() true @@ -84,7 +85,6 @@ class PolarHeartRateObservation : } } Napier.d(tag = "PolarHeartRateObservation::start") { "No connected devices..." } - showPermissionAlertDialog() return false } @@ -97,14 +97,16 @@ class PolarHeartRateObservation : override fun observerErrors(): Set { val errors = mutableSetOf() - if (hasPermissions(MoreApplication.appContext!!)) { - errors.add("No permission to access bluetooth!") - } else if (!BluetoothStateListener.bluetoothEnabled.value) { - errors.add("Bluetooth disabled!") - } else if ( - MoreApplication.shared!!.bluetoothController.observerDeviceAccessible(deviceIdentifier) - ) { - errors.add("No viable device connected!") + if (!hasPermissions(MoreApplication.appContext!!)) { + errors.add("error_access_bluetooth") + showPermissionAlertDialog() + } + if (!BluetoothStateListener.bluetoothEnabled.value) { + errors.add("bluetooth_disabled") + } + if (!MoreApplication.shared!!.bluetoothController.observerDeviceAccessible(deviceIdentifier)) { + errors.add("device_not_connected") + errors.add(ERROR_DEVICE_NOT_CONNECTED) } return errors } 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 258127d9..bf8b7481 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 @@ -59,7 +59,7 @@ class AccelerometerObservation( override fun observerErrors(): Set { val errors = mutableSetOf() if (this.sensor == null) { - errors.add("Cannot access Accelerometer sensor!") + errors.add("accelerometer_sensor_not_available") } return errors } 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 cc5c3b2d..cb6959aa 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 @@ -47,7 +47,6 @@ class PolarConnector(context: Context) : BluetoothConnector, PolarConnectorListe api.setPolarFilter(true) api.setApiCallback(polarObserverCallback) api.setAutomaticReconnection(true) - api.setApiLogger { str -> Napier.i(tag = "PolarBleApi::Logger") { str } } api } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt index ee87aa0d..4fcd8d29 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/BluetoothStateListener.kt @@ -1,10 +1,14 @@ package io.redlink.more.app.android.services.sensorsListener import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import androidx.core.content.ContextCompat +import io.redlink.more.more_app_mutliplatform.util.Scope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -27,9 +31,11 @@ object BluetoothStateListener { fun startListening(context: Context) { if (!listenerActive) { listenerActive = true - val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) - context.registerReceiver(receiver, filter) - updateBluetoothState(context) + Scope.launch(Dispatchers.Main) { + val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + context.registerReceiver(receiver, filter) + updateBluetoothState(context) + } } } @@ -41,8 +47,18 @@ object BluetoothStateListener { } private fun updateBluetoothState(context: Context?) { - val bluetoothAdapter: BluetoothAdapter? = - context?.getSystemService(BluetoothAdapter::class.java) + if (context == null) { + _bluetoothEnabled.update { false } + return + } + + val bluetoothManager = ContextCompat.getSystemService(context, BluetoothManager::class.java) + if (bluetoothManager == null) { + _bluetoothEnabled.update { false } + return + } + + val bluetoothAdapter = bluetoothManager.adapter _bluetoothEnabled.update { bluetoothAdapter?.isEnabled ?: false } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt index a6d6c6c3..85e6d934 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/services/sensorsListener/GPSStateListener.kt @@ -5,12 +5,14 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.location.LocationManager +import io.redlink.more.more_app_mutliplatform.util.Scope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update object GPSStateListener { - private val _gpsEnabled = MutableStateFlow(false) + private val _gpsEnabled = MutableStateFlow(false) val gpsEnabled: StateFlow = _gpsEnabled var listenerActive = false private set @@ -26,9 +28,11 @@ object GPSStateListener { fun startListening(context: Context) { if (!listenerActive) { listenerActive = true - val filter = IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION) - context.registerReceiver(receiver, filter) - updateGpsState(context) + Scope.launch(Dispatchers.Main) { + val filter = IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION) + context.registerReceiver(receiver, filter) + updateGpsState(context) + } } } diff --git a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt index 1987d756..e139d283 100644 --- a/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt +++ b/androidApp/src/main/java/io/redlink/more/app/android/shared_composables/ScheduleListHeader.kt @@ -10,21 +10,58 @@ */ package io.redlink.more.app.android.shared_composables +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import io.redlink.more.app.android.R +import io.redlink.more.app.android.activities.NavigationScreen import io.redlink.more.app.android.activities.dashboard.composables.FilterView import io.redlink.more.app.android.activities.dashboard.schedule.ScheduleViewModel import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarView import io.redlink.more.app.android.activities.taskCompletion.TaskCompletionBarViewModel +import io.redlink.more.app.android.extensions.getStringResource +import io.redlink.more.app.android.ui.theme.MoreColors +import io.redlink.more.app.android.ui.theme.moreImportant @Composable -fun ScheduleListHeader(viewModel: ScheduleViewModel, navController: NavController, taskCompletionBarViewModel: TaskCompletionBarViewModel) { - Column(modifier = Modifier.height(IntrinsicSize.Min)) { +fun ScheduleListHeader( + viewModel: ScheduleViewModel, + navController: NavController, + taskCompletionBarViewModel: TaskCompletionBarViewModel +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.height(IntrinsicSize.Min) + ) { TaskCompletionBarView(taskCompletionBarViewModel) - FilterView(navController, model = viewModel.filterModel, scheduleListType = viewModel.scheduleListType) + if (viewModel.numberOfObservationErrors() > 0) { + Box(modifier = Modifier.padding(vertical = 4.dp)) { + + SmallTextIconButton( + text = "${viewModel.numberOfObservationErrors()} ${getStringResource(id = R.string.error)}", + imageText = "Error", + image = Icons.Default.Warning, + imageTint = MoreColors.White, + buttonColors = ButtonDefaults.moreImportant(), + ) { + navController.navigate(NavigationScreen.OBSERVATION_ERRORS.navigationRoute()) + } + } + } + FilterView( + navController, + model = viewModel.filterModel, + scheduleListType = viewModel.scheduleListType + ) } } \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/error-strings.xml b/androidApp/src/main/res/values-de/error-strings.xml index 95d1f07e..5edf1bc2 100644 --- a/androidApp/src/main/res/values-de/error-strings.xml +++ b/androidApp/src/main/res/values-de/error-strings.xml @@ -2,4 +2,11 @@ Fehler Daten konnten nicht geladen werden + Keine Berechtigung für den Zugriff auf Bluetooth + Bluetooth ist deaktiviert + Kein verfügbares Gerät verbunden + Ortungsdienste geben einen unbekannten Fehler zurück + Ortungsdienste sind deaktiviert + Keine Berechtigung für den Zugriff auf die Ortungsdienste gewährt + Kann nicht auf den Beschleunigungssensor zugreifen \ No newline at end of file diff --git a/androidApp/src/main/res/values-de/navigation-strings.xml b/androidApp/src/main/res/values-de/navigation-strings.xml index d5bfe076..0cfb0996 100644 --- a/androidApp/src/main/res/values-de/navigation-strings.xml +++ b/androidApp/src/main/res/values-de/navigation-strings.xml @@ -15,4 +15,5 @@ Studie verlassen Studie verlassen bestätigen Limesurvey + Aufzeichnungsfehler \ No newline at end of file diff --git a/androidApp/src/main/res/values/error-strings.xml b/androidApp/src/main/res/values/error-strings.xml index 4499cd4a..988206d4 100644 --- a/androidApp/src/main/res/values/error-strings.xml +++ b/androidApp/src/main/res/values/error-strings.xml @@ -2,4 +2,11 @@ Error Could not load data + No permission to access bluetooth + Bluetooth disabled + No viable device connected + Location Services return an unknown error + Location Servies are disabled + No Permission were granted to access the location services + Cannot access Accelerometer sensor \ No newline at end of file diff --git a/androidApp/src/main/res/values/navigation-strings.xml b/androidApp/src/main/res/values/navigation-strings.xml index 4781154e..e11680c6 100644 --- a/androidApp/src/main/res/values/navigation-strings.xml +++ b/androidApp/src/main/res/values/navigation-strings.xml @@ -15,4 +15,5 @@ Leave Study Confirm to leave the study Limesurvey + Observation Errors \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 83a66e87..a1e5cab7 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -91,6 +91,10 @@ 1F8847EC2992C3240023EF10 /* MoreFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8847EB2992C3240023EF10 /* MoreFrame.swift */; }; 1F8847EF29938C610023EF10 /* ConsentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8847EE29938C610023EF10 /* ConsentView.swift */; }; 1F8847F129938C6B0023EF10 /* ConsentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8847F029938C6B0023EF10 /* ConsentViewModel.swift */; }; + 1F8937852BFE31400083D20E /* Errors.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F8937832BFE31400083D20E /* Errors.strings */; }; + 1F8937882BFF0DAB0083D20E /* ObservationErrorListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8937872BFF0DAB0083D20E /* ObservationErrorListView.swift */; }; + 1F89378B2BFF1EBF0083D20E /* ObservationErrorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F89378A2BFF1EBF0083D20E /* ObservationErrorsView.swift */; }; + 1F89378D2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F89378C2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift */; }; 1F8EA2CD2A0BDF9A00F32602 /* LimeSurveyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8EA2CC2A0BDF9A00F32602 /* LimeSurveyViewModel.swift */; }; 1F8EA2CF2A0CC22200F32602 /* LimeSurveyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8EA2CE2A0CC22200F32602 /* LimeSurveyView.swift */; }; 1F8EA2D12A0CC2EA00F32602 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8EA2D02A0CC2EA00F32602 /* WebView.swift */; }; @@ -296,6 +300,11 @@ 1F8847EB2992C3240023EF10 /* MoreFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreFrame.swift; sourceTree = ""; }; 1F8847EE29938C610023EF10 /* ConsentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentView.swift; sourceTree = ""; }; 1F8847F029938C6B0023EF10 /* ConsentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentViewModel.swift; sourceTree = ""; }; + 1F8937842BFE31400083D20E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Errors.strings; sourceTree = ""; }; + 1F8937862BFE32890083D20E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Errors.strings; sourceTree = ""; }; + 1F8937872BFF0DAB0083D20E /* ObservationErrorListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationErrorListView.swift; sourceTree = ""; }; + 1F89378A2BFF1EBF0083D20E /* ObservationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationErrorsView.swift; sourceTree = ""; }; + 1F89378C2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationErrorsViewModel.swift; sourceTree = ""; }; 1F8EA2CC2A0BDF9A00F32602 /* LimeSurveyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimeSurveyViewModel.swift; sourceTree = ""; }; 1F8EA2CE2A0CC22200F32602 /* LimeSurveyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimeSurveyView.swift; sourceTree = ""; }; 1F8EA2D02A0CC2EA00F32602 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; @@ -668,6 +677,7 @@ 1F9C74622A30C779003AE946 /* LimeSurvey.strings */, 1F750CAA2A6F9C9B006E455E /* StudyStates.strings */, 1FD531B72B693C3400C2D9FB /* AlertDialog.strings */, + 1F8937832BFE31400083D20E /* Errors.strings */, ); path = Strings; sourceTree = ""; @@ -706,6 +716,15 @@ path = Consent; sourceTree = ""; }; + 1F8937892BFF1EB20083D20E /* ObservationErrors */ = { + isa = PBXGroup; + children = ( + 1F89378A2BFF1EBF0083D20E /* ObservationErrorsView.swift */, + 1F89378C2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift */, + ); + path = ObservationErrors; + sourceTree = ""; + }; 1F8EA2CB2A0BDF8D00F32602 /* LimeSurvey */ = { isa = PBXGroup; children = ( @@ -718,6 +737,7 @@ 1F9C3E8B298AAC0000B9AC82 /* Views */ = { isa = PBXGroup; children = ( + 1F8937892BFF1EB20083D20E /* ObservationErrors */, 1F2C4BDB2A6F974900C29888 /* StudyStates */, 1F8EA2CB2A0BDF8D00F32602 /* LimeSurvey */, EDB16E2C29F934CE00701C27 /* TaskCompletionBar */, @@ -742,6 +762,7 @@ B77F20102A014C8400C44118 /* NavigationModalState.swift */, 1F43998829C0FF3E00687906 /* MainTabView.swift */, 1F8EA2D42A0CE5EE00F32602 /* ModalView.swift */, + 1F8937872BFF0DAB0083D20E /* ObservationErrorListView.swift */, ); path = Views; sourceTree = ""; @@ -1047,6 +1068,7 @@ 1F9C744E2A30C768003AE946 /* NotificationView.strings in Resources */, 1F9C74422A30C75D003AE946 /* Navigation.strings in Resources */, 1F9C74452A30C75F003AE946 /* StudyDetailsView.strings in Resources */, + 1F8937852BFE31400083D20E /* Errors.strings in Resources */, 1F9DB1A5298D02FB00DBB7DB /* MoreColors.xcassets in Resources */, 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 1F9DB1A3298D022E00DBB7DB /* MoreImages.xcassets in Resources */, @@ -1118,6 +1140,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1F89378D2BFF1F8D0083D20E /* ObservationErrorsViewModel.swift in Sources */, 1F5A248D29C893B3008140CF /* AccelerometerBackgroundObservation.swift in Sources */, 1FF5B28D2A8275790076EF8E /* Bundle.swift in Sources */, 1FA763E82A42F834007C1CF9 /* NotificationFilterViewModel.swift in Sources */, @@ -1170,6 +1193,7 @@ B784169229C9E3F50035D830 /* Title2.swift in Sources */, EDD808D729BF4C3A005779DC /* MoreBackButton.swift in Sources */, 1F8847BA29914C8D0023EF10 /* Title.swift in Sources */, + 1F8937882BFF0DAB0083D20E /* ObservationErrorListView.swift in Sources */, 1F9C3E8E298AAC1A00B9AC82 /* LoginView.swift in Sources */, 1F6A4E3929F6C7C000F0247F /* EmptyListView.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, @@ -1247,6 +1271,7 @@ B78B4A8429F04BEA00A1BA58 /* ExpandableContentWithLink.swift in Sources */, B748DF4829D1C07F0026C348 /* SimpleQuestionObservationViewModel.swift in Sources */, 1F8847EA2992C0060023EF10 /* MoreContainer.swift in Sources */, + 1F89378B2BFF1EBF0083D20E /* ObservationErrorsView.swift in Sources */, B74DDB6A29E6AEDE006FEA74 /* NotificationItem.swift in Sources */, 1F27536B29CC68FC00324417 /* ObservationExtension.swift in Sources */, 1F8847EC2992C3240023EF10 /* MoreFrame.swift in Sources */, @@ -1290,6 +1315,15 @@ name = StudyStates.strings; sourceTree = ""; }; + 1F8937832BFE31400083D20E /* Errors.strings */ = { + isa = PBXVariantGroup; + children = ( + 1F8937842BFE31400083D20E /* en */, + 1F8937862BFE32890083D20E /* de */, + ); + name = Errors.strings; + sourceTree = ""; + }; 1F9C74322A30C72B003AE946 /* LoginView.strings */ = { isa = PBXVariantGroup; children = ( diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..af931df3 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,159 @@ +{ + "originHash" : "035e8c2986e70eb80117b23f041bf88071e4c3e2137683d75a051eed044bef2e", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "748c7837511d0e6a507737353af268484e1745e2", + "version" : "1.2024011601.1" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "c218c2054299b15ae577e818bbba16084d3eabe6", + "version" : "10.18.2" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "42eae77a0af79e9c3f41df04a23c76f05cfdda77", + "version" : "10.24.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "51ba746a9d51a4bd0774b68499b0c73ef6e8570d", + "version" : "10.24.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", + "version" : "9.4.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "26c898aed8bed13b8a63057ee26500abbbcb8d55", + "version" : "7.13.1" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", + "version" : "1.62.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "9534039303015a84837090d20fa21cae6e5eadb6", + "version" : "3.3.2" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "polar-ble-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/polarofficial/polar-ble-sdk.git", + "state" : { + "revision" : "784f7ed2392bfa2f5a295e7b26a153ce6120d97f", + "version" : "5.5.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "realm-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/realm-core.git", + "state" : { + "revision" : "374dd672af357732dccc135fecc905406fec3223", + "version" : "14.4.1" + } + }, + { + "identity" : "realm-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/realm-swift.git", + "state" : { + "revision" : "e0c2fbb442979fbf1e4be80e01d142f310a9c762", + "version" : "10.49.1" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "b4307ba0b6425c0ba4178e138799946c3da594f8", + "version" : "6.5.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", + "version" : "1.26.0" + } + } + ], + "version" : 3 +} diff --git a/iosApp/iosApp/ContentViewModel.swift b/iosApp/iosApp/ContentViewModel.swift index 85e1fa18..40eefc7a 100644 --- a/iosApp/iosApp/ContentViewModel.swift +++ b/iosApp/iosApp/ContentViewModel.swift @@ -77,7 +77,9 @@ class ContentViewModel: ObservableObject { } ViewManager.shared.showBluetoothViewAsClosure { [weak self] kBool in - self?.showBleView = kBool.boolValue + if kBool.boolValue { + self?.showBleView = kBool.boolValue + } } AppDelegate.shared.onStudyStateChange { [weak self] studyState in @@ -90,10 +92,6 @@ class ContentViewModel: ObservableObject { } } - func showBLESetup() { - AppDelegate.shared.showBLESetupOnFirstStartup() - } - func showLoginView() { DispatchQueue.main.async { self.registrationService.reset() @@ -162,10 +160,7 @@ extension ContentViewModel: ConsentViewModelListener { func credentialsStored() { reinitAllViewModels() DispatchQueue.main.async { [weak self] in - if let self { - self.hasCredentials = true - self.showBLESetup() - } + self?.hasCredentials = true } AppDelegate.shared.doNewLogin() } diff --git a/iosApp/iosApp/Extensions/DictionaryExtension.swift b/iosApp/iosApp/Extensions/DictionaryExtension.swift index 87e1c3e1..58c7802a 100644 --- a/iosApp/iosApp/Extensions/DictionaryExtension.swift +++ b/iosApp/iosApp/Extensions/DictionaryExtension.swift @@ -7,16 +7,32 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // import Foundation -func += (left: inout [K:V], right: [K:V]) { +func += (left: inout [K: V], right: [K: V]) { for (k, v) in right { left[k] = v } } + +extension Dictionary { + func filterValues(predicate: (V) -> Bool) -> [Key: Set] where Value == Set { + return mapValues { $0.filter(predicate) } + } +} + +extension Dictionary where Value == Set { + func flattenValues() -> Set { + var resultSet = Set() + for valueSet in self.values { + resultSet.formUnion(valueSet) + } + return resultSet + } +} diff --git a/iosApp/iosApp/Extensions/NavigationScreen.swift b/iosApp/iosApp/Extensions/NavigationScreen.swift index 88b07be2..20824c17 100644 --- a/iosApp/iosApp/Extensions/NavigationScreen.swift +++ b/iosApp/iosApp/Extensions/NavigationScreen.swift @@ -49,6 +49,7 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { case withdrawStudy case withdrawStudyConfirm case limeSurvey + case observationErrors var values: NavigationScreenValues { switch self { @@ -88,6 +89,8 @@ enum NavigationScreen: CaseIterable, Equatable, Identifiable { return NavigationScreenValues(screenName: "Confirm to leave the study", navigationLink: "/confirm-leave-study", fullScreen: true) case .limeSurvey: return NavigationScreenValues(screenName: "LimeSurvey", navigationLink: "/lime-survey-observation", parameters: [.observationId, .notificaitonId, .scheduleId], fullScreen: true) + case .observationErrors: + return NavigationScreenValues(screenName: "Observation Errors", navigationLink: "/observation-errors") } } diff --git a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift index 38ac8baf..5142c1bb 100644 --- a/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerBackgroundObservation.swift @@ -89,10 +89,10 @@ class AccelerometerBackgroundObservation: Observation_ { override func observerErrors() -> Set { var errors: Set = [] if !CMSensorRecorder.isAccelerometerRecordingAvailable() { - errors.insert("Accelerometer Recording is not available!") + errors.insert("Accelerometer Recording is not available") } if CMSensorRecorder.authorizationStatus() != .authorized { - errors.insert("Permission not granted to access Sensor recording service!") + errors.insert("Permission not granted to access Sensor recording service") PermissionManager.openSensorPermissionDialog() } return errors diff --git a/iosApp/iosApp/Observations/AccelerometerObservation.swift b/iosApp/iosApp/Observations/AccelerometerObservation.swift index edd0ca15..135c0141 100644 --- a/iosApp/iosApp/Observations/AccelerometerObservation.swift +++ b/iosApp/iosApp/Observations/AccelerometerObservation.swift @@ -50,7 +50,7 @@ class AccelerometerObservation: Observation_ { override func observerErrors() -> Set { var errors: Set = [] if !motion.isAccelerometerAvailable { - errors.insert("Accelerometer Sensor not available!") + errors.insert("Accelerometer Sensor not available") } return errors } diff --git a/iosApp/iosApp/Observations/GPSObservation.swift b/iosApp/iosApp/Observations/GPSObservation.swift index a4ff16ff..3c135f1a 100644 --- a/iosApp/iosApp/Observations/GPSObservation.swift +++ b/iosApp/iosApp/Observations/GPSObservation.swift @@ -44,19 +44,18 @@ class GPSObservation: Observation_ { running = false onCompletion() } - + 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!") + 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!") + && manager.authorizationStatus != .authorizedAlways { + errors.insert("Permission not granted to access location of the device") PermissionManager.openSensorPermissionDialog() + } else if !CLLocationManager.locationServicesEnabled() { + errors.insert("Location Services not enabled") } return errors } @@ -72,6 +71,7 @@ extension GPSObservation: CLLocationManagerDelegate { } storeData(data: data) {} } + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { if manager.authorizationStatus == .restricted || manager.authorizationStatus == .denied || manager.accuracyAuthorization != .fullAccuracy { super.stopAndSetState(state: .active, scheduleId: nil) diff --git a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift index 21bbbb8f..6f079cd1 100644 --- a/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift +++ b/iosApp/iosApp/Observations/PolarVerityHeartRateObservation.swift @@ -7,17 +7,17 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // +import CoreBluetooth import Foundation -import shared import PolarBleSdk -import CoreBluetooth import RxSwift +import shared import UIKit class PolarVerityHeartRateObservation: Observation_ { @@ -34,7 +34,7 @@ class PolarVerityHeartRateObservation: Observation_ { private let polarConnector = AppDelegate.polarConnector private var connectedDevices: [BluetoothDevice] = [] - private var hrObservation: Disposable? = nil + private var hrObservation: Disposable? private let deviceManager = BluetoothDeviceManager.shared @@ -45,11 +45,11 @@ class PolarVerityHeartRateObservation: Observation_ { } override func start() -> Bool { - if self.observerAccessible() { + if 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 + hrObservation = 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) { } @@ -58,7 +58,6 @@ class PolarVerityHeartRateObservation: Observation_ { print(error) if let self { self.pauseObservation(self.observationType) - self.observerAccessible() } }) return true @@ -68,25 +67,29 @@ class PolarVerityHeartRateObservation: Observation_ { } override func stop(onCompletion: @escaping () -> Void) { - self.hrObservation?.dispose() - self.deviceListener?.close() + hrObservation?.dispose() + deviceListener?.close() onCompletion() } - + override func observerErrors() -> Set { var errors: Set = [] + let state = AppDelegate.shared.bluetoothController.bluetoothPower.value as? BluetoothState if CBManager.authorization != .allowedAlways { - errors.insert("Access to Bluetooth not granted!") + errors.insert("Access to Bluetooth not granted") PermissionManager.openSensorPermissionDialog() } + if state == nil || state == BluetoothState.off { + errors.insert("Bluetooth is not enabled") + } if !AppDelegate.shared.bluetoothController.observerDeviceAccessible(bleDevices: deviceIdentificer) { - errors.insert("No polar device connected!") + errors.insert("No polar device connected") + errors.insert(Observation_.companion.ERROR_DEVICE_NOT_CONNECTED) } return errors } override func applyObservationConfig(settings: Dictionary) { - } override func bleDevicesNeeded() -> Set { @@ -99,7 +102,7 @@ class PolarVerityHeartRateObservation: Observation_ { } private func listenToDeviceConnection() { - self.deviceListener = deviceManager.connectedDevicesAsClosure { [weak self] devices in + deviceListener = deviceManager.connectedDevicesAsClosure { [weak self] devices in if let self, !self.deviceIdentificer.anyNameIn(items: devices) { self.pauseObservation(self.observationType) PolarVerityHeartRateObservation.hrReady = false @@ -108,4 +111,3 @@ class PolarVerityHeartRateObservation: Observation_ { } } } - diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings b/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings new file mode 100644 index 00000000..55838c4e --- /dev/null +++ b/iosApp/iosApp/Resources/Strings/de.lproj/Errors.strings @@ -0,0 +1,18 @@ +/* + Errors.strings + iosApp + + Created by Jan Cortiel on 22.05.24. + Copyright © 2024 Redlink GmbH. All rights reserved. +*/ + +"Access to Bluetooth not granted" = "Zugriff auf Bluetooth nicht gewährt"; +"Bluetooth is not enabled" = "Bluetooth ist nicht aktiviert"; +"No polar device connected" = "Kein Polar-Gerät verbunden"; +"Permission request pending until observation is about to start" = "Genehmigungsanfrage ausstehend, bis die Beobachtung beginnt"; +"Accelerometer Sensor not available" = "Beschleunigungssensor nicht verfügbar"; +"Permission not granted to access Sensor recording service" = "Zugriff auf Sensoraufzeichnungsdienst nicht genehmigt"; +"Accelerometer Recording is not available" = "Beschleunigungssensoraufzeichnung ist nicht verfügbar"; +"Location Services not enabled" = "Ortungsdienste sind nicht aktiviert"; +"Permission not granted to access location of the device" = "Zugriff auf den Standort des Geräts nicht genehmigt"; +"errors" = "Fehler"; diff --git a/iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings b/iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings index 5cdc5da0..76efe1ef 100644 --- a/iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings +++ b/iosApp/iosApp/Resources/Strings/de.lproj/Navigation.strings @@ -18,5 +18,7 @@ "Study Details" = "Studiendetails"; "Running Observations" = "Laufende Aufzeichnungen"; "Past Observations" = "Vergangene Aufzeichnungen"; +"Devices" = "Geräte"; +"Observation Errors" = "Aufzeichnungsfehler"; "Scan QR Code" = "QR-Code scannen"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings b/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings new file mode 100644 index 00000000..5e04d7d3 --- /dev/null +++ b/iosApp/iosApp/Resources/Strings/en.lproj/Errors.strings @@ -0,0 +1,18 @@ +/* + Errors.strings + iosApp + + Created by Jan Cortiel on 22.05.24. + Copyright © 2024 Redlink GmbH. All rights reserved. +*/ + +"Access to Bluetooth not granted" = "Access to Bluetooth not granted"; +"Bluetooth is not enabled" = "Bluetooth is not enabled"; +"No polar device connected" = "No polar device connected"; +"Permission request pending until observation is about to start" = "Permission request pending until observation is about to start"; +"Accelerometer Sensor not available" = "Accelerometer Sensor not available"; +"Permission not granted to access Sensor recording service" = "Permission not granted to access Sensor recording service"; +"Accelerometer Recording is not available" = "Accelerometer Recording is not available"; +"Location Services not enabled" = "Location Services not enabled"; +"Permission not granted to access location of the device" = "Permission not granted to access location of the device"; +"errors" = "errors"; diff --git a/iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings b/iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings index c286ad21..8b8bbc0c 100644 --- a/iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings +++ b/iosApp/iosApp/Resources/Strings/en.lproj/Navigation.strings @@ -18,6 +18,8 @@ "Study Details" = "Study Details"; "Running Observations" = "Running Observations"; "Past Observations" = "Past Observations"; +"Devices" = "Devices"; +"Observation Errors" = "Observation Errors"; "Scan QR Code" = "Scan QR Code"; diff --git a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift index 22b8eca5..f67c0ee2 100644 --- a/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift +++ b/iosApp/iosApp/Views/Bluetooth/BluetoothConnectionViewModel.swift @@ -99,6 +99,7 @@ class BluetoothConnectionViewModel: ObservableObject { discoveredDevices.removeAll() connectingDevices.removeAll() bluetoothIsScanning = false + ViewManager.shared.showBLEView(state: false) } func connectToDevice(device: BluetoothDevice) { diff --git a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift index a5e3efce..4e0de351 100644 --- a/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift +++ b/iosApp/iosApp/Views/CompletedSchedules/CompletedSchedules.swift @@ -23,7 +23,7 @@ struct CompletedSchedules: View { @State var totalTasks: Double = 0 var body: some View { VStack { - ScheduleListHeader(totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) + ScheduleListHeader(scheduleViewModel: scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) ScheduleView(viewModel: scheduleViewModel) } .customNavigationTitle(with: NavigationScreen.pastObservations.localize(useTable: navigationStrings, withComment: "Completed Schedules title"),displayMode: .inline) diff --git a/iosApp/iosApp/Views/Components/ScheduleListHeader.swift b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift index f283ad55..14eb446e 100644 --- a/iosApp/iosApp/Views/Components/ScheduleListHeader.swift +++ b/iosApp/iosApp/Views/Components/ScheduleListHeader.swift @@ -16,14 +16,29 @@ import SwiftUI struct ScheduleListHeader: View { + @ObservedObject var scheduleViewModel: ScheduleViewModel @Binding var totalTasks: Double @Binding var tasksCompleted: Double + + @EnvironmentObject var navigationModalState: NavigationModalState private let stringTable = "DashboardView" var body: some View { VStack { TaskCompletionBarView(viewModel: TaskCompletionBarViewModel(), progressViewTitle: String.localize(forKey: "tasks_completed", withComment: "string for completed tasks", inTable: "DashboardView")) .padding(.bottom) + if scheduleViewModel.numberOfObservationErrors() > 0 { + MoreActionButton(backgroundColor: .more.important, disabled: .constant(false)) { + navigationModalState.openView(screen: .observationErrors) + } label: { + HStack { + Image(systemName: "exclamationmark.triangle") + .padding(.trailing, 2) + Text("\(scheduleViewModel.numberOfObservationErrors()) \("errors".localize(withComment: "Errors", useTable: "Errors"))") + } + } + .padding(.bottom) + } MoreFilter(filterText: .constant("All Items"), destination: .dashboardFilter) .padding(.bottom) } diff --git a/iosApp/iosApp/Views/Dashboard/DashboardView.swift b/iosApp/iosApp/Views/Dashboard/DashboardView.swift index 30f288d0..51b77e0d 100644 --- a/iosApp/iosApp/Views/Dashboard/DashboardView.swift +++ b/iosApp/iosApp/Views/Dashboard/DashboardView.swift @@ -26,7 +26,7 @@ struct DashboardView: View { private let navigationStrings = "Navigation" var body: some View { VStack { - ScheduleListHeader(totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) + ScheduleListHeader(scheduleViewModel: viewModel.scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) if selection == 0 { ScheduleView(viewModel: viewModel.scheduleViewModel) } else { diff --git a/iosApp/iosApp/Views/Navigation.swift b/iosApp/iosApp/Views/Navigation.swift index 8f9db654..618e3b84 100644 --- a/iosApp/iosApp/Views/Navigation.swift +++ b/iosApp/iosApp/Views/Navigation.swift @@ -155,6 +155,8 @@ struct NavigationWithDestinations: View { if let observationId = navigationModalState.navigationState(for: screen)?.observationId { ObservationDetailsView(viewModel: ObservationDetailsViewModel(observationId: observationId)) } + case .observationErrors: + ObservationErrorsView() default: EmptyView() } diff --git a/iosApp/iosApp/Views/ObservationErrorListView.swift b/iosApp/iosApp/Views/ObservationErrorListView.swift new file mode 100644 index 00000000..b29a8d5e --- /dev/null +++ b/iosApp/iosApp/Views/ObservationErrorListView.swift @@ -0,0 +1,60 @@ +// +// ObservationErrorListView.swift +// More +// +// Created by Jan Cortiel on 23.05.24. +// Copyright © 2024 Redlink GmbH. All rights reserved. +// + +import shared +import SwiftUI + +struct ObservationErrorListView: View { + let taskObservationErrors: [String] + let taskObservationErrorActions: [String] + + @State private var scrollViewContentSize: CGSize = .zero + private let errorStrings = "Errors" + private let navigationStrings = "Navigation" + + var body: some View { + if !taskObservationErrors.isEmpty { + ScrollView { + VStack { + ForEach(taskObservationErrors, id: \.self) { error in + HStack { + Image(systemName: "exclamationmark.triangle") + .font(.more.headline) + .foregroundColor(.more.important) + .padding(.trailing, 4) + BasicText(text: "\(error.localize(withComment: "Error message", useTable: errorStrings))!") + } + .padding(.bottom) + } + } + + } +// .frame(maxWidth: .infinity, maxHeight: scrollViewContentSize.height) + if taskObservationErrorActions.isEmpty { + Divider() + } + } + + if !taskObservationErrorActions.isEmpty { + if taskObservationErrorActions + .contains(Observation_.companion.ERROR_DEVICE_NOT_CONNECTED) { + MoreActionButton(disabled: .constant(false)) { + ViewManager.shared.showBLEView(state: true) + } label: { + Text(String.localize(forKey: "Devices", withComment: "Lists all connected or needed devices.", inTable: navigationStrings)) + } + } + + Divider() + } + } +} + +#Preview { + ObservationErrorListView(taskObservationErrors: [], taskObservationErrorActions: [Observation_.companion.ERROR_DEVICE_NOT_CONNECTED]) +} diff --git a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift new file mode 100644 index 00000000..7f6d42ef --- /dev/null +++ b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsView.swift @@ -0,0 +1,24 @@ +// +// ObservationErrorsView.swift +// More +// +// Created by Jan Cortiel on 23.05.24. +// Copyright © 2024 Redlink GmbH. All rights reserved. +// + +import SwiftUI + +struct ObservationErrorsView: View { + @StateObject private var observationErrorsViewModel = ObservationErrorsViewModel() + + private let navigationStrings = "Navigation" + var body: some View { + ObservationErrorListView(taskObservationErrors: observationErrorsViewModel.observationErrors, taskObservationErrorActions: observationErrorsViewModel.observationErrorActions) + .padding(.vertical) + .customNavigationTitle(with: NavigationScreen.observationErrors.localize(useTable: navigationStrings, withComment: "Observation Errors title"), displayMode: .inline) + } +} + +#Preview { + ObservationErrorsView() +} diff --git a/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsViewModel.swift b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsViewModel.swift new file mode 100644 index 00000000..40792776 --- /dev/null +++ b/iosApp/iosApp/Views/ObservationErrors/ObservationErrorsViewModel.swift @@ -0,0 +1,26 @@ +// +// ObservationErrorsViewModel.swift +// More +// +// Created by Jan Cortiel on 23.05.24. +// Copyright © 2024 Redlink GmbH. All rights reserved. +// + +import Foundation +import shared + +class ObservationErrorsViewModel: ObservableObject { + @Published var observationErrors: [String] = [] + @Published var observationErrorActions: [String] = [] + + init() { + AppDelegate.shared.observationFactory.observationErrorsAsClosure { [weak self] errors in + DispatchQueue.main.async { + if let self { + self.observationErrors = Array(Set(errors.filterValues { $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED }.flatMap{$0.value})) + self.observationErrorActions = Array(Set(errors.filterValues { $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED }.flatMap{$0.value})) + } + } + } + } +} diff --git a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift index b2046b61..bde8edc6 100644 --- a/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift +++ b/iosApp/iosApp/Views/RunningSchedules/RunningSchedules.swift @@ -23,7 +23,7 @@ struct RunningSchedules: View { private let navigationStrings = "Navigation" var body: some View { VStack { - ScheduleListHeader(totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) + ScheduleListHeader(scheduleViewModel: scheduleViewModel, totalTasks: $totalTasks, tasksCompleted: $tasksCompleted) ScheduleView(viewModel: scheduleViewModel) } .customNavigationTitle(with: NavigationScreen.runningObservations.localize(useTable: navigationStrings, withComment: "Running Schedules title"), displayMode: .inline) diff --git a/iosApp/iosApp/Views/Schedule/ScheduleView.swift b/iosApp/iosApp/Views/Schedule/ScheduleView.swift index ff68a534..32cf4aa3 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleView.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleView.swift @@ -24,7 +24,6 @@ struct ScheduleView: View { private let stringsTable = "ScheduleListView" var body: some View { VStack { - ScrollView(.vertical) { if (viewModel.schedulesByDate.isEmpty) { if viewModel.scheduleListType == ScheduleListType.running { diff --git a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift index a533368f..92e60598 100644 --- a/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift +++ b/iosApp/iosApp/Views/Schedule/ScheduleViewModel.swift @@ -7,8 +7,8 @@ // Digital Health and Prevention - A research institute // of the Ludwig Boltzmann Gesellschaft, // Oesterreichische Vereinigung zur Foerderung -// der wissenschaftlichen Forschung -// Licensed under the Apache 2.0 license with Commons Clause +// der wissenschaftlichen Forschung +// Licensed under the Apache 2.0 license with Commons Clause // (see https://www.apache.org/licenses/LICENSE-2.0 and // https://commonsclause.com/). // @@ -21,39 +21,38 @@ class ScheduleViewModel: ObservableObject { private let coreModel: CoreScheduleViewModel let filterViewModel: DashboardFilterViewModel = DashboardFilterViewModel() - + @Published var schedulesByDate: [Date: [ScheduleModel]] = [:] @Published var observationErrors: [String: Set] = [:] - + @Published var observationErrorActions: [String: Set] = [:] init(scheduleListType: ScheduleListType) { self.scheduleListType = scheduleListType - self.coreModel = CoreScheduleViewModel(dataRecorder: recorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) - self.loadSchedules() + coreModel = CoreScheduleViewModel(dataRecorder: recorder, scheduleListType: scheduleListType, coreFilterModel: filterViewModel.coreViewModel) + loadSchedules() } func loadSchedules() { coreModel.onScheduleStateUpdated { [weak self] triple in if let self, - let added = triple.first as? Set, - let removed = triple.second as? Set, - let updated = triple.third as? Set { - + let added = triple.first as? Set, + let removed = triple.second as? Set, + let updated = triple.third as? Set { if !removed.isEmpty || !updated.isEmpty { - let idsToRemove = removed.union(updated.map{$0.scheduleId}) - let filtered = self.schedulesByDate.filter { (date, list) in - list.contains(where: {idsToRemove.contains($0.scheduleId)}) + let idsToRemove = removed.union(updated.map { $0.scheduleId }) + let filtered = self.schedulesByDate.filter { _, list in + list.contains(where: { idsToRemove.contains($0.scheduleId) }) } for (date, schedules) in filtered { - self.schedulesByDate[date] = schedules.filter { !removed.contains($0.scheduleId)} + self.schedulesByDate[date] = schedules.filter { !removed.contains($0.scheduleId) } } } - + if !added.isEmpty || !updated.isEmpty { let itemsToBeAdded = self.mergeSchedules(Array(added), Array(updated)) - + let groupedSchedulesToAdd = Dictionary(grouping: itemsToBeAdded, by: { $0.start.startOfDate() }) - + for (date, schedules) in groupedSchedulesToAdd { self.schedulesByDate[date] = self.mergeSchedules(schedules, self.schedulesByDate[date, default: []]).sorted(by: { if $0.start == $1.start { @@ -71,10 +70,13 @@ class ScheduleViewModel: ObservableObject { } } } - + AppDelegate.shared.observationFactory.observationErrorsAsClosure { [weak self] errors in DispatchQueue.main.async { - self?.observationErrors = errors + if let self { + self.observationErrors = errors.filterValues { $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED } + self.observationErrorActions = errors.filterValues { $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED } + } } } } @@ -86,12 +88,17 @@ class ScheduleViewModel: ObservableObject { func viewDidDisappear() { coreModel.viewDidDisappear() } - + func mergeSchedules(_ lhs: [ScheduleModel], _ rhs: [ScheduleModel]) -> [ScheduleModel] { - let lhsIds = Set(lhs.map{$0.scheduleId}) - let filteredRhs = rhs.filter{ !lhsIds.contains($0.scheduleId) } + let lhsIds = Set(lhs.map { $0.scheduleId }) + let filteredRhs = rhs.filter { !lhsIds.contains($0.scheduleId) } return lhs + filteredRhs } + + func numberOfObservationErrors() -> Int { + let errors = Set(observationErrors.values.flatMap { $0 }).count + return errors > 0 ? errors : Set(observationErrorActions.values.flatMap { $0 }).count + } } extension ScheduleViewModel: ObservationActionDelegate { diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift index 8589d763..1f56f1a8 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsView.swift @@ -26,6 +26,7 @@ struct TaskDetailsView: View { private let stringTable = "TaskDetail" private let scheduleStringTable = "ScheduleListView" private let navigationStrings = "Navigation" + private let errorStrings = "Errors" var body: some View { MoreMainBackgroundView(contentPadding: 0) { @@ -61,31 +62,17 @@ struct TaskDetailsView: View { } Spacer() - if !viewModel.taskObservationErrors.isEmpty { - ScrollView { - VStack { - ForEach(viewModel.taskObservationErrors, id: \.self) { error in - HStack { - Image(systemName: "exclamationmark.triangle") - .font(.more.headline) - .foregroundColor(.more.important) - .padding(.trailing, 4) - BasicText(text: error) - } + ObservationErrorListView(taskObservationErrors: viewModel.taskObservationErrors, taskObservationErrorActions: viewModel.taskObservationErrorAction) + .background( + GeometryReader { geo -> Color in + DispatchQueue.main.async { + scrollViewContentSize = geo.size } + return Color.clear } - .background( - GeometryReader { geo -> Color in - DispatchQueue.main.async { - scrollViewContentSize = geo.size - } - return Color.clear - } - ) - } - .frame(maxWidth: .infinity, maxHeight: scrollViewContentSize.height) - Divider() - } + ) + .frame(maxWidth: .infinity, maxHeight: 150) + if !detailsModel.hidden { if let scheduleId = navigationModalState.navigationState(for: .taskDetails)?.scheduleId { ObservationButton( diff --git a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift index 22903a39..8360aeed 100644 --- a/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift +++ b/iosApp/iosApp/Views/TaskDetails/TaskDetailsViewModel.swift @@ -26,6 +26,7 @@ class TaskDetailsViewModel: ObservableObject { } @Published var dataCount: Int64 = 0 @Published var taskObservationErrors: [String] = [] + @Published var taskObservationErrorAction: [String] = [] private var observationErrors: [String : Set] = [:] { didSet { @@ -85,10 +86,9 @@ class TaskDetailsViewModel: ObservableObject { } private func updateTaskObservationErrors() { - print(observationErrors) if let taskDetailsModel { - self.taskObservationErrors = Array(observationErrors[taskDetailsModel.observationType] ?? []) - print(self.taskObservationErrors) + self.taskObservationErrors = Array(observationErrors[taskDetailsModel.observationType]?.filter { $0 != Observation_.companion.ERROR_DEVICE_NOT_CONNECTED} ?? []) + self.taskObservationErrorAction = Array(observationErrors[taskDetailsModel.observationType]?.filter { $0 == Observation_.companion.ERROR_DEVICE_NOT_CONNECTED} ?? []) } else { self.taskObservationErrors = [] } 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 0d70418b..1707a29c 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 @@ -93,6 +93,13 @@ class Shared( updateStudyBlocking() if (credentialRepository.hasCredentials()) { notificationManager.createNewFCMIfNecessary() + StudyScope.launch { + observationFactory.updateObservationErrors() + bluetoothController.listenToConnectionChanges( + observationFactory, + observationManager + ) + } } } else { ViewManager.showBLEView(false) @@ -113,6 +120,7 @@ class Shared( observationDataManager.listenToDatapointCountChanges() updateTaskStates() observationManager.activateScheduleUpdate() + } } } @@ -138,14 +146,6 @@ class Shared( } else false } - fun showBLESetupOnFirstStartup() { - ViewManager.showBLEView( - firstStartUp() - && observationFactory.bleDevicesNeeded().isNotEmpty() - ) - } - - fun updateStudyBlocking(oldStudyState: StudyState? = null, newStudyState: StudyState? = null) { Scope.launch(Dispatchers.IO) { updateStudy(oldStudyState, newStudyState) 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 8be9be3d..971530d4 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 @@ -16,12 +16,14 @@ import io.realm.kotlin.ext.query import io.realm.kotlin.types.RealmInstant import io.redlink.more.more_app_mutliplatform.database.schemas.ObservationSchema import io.redlink.more.more_app_mutliplatform.database.schemas.ScheduleSchema +import io.redlink.more.more_app_mutliplatform.extensions.areAllNamesIn import io.redlink.more.more_app_mutliplatform.extensions.asClosure import io.redlink.more.more_app_mutliplatform.extensions.asMappedFlow import io.redlink.more.more_app_mutliplatform.extensions.firstAsFlow import io.redlink.more.more_app_mutliplatform.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.services.bluetooth.BluetoothDeviceManager import io.redlink.more.more_app_mutliplatform.util.StudyScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -159,6 +161,9 @@ class ScheduleRepository : Repository() { && scheduleSchema.hidden && newState.active() && scheduleSchema.observationType in autoStartingObservations + && observationFactory.observation(scheduleSchema.observationType) + ?.bleDevicesNeeded() + ?.areAllNamesIn(BluetoothDeviceManager.connectedDevices.value) != false ) { scheduleSchema.scheduleId.toHexString() } else { @@ -172,5 +177,39 @@ class ScheduleRepository : Repository() { } } + suspend fun updateTaskStatesWithBLEDevices( + observationFactory: ObservationFactory, + dataRecorder: DataRecorder + ) { + val autoStartingObservations = observationFactory.autoStartableObservations() + if (autoStartingObservations.isNotEmpty()) { + Napier.i { "Updating Schedule states using Bluetooth devices..." } + val activeIds = realm()?.let { + it.write { + query("done = $0", false).find().filter { + observationFactory.observation(it.observationType)?.bleDevicesNeeded() + ?.isNotEmpty() == true && it.observationType in autoStartingObservations + }.mapNotNull { scheduleSchema -> + val newState = scheduleSchema.updateState() + if (newState.active() && scheduleSchema.hidden && observationFactory.observation( + scheduleSchema.observationType + ) + ?.bleDevicesNeeded() + ?.areAllNamesIn(BluetoothDeviceManager.connectedDevices.value) != false + + ) { + scheduleSchema.scheduleId.toHexString() + } else { + null + } + } + } + }?.toSet() ?: emptySet() + if (activeIds.isNotEmpty()) { + dataRecorder.startMultiple(activeIds) + } + } + } + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/Observation.kt index 6d8eae0a..727b5696 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 io.redlink.more.more_app_mutliplatform.util.StudyScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -67,6 +70,7 @@ abstract class Observation(val observationType: ObservationType) { stop { saveAndSend() observationShutdown(scheduleId) + updateObservationErrors() } } else { saveAndSend() @@ -120,7 +124,13 @@ abstract class Observation(val observationType: ObservationType) { return errors.isEmpty() } - open fun observerErrors(): Set = emptySet() + protected open fun observerErrors(): Set = emptySet() + + fun updateObservationErrors() { + StudyScope.launch(Dispatchers.IO) { + _observationErrors.update { Pair(observationType.observationType, observerErrors()) } + } + } protected abstract fun applyObservationConfig(settings: Map) @@ -130,9 +140,7 @@ abstract class Observation(val observationType: ObservationType) { fun storeData(data: Any, timestamp: Long = -1, onCompletion: () -> Unit = {}) { val dataSchemas = ObservationDataSchema.fromData( - observationIds.toSet(), setOf( - ObservationBulkModel(data, timestamp) - ) + observationIds.toSet(), setOf(ObservationBulkModel(data, timestamp)) ).map { observationType.addObservationType(it) } Napier.i(tag = "Observation::storeData") { "Observation, with ids $observationIds, ${observationType.observationType} recorded a new data point!" } dataManager?.add(dataSchemas, scheduleIds.keys) @@ -152,6 +160,7 @@ abstract class Observation(val observationType: ObservationType) { stop { saveAndSend() observationShutdown(scheduleId) + updateObservationErrors() } } @@ -163,6 +172,7 @@ abstract class Observation(val observationType: ObservationType) { scheduleId?.let { observationShutdown(it) } + updateObservationErrors() } } @@ -219,5 +229,7 @@ abstract class Observation(val observationType: ObservationType) { const val CONFIG_TASK_STOP = "observation_stop_date_time" const val SCHEDULE_ID = "schedule_id" const val CONFIG_LAST_COLLECTION_TIMESTAMP = "observation_last_collection_timestamp" + + const val ERROR_DEVICE_NOT_CONNECTED = "error_device_not_connected" } } diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/observations/ObservationFactory.kt index df59d5a7..b1408c28 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 @@ -53,7 +53,7 @@ abstract class ObservationFactory(private val dataManager: ObservationDataManage Scope.launch { studyObservationTypes.collect { if (it.isNotEmpty()) { - updateObservationErrors() + listenToObservationErrors() } else { observationErrorWatcher?.cancel() observationErrorWatcher = null @@ -98,13 +98,13 @@ abstract class ObservationFactory(private val dataManager: ObservationDataManage } fun autoStartableObservations(): Set { - val autoStartTypes = observations.filter { it.ableToAutomaticallyStart() } + val autoStartTypes = studyObservations().filter { it.ableToAutomaticallyStart() } .map { it.observationType.observationType }.toSet() Napier.i(tag = "ObservationFactory::autoStartableObservations") { "Auto-startable observations: $autoStartTypes" } return autoStartTypes } - private fun updateObservationErrors() { + private fun listenToObservationErrors() { val flowList = studyObservations().map { it.observationErrors } val combinedFlow = combine(flowList) { values -> values.toMap() @@ -117,6 +117,10 @@ abstract class ObservationFactory(private val dataManager: ObservationDataManage }.second } + fun updateObservationErrors() { + studyObservations().forEach { it.updateObservationErrors() } + } + fun observation(type: String): Observation? { Napier.i(tag = "ObservationFactory::observation") { "Fetching observation of type: $type" } return observations.firstOrNull { it.observationType.observationType == type }?.apply { 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 52bd2766..177ee448 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 @@ -149,7 +149,7 @@ class ObservationManager( setObservationState(it, ScheduleState.PAUSED) Napier.i(tag = "ObservationManager::pause") { "Recording paused of ${it.scheduleId}" } } - } ?: kotlin.run { + } ?: run { setObservationState(scheduleId, ScheduleState.PAUSED) } currentlyRunning.remove(scheduleId) @@ -158,11 +158,9 @@ class ObservationManager( fun pauseObservationType(type: String) { Napier.i(tag = "ObservationManager::pauseObservationType") { "Pausing Observation type: $type" } runningObservations.filterValues { it.observationType.observationType == type } - .forEach { (key, value) -> + .keys.forEach { key -> Napier.d(tag = "ObservationManager::pauseObservationType") { "Pausing schedule: $key" } - value.stop(key) - setObservationState(key, ScheduleState.PAUSED) - currentlyRunning.remove(key) + dataRecorder.pause(key) Napier.d(tag = "ObservationManager::pauseObservationType") { "Recording paused of $key" } } } @@ -208,10 +206,13 @@ class ObservationManager( } fun updateTaskStates() { - Napier.d(tag = "ObservationManager::updateTaskStates") { "" } scheduleRepository.updateTaskStates(observationFactory, dataRecorder) } + suspend fun updateTaskStatesWithBLEDevices() { + scheduleRepository.updateTaskStatesWithBLEDevices(observationFactory, dataRecorder) + } + fun hasRunningTasks() = currentlyRunning.isNotEmpty() fun allRunningObservations() = runningObservations.toMap() diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt index d8bec911..6cbfc6ef 100644 --- a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt +++ b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/ViewManager.kt @@ -4,6 +4,7 @@ import io.redlink.more.more_app_mutliplatform.extensions.asClosure import io.redlink.more.more_app_mutliplatform.extensions.set import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update object ViewManager { private val _studyIsUpdating = MutableStateFlow(false) @@ -21,7 +22,7 @@ object ViewManager { fun showBLEView(state: Boolean): Boolean { if (!state || !bleViewOpen) { - _showBluetoothView.set(state) + _showBluetoothView.update { state } return state } return false diff --git a/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt b/shared/src/commonMain/kotlin/io/redlink/more/more_app_mutliplatform/viewModels/bluetoothConnection/BluetoothController.kt index efc5b3d2..5bfa8a2f 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,6 +17,8 @@ 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.observations.ObservationManager 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 @@ -26,6 +28,8 @@ import io.redlink.more.more_app_mutliplatform.util.Scope import io.redlink.more.more_app_mutliplatform.util.StudyScope import io.redlink.more.more_app_mutliplatform.viewModels.CoreViewModel import io.redlink.more.more_app_mutliplatform.viewModels.ViewManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -75,8 +79,8 @@ class BluetoothController( } fun startScanningForDevices(bleDeviceSet: Set) { - val pairedDevices = deviceManager.pairedDevices.value if (bleDeviceSet.isNotEmpty()) { + val pairedDevices = deviceManager.pairedDevices.value if (bleDeviceSet.areAllNamesIn(pairedDevices)) { val connectedDevices = deviceManager.connectedDevices.value if (bleDeviceSet.areAllNamesIn(connectedDevices)) { @@ -254,6 +258,18 @@ class BluetoothController( fun isScanningAsClosure(state: (Boolean) -> Unit) = isScanning.asClosure(state) + suspend fun listenToConnectionChanges( + observationFactory: ObservationFactory, + observationManager: ObservationManager + ) { + deviceManager.connectedDevices.collect { + observationFactory.updateObservationErrors() + StudyScope.launch(Dispatchers.IO) { + observationManager.updateTaskStatesWithBLEDevices() + } + } + } + fun resetAll() { Napier.i(tag = "BluetoothController::resetAll") { "Resetting Bluetooth data!" } stopPeriodicScan()