Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MessagesAction Update to also handle non-actionable messages without failing #168

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import edu.stanford.bdh.engagehf.messages.Message
import edu.stanford.bdh.engagehf.messages.MessageType
import edu.stanford.bdh.engagehf.bluetooth.data.mapper.MessageActionMapper
import edu.stanford.bdh.engagehf.messages.MessageAction
import edu.stanford.bdh.engagehf.messages.MessagesHandler
import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent
import edu.stanford.bdh.engagehf.navigation.Routes
Expand All @@ -32,6 +32,7 @@ class MainActivityViewModel @Inject constructor(
private val navigator: Navigator,
private val userSessionManager: UserSessionManager,
private val messageNotifier: MessageNotifier,
private val messageActionMapper: MessageActionMapper,
private val messagesHandler: MessagesHandler,
) : ViewModel() {
private val logger by speziLogger()
Expand All @@ -53,13 +54,10 @@ class MainActivityViewModel @Inject constructor(
firebaseMessage?.messageId?.let { messageId ->
viewModelScope.launch {
messagesHandler.handle(
message = Message(
id = messageId, // Is needed to dismiss the message
type = MessageType.Unknown, // We don't need the type, since we directly use the action
title = "", // We don't need the title, since we directly use the action
action = firebaseMessage.action, // We directly use the action
isDismissible = firebaseMessage.isDismissible == true
)
messageId = messageId,
isDismissible = firebaseMessage.isDismissible != false,
action = messageActionMapper.map(firebaseMessage.action)
.getOrElse { MessageAction.UnknownAction },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext
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.MessageUiModel
import edu.stanford.bdh.engagehf.bluetooth.data.models.UiState
import edu.stanford.bdh.engagehf.bluetooth.measurements.MeasurementsRepository
import edu.stanford.bdh.engagehf.bluetooth.service.EngageBLEService
import edu.stanford.bdh.engagehf.bluetooth.service.EngageBLEServiceEvent
import edu.stanford.bdh.engagehf.bluetooth.service.EngageBLEServiceState
import edu.stanford.bdh.engagehf.messages.Message
import edu.stanford.bdh.engagehf.messages.MessagesHandler
import edu.stanford.bdh.engagehf.navigation.screens.BottomBarItem
import edu.stanford.spezi.core.logging.speziLogger
Expand Down Expand Up @@ -118,9 +118,17 @@ class HomeViewModel @Inject internal constructor(
private fun observeMessages() {
viewModelScope.launch {
messagesHandler.observeUserMessages().collect { messages ->
_uiState.update {
it.copy(
messages = messages
_uiState.update { uiState ->
uiState.copy(
messages = messages.map { message ->
// We try to replicate the existing message model's state
// Otherwise a new incoming message would reset all individual
// isLoading/isExpanded properties of all messages to the default value.
uiState.messages
.find { it.message.id == message.id }
?.copy(message = message)
?: MessageUiModel(message = message)
}
)
}
}
Expand All @@ -144,7 +152,7 @@ class HomeViewModel @Inject internal constructor(
}

is Action.MessageItemClicked -> {
handleMessage(message = action.message)
handleMessage(model = action.message)
}

is Action.ToggleExpand -> {
Expand Down Expand Up @@ -190,12 +198,12 @@ class HomeViewModel @Inject internal constructor(
}
}

private fun handleMessage(message: Message) {
private fun handleMessage(model: MessageUiModel) {
val setLoading = { loading: Boolean ->
_uiState.update {
it.copy(
messages = it.messages.map { current ->
if (current.id == message.id) {
if (current.message.id == model.message.id) {
current.copy(isLoading = loading)
} else {
current
Expand All @@ -206,7 +214,7 @@ class HomeViewModel @Inject internal constructor(
}
viewModelScope.launch {
setLoading(true)
messagesHandler.handle(message = message)
messagesHandler.handle(message = model.message)
setLoading(false)
}
}
Expand All @@ -219,11 +227,11 @@ class HomeViewModel @Inject internal constructor(
private fun handleToggleExpandAction(action: Action.ToggleExpand) {
_uiState.update {
it.copy(
messages = it.messages.map { message ->
if (message.id == action.message.id) {
message.copy(isExpanded = !message.isExpanded)
messages = it.messages.map { model ->
if (model.message.id == action.message.message.id) {
model.copy(isExpanded = !model.isExpanded)
} else {
message
model
}
}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package edu.stanford.bdh.engagehf.bluetooth.data.mapper

import edu.stanford.bdh.engagehf.messages.MessagesAction
import edu.stanford.bdh.engagehf.messages.VideoSectionVideo
import edu.stanford.bdh.engagehf.messages.MessageAction
import edu.stanford.bdh.engagehf.messages.Video
import javax.inject.Inject

class MessageActionMapper @Inject constructor() {
Expand All @@ -11,34 +11,32 @@ class MessageActionMapper @Inject constructor() {
private val questionnaireRegex = Regex("/?questionnaires/(.+)")
}

fun map(action: String?): Result<MessagesAction> {
fun map(action: String?): Result<MessageAction> {
return runCatching {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is another error being thrown in the else case. I would propose changing the return type of this method to MessageAction? instead of result and update to runCatching { // exisiting logic }.getOrNull(). No need for NoAction imo as we can represent it with the null case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it accordingly

when {
action.isNullOrBlank() -> error("Invalid action type")
action.isNullOrBlank() -> MessageAction.UnknownAction
videoSectionRegex.matches(action) -> {
mapVideoSectionAction(action).getOrThrow()
mapVideoAction(action).getOrThrow()
}

action == "medications" -> MessagesAction.MedicationsAction
action == "observations" -> MessagesAction.MeasurementsAction
action == "medications" -> MessageAction.MedicationsAction
action == "observations" -> MessageAction.MeasurementsAction
questionnaireRegex.matches(action) -> {
val matchResult = questionnaireRegex.find(action)
val (questionnaireId) = matchResult!!.destructured
MessagesAction.QuestionnaireAction(questionnaireId)
MessageAction.QuestionnaireAction(questionnaireId)
}

action == "healthSummary" -> MessagesAction.HealthSummaryAction
action == "healthSummary" -> MessageAction.HealthSummaryAction
else -> error("Unknown action type")
}
}
}

fun mapVideoSectionAction(action: String): Result<MessagesAction.VideoSectionAction> {
fun mapVideoAction(action: String): Result<MessageAction.VideoAction> {
return runCatching {
val matchResult = videoSectionRegex.find(action)
val (videoSectionId, videoId) = matchResult!!.destructured
MessagesAction.VideoSectionAction(
VideoSectionVideo(
MessageAction.VideoAction(
Video(
videoSectionId,
videoId
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package edu.stanford.bdh.engagehf.bluetooth.data.models

import edu.stanford.bdh.engagehf.bluetooth.service.Measurement
import edu.stanford.bdh.engagehf.messages.Message

sealed interface Action {
data class ConfirmMeasurement(val measurement: Measurement) : Action
data object DismissDialog : Action
data class MessageItemClicked(val message: Message) : Action
data class ToggleExpand(val message: Message) : Action
data class MessageItemClicked(val message: MessageUiModel) : Action
data class ToggleExpand(val message: MessageUiModel) : Action
data class PermissionResult(val permission: String) : Action
data object Resumed : Action
data object BLEDevicePairing : Action
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package edu.stanford.bdh.engagehf.bluetooth.data.models

import edu.stanford.bdh.engagehf.messages.Message
import edu.stanford.bdh.engagehf.messages.MessageAction
import edu.stanford.spezi.core.design.R
import java.time.format.DateTimeFormatter

data class UiState(
val bloodPressure: VitalDisplayData = VitalDisplayData(
Expand All @@ -13,7 +16,26 @@
title = "Weight",
),
val missingPermissions: Set<String> = emptySet(),
val messages: List<Message> = emptyList(),
val messages: List<MessageUiModel> = emptyList(),
val bluetooth: BluetoothUiState = BluetoothUiState.Idle(),
val measurementDialog: MeasurementDialogUiState = MeasurementDialogUiState(),
)

data class MessageUiModel(
val message: Message,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping the domain message in a ui model still violates why we discussed. Ideally the ui model should remain domain agnostic and only contain properties relevant for UI. It should be created in a UI mapper, e.g.

data class MessageUiModel(
    val id: String,
    val title: String,
    val description: String?,
    val dueDate: String?,
    val isExpanded: Boolean = false,
    val isLoading: Boolean = false,
    val icon: Int,
)

fun map(message: Message): MessageUiModel {
    return MessageUiModel(
        id = message.id,
        title = message.title,
        description = message.description,
        dueDate = message.dueDate?.format(DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm a")),
        isExpanded = false,
        isLoading = false,
        icon = when (message.action) {
            is MessageAction.MedicationsAction -> R.drawable.ic_medication
            is MessageAction.MeasurementsAction -> R.drawable.ic_vital_signs
            is MessageAction.QuestionnaireAction -> R.drawable.ic_assignment
            is MessageAction.VideoAction -> R.drawable.ic_visibility
            is MessageAction.UnknownAction -> R.drawable.ic_assignment
            is MessageAction.HealthSummaryAction -> R.drawable.ic_vital_signs
        },
    )
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine with this change as well, will do

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mhmmm, okay, so I tried this, but usually when we interact with lower layers (e.g. when dismissing a message or handling an action based on a message), we would usually want the message actually... How would you usually solve this? Mapping back seems to not really solve any issues, since either data is lost or we have an implicit coupling (i.e. we would need to copy the other object property-by-property anyways)

After seeing this, I'm actually more in favor of wrapping the model type in the UI-Model, but open to discussions. @eldcn

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lower layer does not need all the message props, but only the id, action and whether it is dismissable, similarly as you adapted the handler api to process firebase notifications. I see here two ways:

  1. Store additionally messageAction and isDismissable in MessageUiModel since those are additionally needed for handling the message or
  2. Cache the loaded messages list in a private property in the view model on every update before mapping. When having to handle a message due to Action.MessageItemClicked, you can query the cached list for the message id and hand over to the handler

val isExpanded: Boolean = false,
val isLoading: Boolean = false,
) {
val dueDateFormattedString: String?
get() = message.dueDate?.format(DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm a"))

val icon: Int get() =
when (message.action) {

Check warning on line 33 in app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/data/models/UiState.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/data/models/UiState.kt#L33

Added line #L33 was not covered by tests
is MessageAction.MedicationsAction -> R.drawable.ic_medication
is MessageAction.MeasurementsAction -> R.drawable.ic_vital_signs
is MessageAction.QuestionnaireAction -> R.drawable.ic_assignment
is MessageAction.VideoAction -> R.drawable.ic_visibility
is MessageAction.UnknownAction -> R.drawable.ic_assignment
is MessageAction.HealthSummaryAction -> R.drawable.ic_vital_signs
}

Check warning on line 40 in app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/data/models/UiState.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/data/models/UiState.kt#L39-L40

Added lines #L39 - L40 were not covered by tests
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@
import edu.stanford.bdh.engagehf.bluetooth.data.models.BluetoothUiState
import edu.stanford.bdh.engagehf.bluetooth.data.models.DeviceUiModel
import edu.stanford.bdh.engagehf.bluetooth.data.models.MeasurementDialogUiState
import edu.stanford.bdh.engagehf.bluetooth.data.models.MessageUiModel
import edu.stanford.bdh.engagehf.bluetooth.data.models.UiState
import edu.stanford.bdh.engagehf.bluetooth.data.models.VitalDisplayData
import edu.stanford.bdh.engagehf.messages.Message
import edu.stanford.bdh.engagehf.messages.MessageAction
import edu.stanford.bdh.engagehf.messages.MessageItem
import edu.stanford.bdh.engagehf.messages.MessageType
import edu.stanford.spezi.core.design.component.AsyncTextButton
import edu.stanford.spezi.core.design.component.DefaultElevatedCard
import edu.stanford.spezi.core.design.component.LifecycleEvent
Expand Down Expand Up @@ -109,7 +110,7 @@
if (uiState.messages.isNotEmpty()) {
items(uiState.messages) { message ->
MessageItem(
message = message,
model = message,
onAction = onAction,
)
}
Expand Down Expand Up @@ -332,14 +333,14 @@
formattedWeight = "0.0"
),
messages = listOf(
Message(
id = "1",
dueDate = ZonedDateTime.now(),
type = MessageType.WeightGain,
title = "Weight Gained",
description = "You gained weight. Please take action.",
action = "New Weight Entry"
)
MessageUiModel(
Message(
id = "1",
dueDate = ZonedDateTime.now(),
title = "Weight Gained",
description = "You gained weight. Please take action.",
action = MessageAction.MeasurementsAction

Check warning on line 342 in app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/HomeScreen.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/edu/stanford/bdh/engagehf/bluetooth/screen/HomeScreen.kt#L336-L342

Added lines #L336 - L342 were not covered by tests
),),
),
weight = VitalDisplayData(
title = "Weight",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,3 @@ enum class MeasurementLocations(val value: Int) {
MEASUREMENT_LOCATION_LEFT_UPPER_ARM(BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_UPPER_ARM),
MEASUREMENT_LOCATION_RIGHT_UPPER_ARM(BloodPressureRecord.MEASUREMENT_LOCATION_RIGHT_UPPER_ARM),
}

Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ class MedicationViewModel @Inject internal constructor(

is Action.InfoClicked -> {
viewModelScope.launch {
messageActionMapper.mapVideoSectionAction(action.videoPath).let { result ->
messageActionMapper.mapVideoAction(action.videoPath).let { result ->
result.onSuccess { mappedAction ->
engageEducationRepository.getVideoBySectionAndVideoId(
mappedAction.videoSectionVideo.videoSectionId,
mappedAction.videoSectionVideo.videoId
mappedAction.video.sectionId,
mappedAction.video.videoId
).getOrNull()?.let { video ->
navigator.navigateTo(
EducationNavigationEvent.VideoSectionClicked(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,33 @@ package edu.stanford.bdh.engagehf.messages

import com.google.firebase.Timestamp
import com.google.firebase.firestore.DocumentSnapshot
import edu.stanford.bdh.engagehf.bluetooth.data.mapper.MessageActionMapper
import edu.stanford.bdh.engagehf.localization.LocalizedMapReader
import java.time.ZoneId
import java.time.ZonedDateTime
import javax.inject.Inject

class FirestoreMessageMapper @Inject constructor(
private val localizedMapReader: LocalizedMapReader,
private val messageActionMapper: MessageActionMapper,
) {

fun map(document: DocumentSnapshot): Message? {
val jsonMap = document.data ?: return null
val dueDate = jsonMap["dueDate"] as? Timestamp
val completionDate = jsonMap["completionDate"] as? Timestamp
val typeString = jsonMap["type"] as? String?
val title = localizedMapReader.get(key = "title", jsonMap = jsonMap) ?: return null
val description = localizedMapReader.get(key = "description", jsonMap = jsonMap)
val action = jsonMap["action"] as? String?
val isDismissible = (jsonMap["isDismissible"] as? Boolean) == true
val type = MessageType.fromString(typeString)

return Message(
id = document.id,
dueDate = dueDate?.toZonedDateTime(),
completionDate = completionDate?.toZonedDateTime(),
type = type,
title = title,
description = description,
action = action,
action = messageActionMapper.map(action).getOrNull() ?: MessageAction.UnknownAction,
isDismissible = isDismissible,
)
}
Expand Down
Loading
Loading