diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5dab6e7c5..c80295aa8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,8 @@ android { defaultConfig { applicationId = "edu.stanford.bdh.engagehf" - versionCode = (project.findProperty("android.injected.version.code") as? String)?.toInt() ?: 1 + versionCode = + (project.findProperty("android.injected.version.code") as? String)?.toInt() ?: 1 versionName = (project.findProperty("android.injected.version.name") as? String) ?: "1.0.0" targetSdk = libs.versions.targetSdk.get().toInt() @@ -49,6 +50,7 @@ dependencies { implementation(project(":modules:onboarding")) implementation(libs.firebase.firestore.ktx) + implementation(libs.firebase.functions.ktx) implementation(libs.androidx.core.i18n) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3a4e7b1e3..3c3cf8b7a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,15 @@ + + + diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModel.kt index 394ebefff..b69cf0d7d 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModel.kt @@ -3,16 +3,18 @@ package edu.stanford.bdh.engagehf.bluetooth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.bluetooth.data.mapper.BluetoothUiStateMapper import edu.stanford.bdh.engagehf.bluetooth.data.models.Action import edu.stanford.bdh.engagehf.bluetooth.data.models.BluetoothUiState import edu.stanford.bdh.engagehf.bluetooth.data.models.UiState import edu.stanford.bdh.engagehf.bluetooth.measurements.MeasurementsRepository import edu.stanford.bdh.engagehf.education.EngageEducationRepository +import edu.stanford.bdh.engagehf.messages.HealthSummaryService import edu.stanford.bdh.engagehf.messages.MessageRepository import edu.stanford.bdh.engagehf.messages.MessagesAction import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent +import edu.stanford.bdh.engagehf.navigation.screens.BottomBarItem import edu.stanford.spezi.core.bluetooth.api.BLEService import edu.stanford.spezi.core.bluetooth.data.model.BLEServiceEvent import edu.stanford.spezi.core.bluetooth.data.model.BLEServiceState @@ -34,9 +36,10 @@ class BluetoothViewModel @Inject internal constructor( private val uiStateMapper: BluetoothUiStateMapper, private val measurementsRepository: MeasurementsRepository, private val messageRepository: MessageRepository, - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, private val navigator: Navigator, private val engageEducationRepository: EngageEducationRepository, + private val healthSummaryService: HealthSummaryService, ) : ViewModel() { private val logger by speziLogger() @@ -94,7 +97,7 @@ class BluetoothViewModel @Inject internal constructor( } is BLEServiceEvent.MeasurementReceived -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) _uiState.update { it.copy( measurementDialog = uiStateMapper.mapToMeasurementDialogUiState( @@ -131,7 +134,11 @@ class BluetoothViewModel @Inject internal constructor( private fun observeMessages() { viewModelScope.launch { messageRepository.observeUserMessages().collect { messages -> - _uiState.update { it.copy(messages = messages) } + _uiState.update { + it.copy( + messages = messages + ) + } } } } @@ -150,47 +157,45 @@ class BluetoothViewModel @Inject internal constructor( is Action.MessageItemClicked -> { viewModelScope.launch { - val mappingResult = uiStateMapper.mapMessagesAction(action.message.action) - if (mappingResult.isSuccess) { - when (val mappedAction = mappingResult.getOrNull()!!) { - is MessagesAction.HealthSummaryAction -> { /* TODO */ - } - - is MessagesAction.MeasurementsAction -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.DoNewMeasurement) - } + uiStateMapper.mapMessagesAction(action.message.action) + .onFailure { error -> + logger.e(error) { "Error while mapping action: ${action.message.action}" } + } + .onSuccess { mappedAction -> + val messageId = action.message.id + when (mappedAction) { + is MessagesAction.HealthSummaryAction -> { + handleHealthSummaryAction(messageId) + } - is MessagesAction.MedicationsAction -> { /* TODO */ - } + is MessagesAction.MeasurementsAction -> { + appScreenEvents.emit(AppScreenEvents.Event.DoNewMeasurement) + } - is MessagesAction.QuestionnaireAction -> { - navigator.navigateTo( - AppNavigationEvent.QuestionnaireScreen( - mappedAction.questionnaireId + is MessagesAction.MedicationsAction -> { + appScreenEvents.emit( + AppScreenEvents.Event.NavigateToTab( + BottomBarItem.MEDICATION + ) ) - ) - } + } - is MessagesAction.VideoSectionAction -> { - viewModelScope.launch { - engageEducationRepository.getVideoBySectionAndVideoId( - mappedAction.videoSectionVideo.videoSectionId, - mappedAction.videoSectionVideo.videoId - ).getOrNull()?.let { video -> - navigator.navigateTo( - EducationNavigationEvent.VideoSectionClicked( - video = video - ) + is MessagesAction.QuestionnaireAction -> { + navigator.navigateTo( + AppNavigationEvent.QuestionnaireScreen( + mappedAction.questionnaireId ) + ) + } + + is MessagesAction.VideoSectionAction -> { + viewModelScope.launch { + handleVideoSectionAction(mappedAction) } } } + messageRepository.completeMessage(messageId = messageId) } - val messageId = action.message.id - messageRepository.completeMessage(messageId = messageId) - } else { - logger.e { "Error while mapping action: ${mappingResult.exceptionOrNull()}" } - } } } @@ -200,6 +205,40 @@ class BluetoothViewModel @Inject internal constructor( } } + private suspend fun handleVideoSectionAction(messageAction: MessagesAction.VideoSectionAction) { + engageEducationRepository.getVideoBySectionAndVideoId( + messageAction.videoSectionVideo.videoSectionId, + messageAction.videoSectionVideo.videoId + ).getOrNull()?.let { video -> + navigator.navigateTo( + EducationNavigationEvent.VideoSectionClicked( + video = video + ) + ) + } + } + + private fun handleHealthSummaryAction(messageId: String) { + val setLoading = { loading: Boolean -> + _uiState.update { + it.copy( + messages = it.messages.map { message -> + if (message.id == messageId) { + message.copy(isLoading = loading) + } else { + message + } + } + ) + } + } + viewModelScope.launch { + setLoading(true) + healthSummaryService.generateHealthSummaryPdf() + setLoading(false) + } + } + public override fun onCleared() { super.onCleared() bleService.stop() diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/component/BottomSheetEvents.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/component/AppScreenEvents.kt similarity index 86% rename from app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/component/BottomSheetEvents.kt rename to app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/component/AppScreenEvents.kt index c97765ec6..d8cef33b7 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/component/BottomSheetEvents.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/component/AppScreenEvents.kt @@ -1,5 +1,6 @@ package edu.stanford.bdh.engagehf.bluetooth.component +import edu.stanford.bdh.engagehf.navigation.screens.BottomBarItem import edu.stanford.spezi.core.coroutines.di.Dispatching import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -10,7 +11,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class BottomSheetEvents @Inject constructor( +class AppScreenEvents @Inject constructor( @Dispatching.IO private val scope: CoroutineScope, ) { private val _events = MutableSharedFlow(replay = 1) @@ -30,5 +31,6 @@ class BottomSheetEvents @Inject constructor( data object AddWeightRecord : Event data object AddBloodPressureRecord : Event data object AddHeartRateRecord : Event + data class NavigateToTab(val bottomBarItem: BottomBarItem) : Event } } diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModel.kt index 299136dc4..43edea210 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModel.kt @@ -2,7 +2,7 @@ package edu.stanford.bdh.engagehf.health import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -10,7 +10,7 @@ import javax.inject.Inject @HiltViewModel class HealthViewModel @Inject constructor( - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() @@ -19,12 +19,12 @@ class HealthViewModel @Inject constructor( when (action) { is Action.AddRecord -> { val event = when (action.tab) { - HealthTab.Weight -> BottomSheetEvents.Event.AddWeightRecord - HealthTab.BloodPressure -> BottomSheetEvents.Event.AddBloodPressureRecord - HealthTab.HeartRate -> BottomSheetEvents.Event.AddHeartRateRecord + HealthTab.Weight -> AppScreenEvents.Event.AddWeightRecord + HealthTab.BloodPressure -> AppScreenEvents.Event.AddBloodPressureRecord + HealthTab.HeartRate -> AppScreenEvents.Event.AddHeartRateRecord else -> return } - bottomSheetEvents.emit(event) + appScreenEvents.emit(event) } is Action.UpdateTab -> { diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/BloodPressureViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/BloodPressureViewModel.kt index 9360dbdaa..f13ba8f07 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/BloodPressureViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/BloodPressureViewModel.kt @@ -3,7 +3,7 @@ package edu.stanford.bdh.engagehf.health.bloodpressure import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthAction import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.bdh.engagehf.health.HealthUiState @@ -19,7 +19,7 @@ import javax.inject.Inject class BloodPressureViewModel @Inject internal constructor( private val uiStateMapper: HealthUiStateMapper, private val healthRepository: HealthRepository, - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, ) : ViewModel() { private val _uiState = MutableStateFlow(HealthUiState.Loading) val uiState = _uiState.asStateFlow() @@ -56,7 +56,7 @@ class BloodPressureViewModel @Inject internal constructor( } HealthAction.DescriptionBottomSheet -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.BloodPressureDescriptionBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.BloodPressureDescriptionBottomSheet) } is HealthAction.ToggleTimeRangeDropdown -> { diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModel.kt index 99a778a12..f1469e2ae 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModel.kt @@ -5,7 +5,7 @@ import androidx.health.connect.client.units.Pressure import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.spezi.core.utils.MessageNotifier import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +19,7 @@ import javax.inject.Inject @HiltViewModel internal class AddBloodPressureBottomSheetViewModel @Inject constructor( - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, private val addBloodPressureBottomSheetUiStateMapper: AddBloodPressureBottomSheetUiStateMapper, private val healthRepository: HealthRepository, private val notifier: MessageNotifier, @@ -62,7 +62,7 @@ internal class AddBloodPressureBottomSheetViewModel @Inject constructor( } Action.CloseSheet -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } Action.CloseUpdateDate -> { @@ -112,7 +112,7 @@ internal class AddBloodPressureBottomSheetViewModel @Inject constructor( healthRepository.saveRecord(bloodPressureRecord).onFailure { notifier.notify("Failed to save blood pressure record") }.onSuccess { - bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } } diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/HeartRateViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/HeartRateViewModel.kt index b81f0d0f5..e694e0362 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/HeartRateViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/HeartRateViewModel.kt @@ -3,7 +3,7 @@ package edu.stanford.bdh.engagehf.health.heartrate import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthAction import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.bdh.engagehf.health.HealthUiState @@ -20,7 +20,7 @@ import javax.inject.Inject class HeartRateViewModel @Inject internal constructor( private val uiStateMapper: HealthUiStateMapper, private val healthRepository: HealthRepository, - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, ) : ViewModel() { private val logger by speziLogger() @@ -60,7 +60,7 @@ class HeartRateViewModel @Inject internal constructor( } is HealthAction.DescriptionBottomSheet -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.HeartRateDescriptionBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.HeartRateDescriptionBottomSheet) } is HealthAction.ToggleTimeRangeDropdown -> { diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModel.kt index b4b13119c..8e79289bd 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModel.kt @@ -4,7 +4,7 @@ import androidx.health.connect.client.records.HeartRateRecord import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.spezi.core.utils.MessageNotifier import kotlinx.coroutines.flow.MutableStateFlow @@ -18,7 +18,7 @@ import javax.inject.Inject @HiltViewModel internal class AddHeartRateBottomSheetViewModel @Inject constructor( - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, private val healthRepository: HealthRepository, private val addHeartRateBottomSheetUiStateMapper: AddHeartRateBottomSheetUiStateMapper, private val notifier: MessageNotifier, @@ -30,7 +30,7 @@ internal class AddHeartRateBottomSheetViewModel @Inject constructor( fun onAction(action: Action) { when (action) { Action.CloseSheet -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } Action.SaveHeartRate -> { @@ -83,7 +83,7 @@ internal class AddHeartRateBottomSheetViewModel @Inject constructor( healthRepository.saveRecord(heartRate).onFailure { notifier.notify("Failed to save heart rate record") }.onSuccess { - bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } } diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/WeightViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/WeightViewModel.kt index 86dc4d8ad..e37d89987 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/WeightViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/WeightViewModel.kt @@ -5,7 +5,7 @@ package edu.stanford.bdh.engagehf.health.weight import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthAction import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.bdh.engagehf.health.HealthUiState @@ -20,7 +20,7 @@ import javax.inject.Inject @HiltViewModel class WeightViewModel @Inject internal constructor( - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, private val uiStateMapper: HealthUiStateMapper, private val healthRepository: HealthRepository, ) : ViewModel() { @@ -68,7 +68,7 @@ class WeightViewModel @Inject internal constructor( } HealthAction.DescriptionBottomSheet -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.WeightDescriptionBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.WeightDescriptionBottomSheet) } is HealthAction.ToggleTimeRangeDropdown -> { diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModel.kt index b1e3bce52..587fa4028 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModel.kt @@ -5,7 +5,7 @@ import androidx.health.connect.client.units.Mass import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.spezi.core.utils.MessageNotifier import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +19,7 @@ import javax.inject.Inject @HiltViewModel class AddWeightBottomSheetViewModel @Inject internal constructor( - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, private val uiStateMapper: AddWeightBottomSheetUiStateMapper, private val healthRepository: HealthRepository, private val notifier: MessageNotifier, @@ -35,7 +35,7 @@ class AddWeightBottomSheetViewModel @Inject internal constructor( } Action.CloseSheet -> { - bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } is Action.UpdateDate -> { @@ -79,7 +79,7 @@ class AddWeightBottomSheetViewModel @Inject internal constructor( healthRepository.saveRecord(it).onFailure { notifier.notify("Failed to save weight record") }.onSuccess { - bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) + appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } } diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryRepository.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryRepository.kt new file mode 100644 index 000000000..2a0959283 --- /dev/null +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryRepository.kt @@ -0,0 +1,35 @@ +package edu.stanford.bdh.engagehf.messages + +import com.google.firebase.functions.FirebaseFunctions +import edu.stanford.spezi.core.coroutines.di.Dispatching +import edu.stanford.spezi.core.logging.speziLogger +import edu.stanford.spezi.core.utils.JsonMap +import edu.stanford.spezi.module.account.manager.UserSessionManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class HealthSummaryRepository @Inject constructor( + private val userSessionManager: UserSessionManager, + private val firebaseFunctions: FirebaseFunctions, + @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, +) { + private val logger by speziLogger() + + suspend fun getHealthSummary(): Result = withContext(ioDispatcher) { + runCatching { + val uid = userSessionManager.getUserUid() + ?: error("User not authenticated") + val result = firebaseFunctions.getHttpsCallable("exportHealthSummary") + .call(mapOf("userId" to uid)) + .await() + val pdfBase64 = (result.data as? JsonMap)?.get("content") as? String + ?: error("Invalid function response") + val pdfBytes = android.util.Base64.decode(pdfBase64, android.util.Base64.DEFAULT) + pdfBytes + }.onFailure { + logger.e(it) { "Error while fetching health summary" } + } + } +} diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryService.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryService.kt new file mode 100644 index 000000000..ed84519b6 --- /dev/null +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryService.kt @@ -0,0 +1,64 @@ +package edu.stanford.bdh.engagehf.messages + +import android.content.Context +import android.content.Intent +import android.os.Environment +import androidx.core.content.FileProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import edu.stanford.spezi.core.coroutines.di.Dispatching +import edu.stanford.spezi.core.logging.speziLogger +import edu.stanford.spezi.core.utils.MessageNotifier +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject + +class HealthSummaryService @Inject constructor( + private val healthSummaryRepository: HealthSummaryRepository, + private val messageNotifier: MessageNotifier, + @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext private val context: Context, +) { + private val logger by speziLogger() + + suspend fun generateHealthSummaryPdf(): Result = withContext(ioDispatcher) { + healthSummaryRepository.getHealthSummary() + .mapCatching { + val savePdfToFile = savePdfToFile(it) + val pdfUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + savePdfToFile + ) + Intent(Intent.ACTION_VIEW).run { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + setDataAndType(pdfUri, MIME_TYPE_PDF) + context.startActivity(this) + } + }.onFailure { + messageNotifier.notify("Failed to generate Health Summary") + } + } + + private fun savePdfToFile(pdfBytes: ByteArray): File { + logger.i { "PDF size: ${pdfBytes.size}" } + val storageDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!storageDir.exists()) { + storageDir.mkdirs() + } + val pdfFile = File(storageDir, FILE_NAME) + FileOutputStream(pdfFile).use { fos -> + fos.write(pdfBytes) + } + logger.i { "PDF saved to file: ${pdfFile.absolutePath}" } + return pdfFile + } + + companion object { + const val FILE_NAME = "engage_hf_health_summary.pdf" + const val MIME_TYPE_PDF = "application/pdf" + } +} diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/Message.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/Message.kt index dc3e41687..320bee974 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/Message.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/Message.kt @@ -14,6 +14,7 @@ data class Message( val title: String, val description: String, val action: String, + val isLoading: Boolean = false, val isExpanded: Boolean = false, ) { diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/MessageItem.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/MessageItem.kt index d8a976765..a6f39977a 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/MessageItem.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/messages/MessageItem.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -137,14 +138,19 @@ fun MessageItem( Button( modifier = Modifier.testIdentifier(MessageItemTestIdentifiers.ACTION_BUTTON), colors = ButtonDefaults.buttonColors(containerColor = primary), + enabled = !message.isLoading, onClick = { onAction(Action.MessageItemClicked(message)) }, ) { - Text( - text = stringResource(R.string.message_item_button_action_text), - color = Colors.onPrimary, - ) + if (message.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(Sizes.Content.small)) + } else { + Text( + text = stringResource(R.string.message_item_button_action_text), + color = Colors.onPrimary, + ) + } } } } diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/components/AccountDialog.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/components/AccountDialog.kt new file mode 100644 index 000000000..a65e715e5 --- /dev/null +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/components/AccountDialog.kt @@ -0,0 +1,210 @@ +package edu.stanford.bdh.engagehf.navigation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import edu.stanford.bdh.engagehf.R +import edu.stanford.bdh.engagehf.navigation.screens.AccountUiState +import edu.stanford.bdh.engagehf.navigation.screens.Action +import edu.stanford.spezi.core.design.component.VerticalSpacer +import edu.stanford.spezi.core.design.theme.Colors +import edu.stanford.spezi.core.design.theme.Colors.onPrimary +import edu.stanford.spezi.core.design.theme.Colors.primary +import edu.stanford.spezi.core.design.theme.Colors.surface +import edu.stanford.spezi.core.design.theme.Sizes +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.TextStyles +import edu.stanford.spezi.core.design.theme.TextStyles.bodyMedium +import edu.stanford.spezi.core.design.theme.TextStyles.headlineMedium +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.theme.lighten + +@Composable +fun AccountDialog(accountUiState: AccountUiState, onAction: (Action) -> Unit) { + Dialog( + onDismissRequest = { + if (!accountUiState.isHealthSummaryLoading) { + onAction(Action.ShowAccountDialog(false)) + } + }, + properties = DialogProperties() + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = surface.lighten(), + modifier = Modifier + .fillMaxWidth() + .padding(Spacings.medium) + ) { + Column( + modifier = Modifier.padding(Spacings.medium), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopStart + ) { + IconButton( + enabled = !accountUiState.isHealthSummaryLoading, + onClick = { onAction(Action.ShowAccountDialog(false)) }, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close_dialog_content_description), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(Sizes.Icon.small) + ) + } + Text( + text = stringResource(R.string.account), style = TextStyles.titleMedium, + modifier = Modifier.align( + Alignment.Center + ) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + accountUiState.initials?.let { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(Sizes.Icon.large) + .background(primary, shape = CircleShape) + ) { + Text( + text = accountUiState.initials, + style = headlineMedium, + color = onPrimary, + ) + } + } + + if (accountUiState.initials == null) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = null, + modifier = Modifier + .size(Sizes.Icon.large), + tint = primary + ) + } + Spacer(modifier = Modifier.width(Spacings.medium)) + Column { + VerticalSpacer() + accountUiState.name?.let { + Text(text = it, style = headlineMedium) + VerticalSpacer(height = Spacings.small) + } + Text(text = accountUiState.email, style = bodyMedium) + VerticalSpacer() + } + } + + HorizontalDivider() + TextButton( + onClick = { + onAction(Action.ShowHealthSummary) + }, + modifier = Modifier + .align(Alignment.Start), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.health_summary), + style = bodyMedium, + modifier = Modifier.weight(1f) + ) + if (accountUiState.isHealthSummaryLoading) { + CircularProgressIndicator( + modifier = Modifier.size(Sizes.Icon.small), + color = primary + ) + } + } + } + HorizontalDivider() + VerticalSpacer() + TextButton( + onClick = { onAction(Action.SignOut) }, + modifier = Modifier + .align(Alignment.Start), + ) { + Text( + text = stringResource(R.string.sign_out), + style = bodyMedium, + color = Colors.error, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +class AppTopBarProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + AccountUiState( + initials = "JD", + isHealthSummaryLoading = false, + name = "John Doe", + email = "john@doe.de" + ), + AccountUiState( + name = "John Doe", + email = "", + isHealthSummaryLoading = true + ), + AccountUiState( + name = null, + email = "john@doe.de" + ) + ) +} + +@ThemePreviews +@Composable +fun AccountDialogPreview( + @PreviewParameter(AppTopBarProvider::class) accountUiState: AccountUiState, +) { + SpeziTheme { + AccountDialog( + accountUiState = accountUiState, + onAction = {} + ) + } +} diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/components/AccountTopAppBarButton.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/components/AccountTopAppBarButton.kt new file mode 100644 index 000000000..533390c9c --- /dev/null +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/components/AccountTopAppBarButton.kt @@ -0,0 +1,37 @@ +package edu.stanford.bdh.engagehf.navigation.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import edu.stanford.bdh.engagehf.navigation.screens.AccountUiState +import edu.stanford.bdh.engagehf.navigation.screens.Action +import edu.stanford.spezi.core.design.theme.Colors +import edu.stanford.spezi.core.design.theme.Sizes + +@Composable +fun AccountTopAppBarButton(accountUiState: AccountUiState, onAction: (Action) -> Unit) { + IconButton(onClick = { + onAction(Action.ShowAccountDialog(true)) + }) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = "Account", + tint = Colors.onPrimary, + modifier = Modifier.size(Sizes.Icon.medium) + ) + } + AnimatedVisibility( + visible = accountUiState.showDialog, + enter = fadeIn(), + exit = fadeOut() + ) { + AccountDialog(accountUiState = accountUiState, onAction = onAction) + } +} diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt index d1885ae75..82cb5529e 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt @@ -1,6 +1,8 @@ package edu.stanford.bdh.engagehf.navigation.screens import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState @@ -19,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -35,9 +38,13 @@ import edu.stanford.bdh.engagehf.health.heartrate.bottomsheet.HeartRateDescripti import edu.stanford.bdh.engagehf.health.weight.bottomsheet.AddWeightBottomSheet import edu.stanford.bdh.engagehf.health.weight.bottomsheet.WeightDescriptionBottomSheet import edu.stanford.bdh.engagehf.medication.ui.MedicationScreen +import edu.stanford.bdh.engagehf.navigation.components.AccountTopAppBarButton import edu.stanford.spezi.core.design.component.AppTopAppBar +import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.utils.extensions.testIdentifier import edu.stanford.spezi.modules.education.videos.EducationScreen +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Composable @@ -60,20 +67,21 @@ fun AppScreen( bottomSheetState = rememberModalBottomSheetState() ) - LaunchedEffect(key1 = uiState.isBottomSheetExpanded) { + LaunchedEffect(key1 = uiState.bottomSheetContent) { launch { - if (uiState.isBottomSheetExpanded) { + if (uiState.bottomSheetContent != null) { bottomSheetScaffoldState.bottomSheetState.expand() } else { bottomSheetScaffoldState.bottomSheetState.hide() } } } - LaunchedEffect(bottomSheetScaffoldState.bottomSheetState) { snapshotFlow { bottomSheetScaffoldState.bottomSheetState.currentValue } - .collect { state -> - onAction(Action.UpdateBottomSheetState(isExpanded = state == SheetValue.Expanded)) + .filter { it == SheetValue.Hidden } + .distinctUntilChanged() + .collect { + onAction(Action.DismissBottomSheet) } } @@ -104,13 +112,24 @@ fun BottomSheetScaffoldContent( AppTopAppBar( modifier = Modifier.testIdentifier(identifier = AppScreenTestIdentifier.TOP_APP_BAR), title = { - Text( - text = stringResource(id = uiState.selectedItem.label), - modifier = Modifier.testIdentifier( - AppScreenTestIdentifier.TOP_APP_BAR_TITLE + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = Spacings.small), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = uiState.selectedItem.label), + modifier = Modifier.testIdentifier( + AppScreenTestIdentifier.TOP_APP_BAR_TITLE + ) ) - ) - }) + } + }, + actions = { + AccountTopAppBarButton(uiState.accountUiState, onAction = onAction) + } + ) }, bottomBar = { Column { @@ -128,7 +147,13 @@ fun BottomSheetScaffoldContent( ), icon = { Icon( - painter = painterResource(id = if (uiState.selectedItem == item) item.selectedIcon else item.icon), + painter = painterResource( + id = if (uiState.selectedItem == item) { + item.selectedIcon + } else { + item.icon + } + ), contentDescription = null ) }, diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModel.kt index da0cfda65..4aa52e35c 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModel.kt @@ -6,7 +6,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import edu.stanford.bdh.engagehf.R -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents +import edu.stanford.bdh.engagehf.messages.HealthSummaryService +import edu.stanford.spezi.module.account.manager.UserSessionManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -16,7 +18,9 @@ import edu.stanford.spezi.core.design.R.drawable as DesignR @HiltViewModel class AppScreenViewModel @Inject constructor( - private val bottomSheetEvents: BottomSheetEvents, + private val appScreenEvents: AppScreenEvents, + private val userSessionManager: UserSessionManager, + private val healthSummaryService: HealthSummaryService, ) : ViewModel() { private val _uiState = MutableStateFlow( AppUiState( @@ -33,46 +37,42 @@ class AppScreenViewModel @Inject constructor( private fun setup() { viewModelScope.launch { - bottomSheetEvents.events.collect { event -> - val (isExpanded, content) = when (event) { - BottomSheetEvents.Event.NewMeasurementAction -> { - true to BottomSheetContent.NEW_MEASUREMENT_RECEIVED - } + _uiState.update { uiState -> + val userInfo = userSessionManager.getUserInfo() + uiState.copy( + accountUiState = uiState.accountUiState.copy( + email = userInfo.email, + name = userInfo.name, + initials = userInfo.name?.split(" ") + ?.mapNotNull { it.firstOrNull()?.toString() }?.joinToString(""), + ) + ) + } - BottomSheetEvents.Event.DoNewMeasurement -> { - true to BottomSheetContent.DO_NEW_MEASUREMENT + appScreenEvents.events.collect { event -> + if (event is AppScreenEvents.Event.NavigateToTab) { + _uiState.update { + it.copy(selectedItem = event.bottomBarItem) } + } else { + val bottomSheetContent = when (event) { + AppScreenEvents.Event.NewMeasurementAction -> + BottomSheetContent.NEW_MEASUREMENT_RECEIVED - BottomSheetEvents.Event.CloseBottomSheet -> { - false to null - } + AppScreenEvents.Event.DoNewMeasurement -> + BottomSheetContent.DO_NEW_MEASUREMENT - BottomSheetEvents.Event.WeightDescriptionBottomSheet -> { - true to BottomSheetContent.WEIGHT_DESCRIPTION_INFO - } + AppScreenEvents.Event.WeightDescriptionBottomSheet -> + BottomSheetContent.WEIGHT_DESCRIPTION_INFO - BottomSheetEvents.Event.AddWeightRecord -> { - true to BottomSheetContent.ADD_WEIGHT_RECORD - } + AppScreenEvents.Event.AddWeightRecord -> + BottomSheetContent.ADD_WEIGHT_RECORD - BottomSheetEvents.Event.AddBloodPressureRecord -> { - true to BottomSheetContent.ADD_BLOOD_PRESSURE_RECORD + else -> null } - - BottomSheetEvents.Event.AddHeartRateRecord -> { - true to BottomSheetContent.ADD_HEART_RATE_RECORD + _uiState.update { + it.copy(bottomSheetContent = bottomSheetContent) } - - BottomSheetEvents.Event.BloodPressureDescriptionBottomSheet -> { - true to BottomSheetContent.BLOOD_PRESSURE_DESCRIPTION_INFO - } - - BottomSheetEvents.Event.HeartRateDescriptionBottomSheet -> { - true to BottomSheetContent.HEART_RATE_DESCRIPTION_INFO - } - } - _uiState.update { - it.copy(isBottomSheetExpanded = isExpanded, bottomSheetContent = content) } } } @@ -84,8 +84,38 @@ class AppScreenViewModel @Inject constructor( _uiState.update { it.copy(selectedItem = action.selectedBottomBarItem) } } - is Action.UpdateBottomSheetState -> { - _uiState.update { it.copy(isBottomSheetExpanded = action.isExpanded) } + is Action.ShowAccountDialog -> { + _uiState.update { it.copy(accountUiState = it.accountUiState.copy(showDialog = action.showDialog)) } + } + + Action.ShowHealthSummary -> { + viewModelScope.launch { + _uiState.update { + it.copy( + accountUiState = it.accountUiState.copy( + isHealthSummaryLoading = true + ) + ) + } + healthSummaryService.generateHealthSummaryPdf() + _uiState.update { + it.copy( + accountUiState = it.accountUiState.copy( + isHealthSummaryLoading = false, + showDialog = false + ) + ) + } + } + } + + Action.SignOut -> { + _uiState.update { it.copy(accountUiState = it.accountUiState.copy(showDialog = false)) } + /* TODO: Implement sign out */ + } + + Action.DismissBottomSheet -> { + _uiState.update { it.copy(bottomSheetContent = null) } } } } @@ -94,8 +124,16 @@ class AppScreenViewModel @Inject constructor( data class AppUiState( val items: List, val selectedItem: BottomBarItem, - val isBottomSheetExpanded: Boolean = false, val bottomSheetContent: BottomSheetContent? = null, + val accountUiState: AccountUiState = AccountUiState(), +) + +data class AccountUiState( + val showDialog: Boolean = false, + val email: String = "", + val name: String? = null, + val initials: String? = null, + val isHealthSummaryLoading: Boolean = false, ) enum class BottomSheetContent { @@ -111,7 +149,10 @@ enum class BottomSheetContent { sealed interface Action { data class UpdateSelectedBottomBarItem(val selectedBottomBarItem: BottomBarItem) : Action - data class UpdateBottomSheetState(val isExpanded: Boolean) : Action + data object ShowHealthSummary : Action + data object SignOut : Action + data class ShowAccountDialog(val showDialog: Boolean) : Action + data object DismissBottomSheet : Action } enum class BottomBarItem( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 38941c7ea..ef357dc0a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -21,7 +21,7 @@ Keine Empfehlungen für Medikamente Aktuelle Dosis: Ziel Dosis: - K.A. + k.A. Aktuell Ziel Täglich @@ -58,4 +58,8 @@ Blutdruck ANGABEN ZUR MESSUNG Herzfrequenz + Dialog schließen + Ausloggen + Gesundheitszusammenfassung + Account diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 200abe983..5adb02aac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,10 @@ Weight BP Heart Rate + Close dialog + Sign Out + Health Summary + Account Cancel OK Time diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..a075ef96b --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModelTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModelTest.kt index 196d7ff33..a93d8f389 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModelTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/bluetooth/BluetoothViewModelTest.kt @@ -4,7 +4,7 @@ import androidx.health.connect.client.records.BloodPressureRecord import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.WeightRecord import com.google.common.truth.Truth.assertThat -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.bluetooth.data.mapper.BluetoothUiStateMapper import edu.stanford.bdh.engagehf.bluetooth.data.models.Action import edu.stanford.bdh.engagehf.bluetooth.data.models.BluetoothUiState @@ -12,12 +12,14 @@ import edu.stanford.bdh.engagehf.bluetooth.data.models.MeasurementDialogUiState import edu.stanford.bdh.engagehf.bluetooth.data.models.VitalDisplayData import edu.stanford.bdh.engagehf.bluetooth.measurements.MeasurementsRepository import edu.stanford.bdh.engagehf.education.EngageEducationRepository +import edu.stanford.bdh.engagehf.messages.HealthSummaryService import edu.stanford.bdh.engagehf.messages.Message import edu.stanford.bdh.engagehf.messages.MessageRepository import edu.stanford.bdh.engagehf.messages.MessageType import edu.stanford.bdh.engagehf.messages.MessagesAction import edu.stanford.bdh.engagehf.messages.VideoSectionVideo import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent +import edu.stanford.bdh.engagehf.navigation.screens.BottomBarItem import edu.stanford.spezi.core.bluetooth.api.BLEService import edu.stanford.spezi.core.bluetooth.data.model.BLEServiceEvent import edu.stanford.spezi.core.bluetooth.data.model.BLEServiceState @@ -49,11 +51,12 @@ class BluetoothViewModelTest { private val measurementsRepository = mockk(relaxed = true) private val messageRepository = mockk(relaxed = true) private val engageEducationRepository = mockk(relaxed = true) + private val healthSummaryService = mockk(relaxed = true) private val bleServiceState = MutableStateFlow(BLEServiceState.Idle) private val bleServiceEvents = MutableSharedFlow() private val readyUiState: BluetoothUiState.Ready = mockk() - private val bottomSheetEvents = mockk(relaxed = true) + private val appScreenEvents = mockk(relaxed = true) private val navigator = mockk(relaxed = true) private val messageAction = "some-action" private val messageId = "some-id" @@ -241,7 +244,7 @@ class BluetoothViewModelTest { bleServiceEvents.emit(event) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + verify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } assertBluetothUiState(state = BluetoothUiState.Idle) assertThat(bluetoothViewModel.uiState.value.measurementDialog).isEqualTo( measurementDialog @@ -387,28 +390,6 @@ class BluetoothViewModelTest { coVerify { messageRepository.completeMessage(messageId = messageId) } } - @Test - fun `it should do nothing on MessageItemClicked with for TODO actions`() { - // given - val action = Action.MessageItemClicked(message = message) - val todoActions = listOf( - MessagesAction.HealthSummaryAction, - MessagesAction.MedicationsAction, - ) - createViewModel() - val initialState = bluetoothViewModel.uiState.value - - todoActions.forEach { - every { uiStateMapper.mapMessagesAction(messageAction) } returns Result.success(it) - - // when - bluetoothViewModel.onAction(action = action) - - // then - assertThat(bluetoothViewModel.uiState.value).isEqualTo(initialState) - } - } - @Test fun `it should handle MeasurementsAction correctly`() { // given @@ -422,7 +403,7 @@ class BluetoothViewModelTest { bluetoothViewModel.onAction(action = action) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.DoNewMeasurement) } + verify { appScreenEvents.emit(AppScreenEvents.Event.DoNewMeasurement) } } @Test @@ -468,6 +449,38 @@ class BluetoothViewModelTest { verify { navigator.navigateTo(EducationNavigationEvent.VideoSectionClicked(video)) } } + @Test + fun `it should handle health summary action correctly`() = runTestUnconfined { + // given + val action = Action.MessageItemClicked(message = message) + every { + uiStateMapper.mapMessagesAction(messageAction) + } returns Result.success(MessagesAction.HealthSummaryAction) + createViewModel() + + // when + bluetoothViewModel.onAction(action = action) + + // then + coVerify { healthSummaryService.generateHealthSummaryPdf() } + } + + @Test + fun `it should handle medication change action correctly`() { + // given + val action = Action.MessageItemClicked(message = message) + every { + uiStateMapper.mapMessagesAction(messageAction) + } returns Result.success(MessagesAction.MedicationsAction) + createViewModel() + + // when + bluetoothViewModel.onAction(action = action) + + // then + verify { appScreenEvents.emit(AppScreenEvents.Event.NavigateToTab(BottomBarItem.MEDICATION)) } + } + @Test fun `it should handle toggle expand action correctly`() { // given @@ -514,9 +527,10 @@ class BluetoothViewModelTest { uiStateMapper = uiStateMapper, measurementsRepository = measurementsRepository, messageRepository = messageRepository, - bottomSheetEvents = bottomSheetEvents, + appScreenEvents = appScreenEvents, navigator = navigator, engageEducationRepository = engageEducationRepository, + healthSummaryService = healthSummaryService ) } } diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapperTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapperTest.kt index 5ad6da20f..cb3b7fa0a 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapperTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapperTest.kt @@ -4,7 +4,6 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.firestore.CollectionReference import com.google.firebase.firestore.DocumentSnapshot import edu.stanford.bdh.engagehf.localization.LocalizedMapReader -import edu.stanford.spezi.core.utils.JsonMap import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -33,9 +32,9 @@ class VideoSectionDocumentToVideoSectionMapperTest { @Test fun `it should return null if no videos found`() = runTest { // given - val jsonMap: JsonMap = mockk() + val jsonData = mockk>() val document: DocumentSnapshot = mockk { - every { data } returns jsonMap + every { data } returns jsonData every { getLong("orderIndex") } returns 1L val collectionReference: CollectionReference = mockk { every { get() } returns mockk { @@ -54,8 +53,8 @@ class VideoSectionDocumentToVideoSectionMapperTest { every { collection("videos") } returns collectionReference } } - every { localizedMapReader.get("title", jsonMap) } returns "Test Title" - every { localizedMapReader.get("description", jsonMap) } returns "Test Description" + every { localizedMapReader.get("title", jsonData) } returns "Test Title" + every { localizedMapReader.get("description", jsonData) } returns "Test Description" // when val result = mapper.map(document) @@ -67,7 +66,7 @@ class VideoSectionDocumentToVideoSectionMapperTest { @Test fun `it should return a valid VideoSection`() = runTest { // given - val videoJsonMap: JsonMap = mockk() + val videoJsonMap: Map = mockk() val videoDocument: DocumentSnapshot = mockk { every { data } returns videoJsonMap every { exists() } returns true @@ -78,7 +77,7 @@ class VideoSectionDocumentToVideoSectionMapperTest { every { localizedMapReader.get("title", videoJsonMap) } returns "Video Title" every { localizedMapReader.get("description", videoJsonMap) } returns "Video Description" - val videoSectionJsonMap: JsonMap = mockk() + val videoSectionJsonMap: Map = mockk() val document: DocumentSnapshot = mockk { every { data } returns videoSectionJsonMap every { getLong("orderIndex") } returns 1L diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModelTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModelTest.kt index 96180a3d8..30dd03959 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModelTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/HealthViewModelTest.kt @@ -1,7 +1,7 @@ package edu.stanford.bdh.engagehf.health import com.google.common.truth.Truth.assertThat -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.spezi.core.testing.verifyNever import io.mockk.mockk import io.mockk.verify @@ -9,10 +9,10 @@ import org.junit.Test class HealthViewModelTest { - private val bottomSheetEvents: BottomSheetEvents = mockk(relaxed = true) + private val appScreenEvents: AppScreenEvents = mockk(relaxed = true) private val tabs = HealthTab.entries - private val viewModel: HealthViewModel = HealthViewModel(bottomSheetEvents) + private val viewModel: HealthViewModel = HealthViewModel(appScreenEvents) @Test fun `it should have the correct initial state`() { @@ -50,7 +50,7 @@ class HealthViewModelTest { viewModel.onAction(action) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.AddWeightRecord) } + verify { appScreenEvents.emit(AppScreenEvents.Event.AddWeightRecord) } } @Test @@ -62,7 +62,7 @@ class HealthViewModelTest { viewModel.onAction(action) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.AddBloodPressureRecord) } + verify { appScreenEvents.emit(AppScreenEvents.Event.AddBloodPressureRecord) } } @Test @@ -74,7 +74,7 @@ class HealthViewModelTest { viewModel.onAction(action) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.AddHeartRateRecord) } + verify { appScreenEvents.emit(AppScreenEvents.Event.AddHeartRateRecord) } } @Test @@ -86,6 +86,6 @@ class HealthViewModelTest { viewModel.onAction(action) // then - verifyNever { bottomSheetEvents.emit(any()) } + verifyNever { appScreenEvents.emit(any()) } } } diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModelTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModelTest.kt index 714bebb6a..d040a79aa 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModelTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/bloodpressure/bottomsheet/AddBloodPressureBottomSheetViewModelTest.kt @@ -1,7 +1,7 @@ package edu.stanford.bdh.engagehf.health.bloodpressure.bottomsheet import com.google.common.truth.Truth.assertThat -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.spezi.core.testing.CoroutineTestRule import edu.stanford.spezi.core.utils.MessageNotifier @@ -22,14 +22,14 @@ class AddBloodPressureBottomSheetViewModelTest { @get:Rule val coroutineTestRule = CoroutineTestRule() - private var bottomSheetEvents: BottomSheetEvents = mockk(relaxed = true) + private val appScreenEvents: AppScreenEvents = mockk(relaxed = true) private var healthRepository: HealthRepository = mockk(relaxed = true) private val uiStateMapper: AddBloodPressureBottomSheetUiStateMapper = mockk(relaxed = true) private val notifier: MessageNotifier = mockk(relaxed = true) private val viewModel: AddBloodPressureBottomSheetViewModel by lazy { AddBloodPressureBottomSheetViewModel( - bottomSheetEvents = bottomSheetEvents, + appScreenEvents = appScreenEvents, addBloodPressureBottomSheetUiStateMapper = uiStateMapper, healthRepository = healthRepository, notifier = notifier @@ -75,7 +75,7 @@ class AddBloodPressureBottomSheetViewModelTest { viewModel.onAction(action) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + verify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } @Test @@ -97,7 +97,7 @@ class AddBloodPressureBottomSheetViewModelTest { viewModel.onAction(AddBloodPressureBottomSheetViewModel.Action.CloseSheet) // then - coVerify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + coVerify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } @Test diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModelTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModelTest.kt index 8e1031282..faeb2e224 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModelTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/heartrate/bottomsheet/AddHeartRateBottomSheetViewModelTest.kt @@ -1,7 +1,7 @@ package edu.stanford.bdh.engagehf.health.heartrate.bottomsheet import com.google.common.truth.Truth.assertThat -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.bdh.engagehf.health.bloodpressure.bottomsheet.TimePickerState import edu.stanford.spezi.core.testing.CoroutineTestRule @@ -23,14 +23,14 @@ class AddHeartRateBottomSheetViewModelTest { @get:Rule val coroutineTestRule = CoroutineTestRule() - private var bottomSheetEvents: BottomSheetEvents = mockk(relaxed = true) + private val appScreenEvents: AppScreenEvents = mockk(relaxed = true) private var healthRepository: HealthRepository = mockk(relaxed = true) private val uiStateMapper: AddHeartRateBottomSheetUiStateMapper = mockk(relaxed = true) private val notifier: MessageNotifier = mockk(relaxed = true) private val viewModel: AddHeartRateBottomSheetViewModel by lazy { AddHeartRateBottomSheetViewModel( - bottomSheetEvents = bottomSheetEvents, + appScreenEvents = appScreenEvents, healthRepository = healthRepository, addHeartRateBottomSheetUiStateMapper = uiStateMapper, notifier = notifier @@ -75,7 +75,7 @@ class AddHeartRateBottomSheetViewModelTest { viewModel.onAction(action) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + verify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } @Test @@ -97,7 +97,7 @@ class AddHeartRateBottomSheetViewModelTest { viewModel.onAction(AddHeartRateBottomSheetViewModel.Action.CloseSheet) // then - coVerify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + coVerify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } @Test diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModelTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModelTest.kt index 83ee837d6..011d45c98 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModelTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/health/weight/bottomsheet/AddWeightBottomSheetViewModelTest.kt @@ -1,7 +1,7 @@ package edu.stanford.bdh.engagehf.health.weight.bottomsheet import com.google.common.truth.Truth.assertThat -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents import edu.stanford.bdh.engagehf.health.HealthRepository import edu.stanford.bdh.engagehf.health.bloodpressure.bottomsheet.TimePickerState import edu.stanford.spezi.core.testing.CoroutineTestRule @@ -24,14 +24,14 @@ class AddWeightBottomSheetViewModelTest { @get:Rule val coroutineTestRule = CoroutineTestRule() - private var bottomSheetEvents: BottomSheetEvents = mockk(relaxed = true) + private var appScreenEvents: AppScreenEvents = mockk(relaxed = true) private var healthRepository: HealthRepository = mockk(relaxed = true) private val uiStateMapper: AddWeightBottomSheetUiStateMapper = mockk(relaxed = true) private val notifier: MessageNotifier = mockk(relaxed = true) private val viewModel: AddWeightBottomSheetViewModel by lazy { AddWeightBottomSheetViewModel( - bottomSheetEvents = bottomSheetEvents, + appScreenEvents = appScreenEvents, healthRepository = healthRepository, uiStateMapper = uiStateMapper, notifier = notifier @@ -77,7 +77,7 @@ class AddWeightBottomSheetViewModelTest { viewModel.onAction(action) // then - verify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + verify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } @Test @@ -99,7 +99,7 @@ class AddWeightBottomSheetViewModelTest { viewModel.onAction(AddWeightBottomSheetViewModel.Action.CloseSheet) // then - coVerify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + coVerify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } @Test @@ -129,7 +129,7 @@ class AddWeightBottomSheetViewModelTest { // then coVerify { healthRepository.saveRecord(any()) } - coVerify { bottomSheetEvents.emit(BottomSheetEvents.Event.CloseBottomSheet) } + coVerify { appScreenEvents.emit(AppScreenEvents.Event.CloseBottomSheet) } } @Test diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryRepositoryTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryRepositoryTest.kt new file mode 100644 index 000000000..22e504e93 --- /dev/null +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/messages/HealthSummaryRepositoryTest.kt @@ -0,0 +1,56 @@ +package edu.stanford.bdh.engagehf.messages + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.functions.FirebaseFunctions +import edu.stanford.spezi.core.testing.runTestUnconfined +import edu.stanford.spezi.module.account.manager.UserSessionManager +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test + +class HealthSummaryRepositoryTest { + private val uid = "some-uid" + private val userSessionManager: UserSessionManager = mockk { + every { getUserUid() } returns uid + } + private val firebaseFunctions: FirebaseFunctions = mockk() + private val ioDispatcher = UnconfinedTestDispatcher() + + private val repository = HealthSummaryRepository( + userSessionManager = userSessionManager, + firebaseFunctions = firebaseFunctions, + ioDispatcher = ioDispatcher + ) + + @Test + fun `getHealthSummary returns failure when user is not authenticated`() = + runTestUnconfined { + // given + every { userSessionManager.getUserUid() } returns null + + // when + val result = repository.getHealthSummary() + + // then + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `getHealthSummary returns failure when function call fails`() = runTestUnconfined { + // given + val exception = Exception("Function call failed") + coEvery { + firebaseFunctions.getHttpsCallable(any()) + } throws exception + + // when + val result = repository.getHealthSummary() + + // then + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(exception) + } +} diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModelTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModelTest.kt index f40e6c51f..9ba08507b 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModelTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreenViewModelTest.kt @@ -1,9 +1,12 @@ package edu.stanford.bdh.engagehf.navigation.screens import com.google.common.truth.Truth.assertThat -import edu.stanford.bdh.engagehf.bluetooth.component.BottomSheetEvents +import edu.stanford.bdh.engagehf.bluetooth.component.AppScreenEvents +import edu.stanford.bdh.engagehf.messages.HealthSummaryService import edu.stanford.spezi.core.testing.CoroutineTestRule import edu.stanford.spezi.core.testing.runTestUnconfined +import edu.stanford.spezi.module.account.manager.UserSessionManager +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableSharedFlow @@ -16,16 +19,20 @@ class AppScreenViewModelTest { @get:Rule val coroutineTestRule = CoroutineTestRule() - private val bottomSheetEvents: BottomSheetEvents = mockk(relaxed = true) - private val bottomSheetEventsFlow = MutableSharedFlow() + private val appScreenEvents: AppScreenEvents = mockk(relaxed = true) + private val userSessionManager: UserSessionManager = mockk(relaxed = true) + private val healthSummaryService: HealthSummaryService = mockk(relaxed = true) + private val appScreenEventsFlow = MutableSharedFlow() private lateinit var viewModel: AppScreenViewModel @Before fun setup() { - every { bottomSheetEvents.events } returns bottomSheetEventsFlow + every { appScreenEvents.events } returns appScreenEventsFlow viewModel = AppScreenViewModel( - bottomSheetEvents = bottomSheetEvents + appScreenEvents = appScreenEvents, + userSessionManager = userSessionManager, + healthSummaryService = healthSummaryService ) } @@ -56,14 +63,13 @@ class AppScreenViewModelTest { fun `given NewMeasurementAction is received then uiState should be updated`() = runTestUnconfined { // Given - val event = BottomSheetEvents.Event.NewMeasurementAction + val event = AppScreenEvents.Event.NewMeasurementAction // When - bottomSheetEventsFlow.emit(event) + appScreenEventsFlow.emit(event) // Then val updatedUiState = viewModel.uiState.value - assertThat(updatedUiState.isBottomSheetExpanded).isTrue() assertThat(updatedUiState.bottomSheetContent).isEqualTo(BottomSheetContent.NEW_MEASUREMENT_RECEIVED) } @@ -71,14 +77,13 @@ class AppScreenViewModelTest { fun `given DoNewMeasurement is received then uiState should be updated`() = runTestUnconfined { // Given - val event = BottomSheetEvents.Event.DoNewMeasurement + val event = AppScreenEvents.Event.DoNewMeasurement // When - bottomSheetEventsFlow.emit(event) + appScreenEventsFlow.emit(event) // Then val updatedUiState = viewModel.uiState.value - assertThat(updatedUiState.isBottomSheetExpanded).isTrue() assertThat(updatedUiState.bottomSheetContent).isEqualTo(BottomSheetContent.DO_NEW_MEASUREMENT) } @@ -86,14 +91,13 @@ class AppScreenViewModelTest { fun `given CloseBottomSheet is received then uiState should be updated`() = runTestUnconfined { // Given - val event = BottomSheetEvents.Event.CloseBottomSheet + val event = AppScreenEvents.Event.CloseBottomSheet // When - bottomSheetEventsFlow.emit(event) + appScreenEventsFlow.emit(event) // Then val updatedUiState = viewModel.uiState.value - assertThat(updatedUiState.isBottomSheetExpanded).isFalse() assertThat(updatedUiState.bottomSheetContent).isNull() } @@ -101,14 +105,13 @@ class AppScreenViewModelTest { fun `given WeightDescriptionBottomSheet is received then uiState should be updated`() = runTestUnconfined { // Given - val event = BottomSheetEvents.Event.WeightDescriptionBottomSheet + val event = AppScreenEvents.Event.WeightDescriptionBottomSheet // When - bottomSheetEventsFlow.emit(event) + appScreenEventsFlow.emit(event) // Then val updatedUiState = viewModel.uiState.value - assertThat(updatedUiState.isBottomSheetExpanded).isTrue() assertThat(updatedUiState.bottomSheetContent).isEqualTo(BottomSheetContent.WEIGHT_DESCRIPTION_INFO) } @@ -116,14 +119,54 @@ class AppScreenViewModelTest { fun `given AddWeightRecord is received then uiState should be updated`() = runTestUnconfined { // Given - val event = BottomSheetEvents.Event.AddWeightRecord + val event = AppScreenEvents.Event.AddWeightRecord // When - bottomSheetEventsFlow.emit(event) + appScreenEventsFlow.emit(event) // Then val updatedUiState = viewModel.uiState.value - assertThat(updatedUiState.isBottomSheetExpanded).isTrue() assertThat(updatedUiState.bottomSheetContent).isEqualTo(BottomSheetContent.ADD_WEIGHT_RECORD) } + + @Test + fun `given ShowAccountDialog is received then uiState should be updated`() = + runTestUnconfined { + // Given + val event = Action.ShowAccountDialog(showDialog = true) + + // When + viewModel.onAction(event) + + // Then + val updatedUiState = viewModel.uiState.value + assertThat(updatedUiState.accountUiState.showDialog).isTrue() + } + + @Test + fun `given SignOut is received then uiState should be updated`() = + runTestUnconfined { + // Given + val event = Action.SignOut + + // When + viewModel.onAction(event) + + // Then + val updatedUiState = viewModel.uiState.value + assertThat(updatedUiState.accountUiState.showDialog).isFalse() + } + + @Test + fun `given ShowHealthSummary is received then healthSummaryService should be called`() = + runTestUnconfined { + // Given + val event = Action.ShowHealthSummary + + // When + viewModel.onAction(event) + + // Then + coVerify { healthSummaryService.generateHealthSummaryPdf() } + } } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/TopAppBar.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/TopAppBar.kt index a2dd68f69..d2297fe1b 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/TopAppBar.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/TopAppBar.kt @@ -1,5 +1,6 @@ package edu.stanford.spezi.core.design.component +import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -14,6 +15,7 @@ fun AppTopAppBar( modifier: Modifier = Modifier, title: @Composable () -> Unit, navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( modifier = modifier, @@ -25,6 +27,7 @@ fun AppTopAppBar( actionIconContentColor = onPrimary ), title = title, - navigationIcon = navigationIcon + navigationIcon = navigationIcon, + actions = actions, ) } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserInfo.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserInfo.kt new file mode 100644 index 000000000..35165ddef --- /dev/null +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserInfo.kt @@ -0,0 +1,6 @@ +package edu.stanford.spezi.module.account.manager + +data class UserInfo( + val email: String, + val name: String?, +) diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt index a672b4234..63be9dca5 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt @@ -21,6 +21,7 @@ interface UserSessionManager { suspend fun getUserState(): UserState fun observeUserState(): Flow fun getUserUid(): String? + fun getUserInfo(): UserInfo } @Singleton @@ -32,19 +33,20 @@ internal class UserSessionManagerImpl @Inject constructor( ) : UserSessionManager { private val logger by speziLogger() - override suspend fun uploadConsentPdf(pdfBytes: ByteArray): Result = withContext(ioDispatcher) { - runCatching { - val currentUser = firebaseAuth.currentUser ?: error("User not available") - val inputStream = ByteArrayInputStream(pdfBytes) - logger.i { "Uploading file to Firebase Storage" } - val uploaded = firebaseStorage - .getReference("users/${currentUser.uid}/consent/consent.pdf") - .putStream(inputStream) - .await().task.isSuccessful + override suspend fun uploadConsentPdf(pdfBytes: ByteArray): Result = + withContext(ioDispatcher) { + runCatching { + val currentUser = firebaseAuth.currentUser ?: error("User not available") + val inputStream = ByteArrayInputStream(pdfBytes) + logger.i { "Uploading file to Firebase Storage" } + val uploaded = firebaseStorage + .getReference("users/${currentUser.uid}/consent/consent.pdf") + .putStream(inputStream) + .await().task.isSuccessful - if (!uploaded) error("Failed to upload consent.pdf") + if (!uploaded) error("Failed to upload consent.pdf") + } } - } override suspend fun getUserState(): UserState { val user = firebaseAuth.currentUser @@ -69,6 +71,14 @@ internal class UserSessionManagerImpl @Inject constructor( override fun getUserUid(): String? = firebaseAuth.uid + override fun getUserInfo(): UserInfo { + val user = firebaseAuth.currentUser + return UserInfo( + email = user?.email ?: "", + name = user?.displayName?.takeIf { it.isNotBlank() }, + ) + } + private suspend fun hasConsented(): Boolean = withContext(ioDispatcher) { runCatching { val uid = getUserUid() ?: error("No uid available") diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt index 00dd51cfc..a4179ef82 100644 --- a/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt @@ -207,6 +207,26 @@ class UserSessionManagerTest { assertThat(result).isEqualTo(uid) } + @Test + fun `it should return the correct user info`() { + // given + val eMail = "test@test.de" + val name = "Test User" + val firebaseUser: FirebaseUser = mockk { + every { email } returns eMail + every { displayName } returns name + } + every { firebaseAuth.currentUser } returns firebaseUser + createUserSessionManager() + + // when + val result = userSessionManager.getUserInfo() + + // then + assertThat(result.email).isEqualTo(eMail) + assertThat(result.name).isEqualTo(name) + } + private suspend fun assertObservedState(userState: UserState, scope: TestScope) { val slot = slot() var capturedState: UserState? = null