Skip to content

Commit

Permalink
fix x value labels in health data charts
Browse files Browse the repository at this point in the history
  • Loading branch information
eldcn committed Jan 26, 2025
1 parent b98ea73 commit da2843e
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ import edu.stanford.spezi.core.design.component.StringResource
import edu.stanford.spezi.core.utils.LocaleProvider
import edu.stanford.spezi.core.utils.extensions.roundToDecimalPlaces
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAdjusters
import javax.inject.Inject

Expand Down Expand Up @@ -50,27 +49,34 @@ class HealthUiStateMapper @Inject constructor(
records: List<EngageRecord>,
selectedTimeRange: TimeRange,
): HealthUiData {
val filteredRecords: List<EngageRecord> =
filterRecordsByTimeRange(records, selectedTimeRange)
val pairs: List<Pair<Double, Double>> =
groupAndMapRecords(filteredRecords, selectedTimeRange)
val oldestRecordDate = records.minBy { it.zonedDateTime }.zonedDateTime
val pairs = groupAndMapRecords(
records = records,
oldestZonedDateTime = oldestRecordDate,
selectedTimeRange = selectedTimeRange
)
val title: String = when (records.first()) {
is EngageRecord.HeartRate -> "Heart Rate"
is EngageRecord.BloodPressure -> "Systolic"
is EngageRecord.Weight -> "Weight"
}

val chartData = createAggregatedHealthData(title, pairs)

val chartDataList = mutableListOf(chartData)
if (records.any { it is EngageRecord.BloodPressure }) {
val diastolicPairs: List<Pair<Double, Double>> =
groupAndMapRecords(filteredRecords, selectedTimeRange, true)
val diastolicChartData = createAggregatedHealthData("Diastolic", diastolicPairs)
val diastolicPairs = groupAndMapRecords(
records = records,
oldestZonedDateTime = oldestRecordDate,
selectedTimeRange = selectedTimeRange,
diastolic = true
)
val diastolicChartData = createAggregatedHealthData(title, diastolicPairs)
chartDataList.add(diastolicChartData)
}

val newestData: NewestHealthData? = getNewestRecord(filteredRecords)
val tableData: List<TableEntryData> = mapTableData(filteredRecords)
val newestData: NewestHealthData? = getNewestRecord(records)
val tableData: List<TableEntryData> = mapTableData(records)

return HealthUiData(
records = records.map { it.record },
Expand All @@ -79,7 +85,13 @@ class HealthUiStateMapper @Inject constructor(
newestData = newestData,
averageData = getAverageData(tableData),
infoRowData = generateHealthHeaderData(selectedTimeRange, newestData),
valueFormatter = { valueFormatter(it, selectedTimeRange) }
valueFormatter = { value ->
valueFormatter(
value = value.toLong(),
oldestZonedDateTime = oldestRecordDate,
timeRange = selectedTimeRange,
)
}
)
}

Expand All @@ -97,25 +109,35 @@ class HealthUiStateMapper @Inject constructor(

private fun groupAndMapRecords(
records: List<EngageRecord>,
oldestZonedDateTime: ZonedDateTime,
selectedTimeRange: TimeRange,
diastolic: Boolean = false,
): List<Pair<Double, Double>> {
val groupedRecords = groupRecordsByTimeRange(records, selectedTimeRange)
return groupedRecords.values.map { entries ->
val averageValue = entries.map { getValue(it, diastolic).value }.average()
Pair(
averageValue.roundToDecimalPlaces(2),
mapXValue(selectedTimeRange, entries.first().zonedDateTime).roundToDecimalPlaces(2)
val yValue = entries
.map { getValue(it, diastolic).value }
.average()
.roundToDecimalPlaces(2)
val xValue = mapXValue(
selectedTimeRange = selectedTimeRange,
zonedDateTime = entries.first().zonedDateTime,
oldestZonedDateTime = oldestZonedDateTime,
)
Pair(yValue, xValue)
}
}

private fun mapXValue(selectedTimeRange: TimeRange, zonedDateTime: ZonedDateTime): Double {
private fun mapXValue(
selectedTimeRange: TimeRange,
zonedDateTime: ZonedDateTime,
oldestZonedDateTime: ZonedDateTime,
): Double {
return when (selectedTimeRange) {
TimeRange.DAILY -> (zonedDateTime.year.toDouble() + (zonedDateTime.dayOfYear - 1) / 365.0) * 10
TimeRange.WEEKLY -> (zonedDateTime.toEpochSecond() / (7 * 24 * 60 * 60)).toDouble()
TimeRange.MONTHLY -> zonedDateTime.year.toDouble() + (zonedDateTime.monthValue - 1) / 12f
}
TimeRange.DAILY -> ChronoUnit.DAYS.between(oldestZonedDateTime, zonedDateTime)
TimeRange.WEEKLY -> ChronoUnit.WEEKS.between(oldestZonedDateTime, zonedDateTime)
TimeRange.MONTHLY -> ChronoUnit.MONTHS.between(oldestZonedDateTime, zonedDateTime)
}.toDouble()
}

private fun groupRecordsByTimeRange(
Expand Down Expand Up @@ -190,14 +212,6 @@ class HealthUiStateMapper @Inject constructor(
return String.format(getDefaultLocale(), "%.1f", value) + " " + unit
}

private fun filterRecordsByTimeRange(
records: List<EngageRecord>,
timeRange: TimeRange,
): List<EngageRecord> {
val maxTime = ZonedDateTime.now().minusMonths(getMaxMonths(timeRange))
return records.filter { it.zonedDateTime.isAfter(maxTime) }
}

private fun getMaxMonths(selectedTimeRange: TimeRange): Long {
return when (selectedTimeRange) {
TimeRange.DAILY -> DAILY_MAX_MONTHS
Expand Down Expand Up @@ -330,28 +344,15 @@ class HealthUiStateMapper @Inject constructor(

private fun getDefaultLocale() = localeProvider.getDefaultLocale()

private fun valueFormatter(value: Double, timeRange: TimeRange): String {
private fun valueFormatter(
value: Long,
oldestZonedDateTime: ZonedDateTime,
timeRange: TimeRange,
): String {
val date = when (timeRange) {
TimeRange.DAILY -> {
val actualValue = value * 10
val year = actualValue.toInt()
val dayOfYearFraction = actualValue - year
val dayOfYear = (dayOfYearFraction * 365).toInt() + 1
ZonedDateTime.of(year, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault())
.plusDays((dayOfYear - 1).toLong())
}

TimeRange.WEEKLY -> ZonedDateTime.ofInstant(
Instant.ofEpochSecond(
value.toLong() * 7 * 24 * 60 * 60
), ZoneId.systemDefault()
)

TimeRange.MONTHLY -> {
val year = value.toInt()
val month = ((value - year) * 12).toInt() + 1
ZonedDateTime.of(year, month, 1, 0, 0, 0, 0, ZoneId.systemDefault())
}
TimeRange.DAILY -> oldestZonedDateTime.plusDays(value)
TimeRange.WEEKLY -> oldestZonedDateTime.plusWeeks(value)
TimeRange.MONTHLY -> oldestZonedDateTime.plusMonths(value)
}
val pattern = when (timeRange) {
TimeRange.DAILY, TimeRange.WEEKLY -> "MMM dd"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelCompone
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStartAxis
import com.patrykandpatrick.vico.compose.cartesian.decoration.rememberHorizontalLine
import com.patrykandpatrick.vico.compose.cartesian.fullWidth
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberPoint
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.cartesian.segmented
import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent
import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent
import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent
Expand Down Expand Up @@ -132,7 +132,7 @@ fun HealthChart(
decorations = uiState.averageData?.let { averageWeight ->
listOf(rememberComposeHorizontalLine(averageWeight))
} ?: emptyList(),
horizontalLayout = HorizontalLayout.fullWidth(),
horizontalLayout = HorizontalLayout.segmented(),
legend = rememberLegend(uiState.chartData),
),
modelProducer = modelProducer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
Expand All @@ -52,12 +53,10 @@ import com.patrykandpatrick.vico.compose.common.fill
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Scroll
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.axis.AxisPosition
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.data.ChartValues
import com.patrykandpatrick.vico.core.cartesian.data.lineSeries
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer.PointProvider.Companion.single
Expand Down Expand Up @@ -201,8 +200,7 @@ fun SymptomsChart(
color = primary
)

val valueFormatter: (Double, ChartValues, AxisPosition.Vertical?) -> CharSequence =
{ value, _, _ -> uiState.valueFormatter(value) }
val xValueFormatter = CartesianValueFormatter { value, _, _ -> uiState.xValueFormatter(value) }

val marker = remember {
DefaultCartesianMarker(
Expand Down Expand Up @@ -238,15 +236,11 @@ fun SymptomsChart(
titleComponent = rememberTextComponent(),
label = rememberAxisLabelComponent(),
guideline = null,
valueFormatter = if (uiState.headerData.selectedSymptomType == SymptomType.DIZZINESS) {
CartesianValueFormatter.decimal()
} else {
CartesianValueFormatter.yPercent()
},
valueFormatter = rememberYAxisValueFormatter(uiState.headerData.selectedSymptomType),
),
bottomAxis = rememberBottomAxis(
guideline = null,
valueFormatter = valueFormatter,
valueFormatter = xValueFormatter,
itemPlacer = remember {
HorizontalAxis.ItemPlacer.default(
spacing = 1,
Expand All @@ -270,6 +264,31 @@ fun SymptomsChart(
)
}

@Suppress("MagicNumber")
@Composable
private fun rememberYAxisValueFormatter(
selectedSymptomType: SymptomType,
): CartesianValueFormatter {
val context = LocalContext.current
return remember(selectedSymptomType) {
if (selectedSymptomType == SymptomType.DIZZINESS) {
CartesianValueFormatter { value, _, _ ->
val resId = when (value) {
5.0 -> R.string.dizziness_score_very_severe
4.0 -> R.string.dizziness_score_severe
3.0 -> R.string.dizziness_score_moderate
2.0 -> R.string.dizziness_score_mild
1.0 -> R.string.dizziness_score_minimal
else -> R.string.dizziness_score_none
}
context.getString(resId)
}
} else {
CartesianValueFormatter.yPercent()
}
}
}

@Composable
private fun SymptomsDropdown(headerData: HeaderData, onAction: (SymptomsViewModel.Action) -> Unit) {
Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class SymptomsUiStateMapper @Inject constructor(
selectedSymptomType = selectedSymptomType,
isSelectedSymptomTypeDropdownExpanded = false,
),
valueFormatter = { value ->
xValueFormatter = { value ->
oldestDate
.plusDays(value.toLong())
.format(DateTimeFormatter.ofPattern("MMM dd"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ data class SymptomsUiData(
val chartData: List<AggregatedHealthData>,
val tableData: List<TableEntryData> = emptyList(),
val headerData: HeaderData,
val valueFormatter: (Double) -> String = { "" },
val xValueFormatter: (Double) -> String = { "" },
)

data class HeaderData(
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@
<string name="specific_symptoms_score_description">This represents the frequency you are experiencing heart-related symptoms. Higher scores mean less frequent symptoms and lower scores mean more frequent symptoms.</string>
<string name="dizziness_score_title">Dizziness Score</string>
<string name="dizziness_score_description">Your dizziness is important to keep track of because dizziness can be a side effect of your heart or your heart medicines.</string>
<string name="dizziness_score_very_severe">Very Severe</string>
<string name="dizziness_score_severe">Severe</string>
<string name="dizziness_score_moderate">Moderate</string>
<string name="dizziness_score_mild">Mild</string>
<string name="dizziness_score_minimal">Minimal</string>
<string name="dizziness_score_none">None</string>
<string name="error_while_handling_message_action">Error while handling message action.</string>
<string name="no_pdf_reader_app_installed_error_message">Health Summary generated successfully, but no PDF reader is installed on the device</string>
<string name="health_summary_generate_error_message">Failed to generate Health Summary</string>
Expand Down
Loading

0 comments on commit da2843e

Please sign in to comment.