Skip to content

Commit

Permalink
[VIT-7475] Android: Support Nutrition records (#165)
Browse files Browse the repository at this point in the history
* [VIT-7475] Android: Support Nutrition records
  • Loading branch information
ItachiEU authored Sep 26, 2024
1 parent a4c02e5 commit 840ad31
Show file tree
Hide file tree
Showing 11 changed files with 271 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.tryvital.client.services.data.LocalProfile
import io.tryvital.client.services.data.LocalQuantitySample
import io.tryvital.client.services.data.LocalSleep
import io.tryvital.client.services.data.LocalWorkout
import io.tryvital.client.services.data.ManualMealCreation
import io.tryvital.client.services.data.SummaryPayload
import io.tryvital.client.services.data.TimeseriesPayload
import retrofit2.Response
Expand Down Expand Up @@ -51,6 +52,12 @@ interface VitalPrivateService {
@Body body: SummaryPayload<List<LocalMenstrualCycle>>
): Response<Unit>

@POST("summary/meal/{user_id}")
suspend fun addMeals(
@Path("user_id") userId: String,
@Body body: SummaryPayload<List<ManualMealCreation>>
): Response<Unit>

@POST("summary/activity/{user_id}")
suspend fun addActivities(
@Path("user_id") userId: String,
Expand Down
95 changes: 95 additions & 0 deletions VitalClient/src/main/java/io/tryvital/client/services/data/Meal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package io.tryvital.client.services.data

import android.health.connect.datatypes.units.Mass
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import io.tryvital.client.utils.AlwaysSerializeNulls
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset

@AlwaysSerializeNulls
@JsonClass(generateAdapter = true)
data class HealthConnectRecordCollection(
@Json(name = "source_bundle")
val sourceBundle: String,

@Json(name = "nutrition_records")
val nutritionRecords: List<NutritionRecord>
)

@JsonClass(generateAdapter = true)
data class NutritionRecord(
@Json(name = "start_time")
val startTime: Instant,
@Json(name = "start_zone_offset")
val startZoneOffset: String?,
@Json(name = "end_time")
val endTime: Instant,
@Json(name = "end_zone_offset")
val endZoneOffset: String?,
val biotin: Double?,
val caffeine: Double?,
val calcium: Double?,
val energy: Double?,
@Json(name = "energy_from_fat")
val energyFromFat: Double?,
val chloride: Double?,
val cholesterol: Double?,
val chromium: Double?,
val copper: Double?,
@Json(name = "dietary_fiber")
val dietaryFiber: Double?,
val folate: Double?,
@Json(name = "folic_acid")
val folicAcid: Double?,
val iodine: Double?,
val iron: Double?,
val magnesium: Double?,
val manganese: Double?,
val molybdenum: Double?,
@Json(name = "monounsaturated_fat")
val monounsaturatedFat: Double?,
val niacin: Double?,
@Json(name = "pantothenic_acid")
val pantothenicAcid: Double?,
val phosphorus: Double?,
@Json(name = "polyunsaturated_fat")
val polyunsaturatedFat: Double?,
val potassium: Double?,
val protein: Double?,
val riboflavin: Double?,
@Json(name = "saturated_fat")
val saturatedFat: Double?,
val selenium: Double?,
val sodium: Double?,
val sugar: Double?,
val thiamin: Double?,
@Json(name = "total_carbohydrate")
val totalCarbohydrate: Double?,
@Json(name = "total_fat")
val totalFat: Double?,
@Json(name = "trans_fat")
val transFat: Double?,
@Json(name = "unsaturated_fat")
val unsaturatedFat: Double?,
@Json(name = "vitamin_a")
val vitaminA: Double?,
@Json(name = "vitamin_b12")
val vitaminB12: Double?,
@Json(name = "vitamin_b6")
val vitaminB6: Double?,
@Json(name = "vitamin_c")
val vitaminC: Double?,
@Json(name = "vitamin_d")
val vitaminD: Double?,
@Json(name = "vitamin_e")
val vitaminE: Double?,
@Json(name = "vitamin_k")
val vitaminK: Double?,
val zinc: Double?,
val name: String?,
@Json(name = "meal_type")
val mealType: Int,
val metadata: Map<String, Map<String, String>>
)
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ data class LocalMenstrualCycle(
val cycle: MenstrualCycle,
)

@JsonClass(generateAdapter = true)
data class ManualMealCreation(
@Json(name = "health_connect")
val healthConnect: HealthConnectRecordCollection? = null
)

@JsonClass(generateAdapter = true)
data class LocalQuantitySample(
@Json(name = "id")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ sealed class VitalResource(val name: String) {
object RespiratoryRate : VitalResource("respiratoryRate")
object Temperature : VitalResource("temperature")

object Meal: VitalResource("meal")

override fun toString(): String {
return name
}
Expand All @@ -46,6 +48,7 @@ sealed class VitalResource(val name: String) {
RespiratoryRate -> 26
Temperature -> 27
BloodOxygen -> 28
Meal -> 29
Workout -> 31
Steps -> 51
DistanceWalkingRunning -> 52
Expand Down Expand Up @@ -79,6 +82,7 @@ sealed class VitalResource(val name: String) {
RespiratoryRate,
Temperature,
BloodOxygen,
Meal,
)
}

Expand Down Expand Up @@ -190,6 +194,8 @@ internal fun VitalResource.recordTypeDependencies(): RecordTypeRequirements = wh
VitalResource.Water -> RecordTypeRequirements.single(HydrationRecord::class)
VitalResource.Profile -> RecordTypeRequirements.single(HeightRecord::class)

VitalResource.Meal -> RecordTypeRequirements.single(NutritionRecord::class)

VitalResource.Activity -> RecordTypeRequirements(
required = emptyList(),
optional = listOf(
Expand Down Expand Up @@ -270,6 +276,7 @@ internal fun VitalResource.recordTypeDependencies(): RecordTypeRequirements = wh
internal fun VitalResource.recordTypeChangesToTriggerSync(): List<KClass<out Record>> = when (this) {
VitalResource.Water -> listOf(HydrationRecord::class)
VitalResource.Activity -> listOf()
VitalResource.Meal -> listOf()
VitalResource.ActiveEnergyBurned -> listOf(ActiveCaloriesBurnedRecord::class)
VitalResource.BasalEnergyBurned -> listOf(BasalMetabolicRateRecord::class)
VitalResource.DistanceWalkingRunning -> listOf(DistanceRecord::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.tryvital.vitalhealthconnect.model.processedresource

import io.tryvital.client.services.data.HealthConnectRecordCollection
import io.tryvital.client.services.data.LocalBody
import io.tryvital.client.services.data.IngestibleTimeseriesResource
import io.tryvital.client.services.data.LocalActivity
Expand All @@ -9,6 +10,7 @@ import io.tryvital.client.services.data.LocalProfile
import io.tryvital.client.services.data.LocalQuantitySample
import io.tryvital.client.services.data.LocalSleep
import io.tryvital.client.services.data.LocalWorkout
import io.tryvital.client.services.data.ManualMealCreation
import io.tryvital.client.services.data.MenstrualCycle
import java.time.Instant

Expand Down Expand Up @@ -144,6 +146,16 @@ sealed class SummaryData {
}
override fun isNotEmpty(): Boolean = cycles.isNotEmpty()
}

data class Meals(
val meals: List<ManualMealCreation>
) : SummaryData() {
override fun merge(other: SummaryData): SummaryData {
check(other is Meals)
return Meals(meals + other.meals )
}
override fun isNotEmpty(): Boolean = meals.isNotEmpty()
}
}

fun Collection<ProcessedResourceData>.merged(): ProcessedResourceData
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,19 @@ import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.Vo2MaxRecord
import androidx.health.connect.client.records.WeightRecord
import io.tryvital.client.services.data.HealthConnectRecordCollection
import io.tryvital.client.services.data.IngestibleTimeseriesResource
import io.tryvital.client.services.data.LocalActivity
import io.tryvital.client.services.data.LocalBloodPressureSample
import io.tryvital.client.services.data.LocalQuantitySample
import io.tryvital.client.services.data.LocalSleep
import io.tryvital.client.services.data.LocalWorkout
import io.tryvital.client.services.data.ManualMealCreation
import io.tryvital.client.services.data.NutritionRecord
import io.tryvital.client.services.data.SampleType
import io.tryvital.client.utils.VitalLogger
import io.tryvital.vitalhealthconnect.SupportedSleepApps
import io.tryvital.vitalhealthconnect.ext.toDate
import io.tryvital.vitalhealthconnect.model.inferredSourceType
import io.tryvital.vitalhealthconnect.model.processedresource.SummaryData
import io.tryvital.vitalhealthconnect.model.processedresource.TimeSeriesData
Expand All @@ -49,6 +53,7 @@ data class ProcessorOptions(
)

const val ACTIVITY_STATS_DAYS_TO_LOOKBACK = 3L
const val NUTRITION_STATS_DAYS_TO_LOOKBACK = 5L

sealed class TimeRangeOrRecords<out R: Record> {
data class TimeRange<out R: Record>(val start: Instant, val end: Instant): TimeRangeOrRecords<R>()
Expand Down Expand Up @@ -100,6 +105,11 @@ interface RecordProcessor {
timeZone: TimeZone,
): SummaryData.Activities

suspend fun processMeals(
lastSynced: Instant?,
timeZone: TimeZone,
): SummaryData.Meals

suspend fun processActiveCaloriesBurnedRecords(
activeEnergyBurned: TimeRangeOrRecords<ActiveCaloriesBurnedRecord>,
options: ProcessorOptions,
Expand Down Expand Up @@ -693,6 +703,89 @@ internal class HealthConnectRecordProcessor(
)
}

override suspend fun processMeals(
lastSynced: Instant?,
timeZone: TimeZone
): SummaryData.Meals {
val zoneId = timeZone.toZoneId()
val now = ZonedDateTime.now(zoneId)
val startInstant = minOf(
lastSynced?.atZone(zoneId) ?: now,
now.minusDays(ACTIVITY_STATS_DAYS_TO_LOOKBACK)
).toInstant()

val nutritionRecords = recordReader.readNutritionRecords(startInstant, now.toInstant())

val meals = nutritionRecords.groupBy { Triple(
it.metadata.dataOrigin.packageName,
it.mealType,
it.startTime.atZone(it.startZoneOffset).toLocalDate().atStartOfDay()
) }

return SummaryData.Meals(
meals = meals.map{
ManualMealCreation(
healthConnect = HealthConnectRecordCollection(
nutritionRecords = it.value.map{record ->
NutritionRecord(
startTime = record.startTime,
startZoneOffset = record.startZoneOffset.toString(),
endTime = record.endTime,
endZoneOffset = record.endZoneOffset.toString(),
biotin = record.biotin?.inMicrograms,
caffeine = record.caffeine?.inMilligrams,
calcium = record.calcium?.inMilligrams,
energy = record.energy?.inKilocalories,
energyFromFat = record.energyFromFat?.inKilocalories,
chloride = record.chloride?.inMilligrams,
cholesterol = record.cholesterol?.inMilligrams,
chromium = record.chromium?.inMicrograms,
copper = record.copper?.inMilligrams,
dietaryFiber = record.dietaryFiber?.inGrams,
folate = record.folate?.inMicrograms,
folicAcid = record.folicAcid?.inMicrograms,
iodine = record.iodine?.inMicrograms,
iron = record.iron?.inMilligrams,
magnesium = record.magnesium?.inMilligrams,
manganese = record.manganese?.inMilligrams,
molybdenum = record.molybdenum?.inMicrograms,
monounsaturatedFat = record.monounsaturatedFat?.inGrams,
niacin = record.niacin?.inMilligrams,
pantothenicAcid = record.pantothenicAcid?.inMilligrams,
phosphorus = record.phosphorus?.inMilligrams,
polyunsaturatedFat = record.polyunsaturatedFat?.inGrams,
potassium = record.potassium?.inMilligrams,
protein = record.protein?.inGrams,
riboflavin = record.riboflavin?.inMilligrams,
saturatedFat = record.saturatedFat?.inGrams,
selenium = record.selenium?.inMicrograms,
sodium = record.sodium?.inMilligrams,
sugar = record.sugar?.inGrams,
thiamin = record.thiamin?.inMilligrams,
totalCarbohydrate = record.totalCarbohydrate?.inGrams,
totalFat = record.totalFat?.inGrams,
transFat = record.transFat?.inGrams,
unsaturatedFat = record.unsaturatedFat?.inGrams,
vitaminA = record.vitaminA?.inMicrograms,
vitaminB12 = record.vitaminB12?.inMicrograms,
vitaminB6 = record.vitaminB6?.inMilligrams,
vitaminC = record.vitaminC?.inMilligrams,
vitaminD = record.vitaminD?.inMicrograms,
vitaminE = record.vitaminE?.inMilligrams,
vitaminK = record.vitaminK?.inMicrograms,
zinc = record.zinc?.inMilligrams,
name = record.name,
mealType = record.mealType,
metadata = mapOf("dataOrigin" to mapOf("packageName" to record.metadata.dataOrigin.packageName))
)
},
sourceBundle = it.key.first
)
)
}
)
}

/**
* We ignore any delta record inputs. We recompute cycle boundaries on the fly every time,
* and then grouping the record scraps into [MenstrualCycle]s based on the boundaries.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.health.connect.client.records.HydrationRecord
import androidx.health.connect.client.records.IntermenstrualBleedingRecord
import androidx.health.connect.client.records.MenstruationFlowRecord
import androidx.health.connect.client.records.MenstruationPeriodRecord
import androidx.health.connect.client.records.NutritionRecord
import androidx.health.connect.client.records.OvulationTestRecord
import androidx.health.connect.client.records.OxygenSaturationRecord
import androidx.health.connect.client.records.Record
Expand Down Expand Up @@ -128,6 +129,11 @@ interface RecordReader {
endTime: Instant
): List<HydrationRecord>

suspend fun readNutritionRecords(
start: Instant,
end: Instant,
): List<NutritionRecord>

suspend fun readRespiratoryRates(start: Instant, end: Instant): List<RespiratoryRateRecord>
suspend fun readBodyTemperatures(start: Instant, end: Instant): List<BodyTemperatureRecord>

Expand Down Expand Up @@ -224,6 +230,8 @@ internal class HealthConnectRecordReader(
endTime: Instant
): List<HydrationRecord> = readRecords(startTime, endTime)

override suspend fun readNutritionRecords(start: Instant, end: Instant): List<NutritionRecord>
= readRecords(start, end)

override suspend fun menstruationPeriod(start: Instant, end: Instant): List<MenstruationPeriodRecord>
= readRecords(start, end)
Expand Down
Loading

0 comments on commit 840ad31

Please sign in to comment.