Skip to content

Commit

Permalink
feat: Read and write data from/to Health Connect (#2)
Browse files Browse the repository at this point in the history
* Read data

* Write data to Health Connect
  • Loading branch information
eevajonnapanula committed Jan 18, 2024
1 parent b85b08f commit 8e34516
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 38 deletions.
@@ -1,11 +1,16 @@
package com.eevajonna.period.data

import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.MenstruationPeriodRecord
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import java.time.LocalDateTime
class HealthConnectManager(private val context: Context) {
private val healthConnectClient by lazy { HealthConnectClient.getOrCreate(context) }

Expand All @@ -14,6 +19,26 @@ class HealthConnectManager(private val context: Context) {
fun requestPermissionsActivityContract(): ActivityResultContract<Set<String>, Set<String>> {
return PermissionController.createRequestPermissionResultContract()
}

suspend fun readMenstruationRecords(): List<MenstruationPeriodRecord> {
val request = ReadRecordsRequest(
recordType = MenstruationPeriodRecord::class,
timeRangeFilter = TimeRangeFilter.after(LocalDateTime.now().minusMonths(12)),
)
val response = healthConnectClient.readRecords(request)
return response.records
}

suspend fun writeMenstruationRecords(menstruationPeriodRecord: MenstruationPeriodRecord) {
val records = listOf(menstruationPeriodRecord)
try {
healthConnectClient.insertRecords(records)
Toast.makeText(context, "Successfully insert records", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, e.message.toString(), Toast.LENGTH_SHORT).show()
Log.e("Error", "Message: ${e.message}")
}
}
}
val PERMISSIONS =
setOf(
Expand Down
@@ -0,0 +1,11 @@
package com.eevajonna.period.data

import androidx.health.connect.client.records.MenstruationPeriodRecord
import com.eevajonna.period.ui.utils.TimeUtils
import java.time.LocalDate

fun MenstruationPeriodRecord.startDateToLocalDate(): LocalDate = TimeUtils.instantToLocalDate(this.startTime)

fun MenstruationPeriodRecord.endDateToLocalDate(): LocalDate = TimeUtils.instantToLocalDate(this.endTime)

fun MenstruationPeriodRecord.isCurrent(): Boolean = this.startDateToLocalDate().isEqual(this.endDateToLocalDate())
47 changes: 47 additions & 0 deletions app/src/main/java/com/eevajonna/period/ui/PeriodViewModel.kt
@@ -1,28 +1,75 @@
package com.eevajonna.period.ui

import android.os.RemoteException
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.MenstruationPeriodRecord
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.eevajonna.period.data.HealthConnectManager
import kotlinx.coroutines.launch
import java.io.IOException

class PeriodViewModel(private val healthConnectManager: HealthConnectManager) : ViewModel() {
var periods by mutableStateOf<List<MenstruationPeriodRecord>>(emptyList())
var permissionsGranted by mutableStateOf(false)

init {
checkPermissions()
}

fun getInitialRecords() {
viewModelScope.launch {
tryWithPermissionsCheck {
getPeriodRecords()
}
}
}

val permissionsLauncher = healthConnectManager.requestPermissionsActivityContract()

fun writeMenstruationRecord(menstruationPeriodRecord: MenstruationPeriodRecord) {
viewModelScope.launch {
tryWithPermissionsCheck {
healthConnectManager.writeMenstruationRecords(menstruationPeriodRecord)
getPeriodRecords()
}
}
}
private fun checkPermissions() {
viewModelScope.launch {
permissionsGranted = healthConnectManager.hasAllPermissions()
}
}

private suspend fun tryWithPermissionsCheck(block: suspend () -> Unit) {
permissionsGranted = healthConnectManager.hasAllPermissions()
try {
if (permissionsGranted) {
block()
}
} catch (remoteException: RemoteException) {
Log.e("Error getting records:", "${remoteException.message}")
} catch (securityException: SecurityException) {
Log.e("Error getting records:", "${securityException.message}")
} catch (ioException: IOException) {
Log.e("Error getting records:", "${ioException.message}")
} catch (illegalStateException: IllegalStateException) {
Log.e("Error getting records:", "${illegalStateException.message}")
} catch (e: Exception) {
Log.e("Error getting records:", "${e.message}")
}
}

private fun getPeriodRecords() {
viewModelScope.launch {
periods = healthConnectManager.readMenstruationRecords()
}
}
}

class PeriodViewModelFactory(
Expand Down
Expand Up @@ -16,31 +16,48 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.health.connect.client.records.MenstruationPeriodRecord
import com.eevajonna.period.R
import com.eevajonna.period.ui.screens.Period
import com.eevajonna.period.data.endDateToLocalDate
import com.eevajonna.period.data.isCurrent
import com.eevajonna.period.data.startDateToLocalDate
import com.eevajonna.period.ui.utils.TextUtils
import com.eevajonna.period.ui.utils.TimeUtils
import java.time.LocalDate
import java.time.Instant

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRangePickerDialog(
selectedPeriod: Period,
selectedPeriod: MenstruationPeriodRecord,
onDismiss: () -> Unit,
onConfirm: (Period) -> Unit,
onConfirm: (MenstruationPeriodRecord) -> Unit,
) {
val dateRangePickerState = rememberDateRangePickerState(
initialSelectedStartDateMillis = TimeUtils.localDateToMilliseconds(selectedPeriod.startDate),
initialSelectedEndDateMillis = if (selectedPeriod.isCurrent) null else TimeUtils.localDateToMilliseconds(selectedPeriod.endDate!!),
initialSelectedStartDateMillis = TimeUtils.localDateToMilliseconds(selectedPeriod.startDateToLocalDate()),
initialSelectedEndDateMillis = if (selectedPeriod.isCurrent()) null else TimeUtils.localDateToMilliseconds(selectedPeriod.endDateToLocalDate()),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val updated = selectedPeriod.copy(
startDate = TimeUtils.milliSecondsToLocalDate(dateRangePickerState.selectedStartDateMillis)
?: LocalDate.now(),
endDate = TimeUtils.milliSecondsToLocalDate(dateRangePickerState.selectedEndDateMillis),
val startTime =
if (dateRangePickerState.selectedStartDateMillis != null) {
Instant.ofEpochMilli(dateRangePickerState.selectedStartDateMillis!!)
} else
Instant.now()

val endTime =
if (dateRangePickerState.selectedEndDateMillis != null) {
Instant.ofEpochMilli(dateRangePickerState.selectedEndDateMillis!!)
} else
startTime.plusSeconds(60) // This needs to be different from start time, but within the same day to work as intended on my code.

val updated = MenstruationPeriodRecord(
startTime = startTime,
endTime = endTime,
startZoneOffset = selectedPeriod.startZoneOffset,
endZoneOffset = selectedPeriod.endZoneOffset,
metadata = selectedPeriod.metadata,
)
onConfirm(updated)
}) {
Expand Down
Expand Up @@ -25,7 +25,7 @@ import java.time.LocalDate
import java.time.temporal.ChronoUnit

@Composable
fun PeriodCanvas(modifier: Modifier = Modifier, startDate: LocalDate, endDate: LocalDate?) {
fun PeriodCanvas(modifier: Modifier = Modifier, startDate: LocalDate, endDate: LocalDate) {
val color = MaterialTheme.colorScheme.surface
val periodColor = MaterialTheme.colorScheme.tertiaryContainer
val textColor = MaterialTheme.colorScheme.onBackground
Expand All @@ -38,8 +38,8 @@ fun PeriodCanvas(modifier: Modifier = Modifier, startDate: LocalDate, endDate: L
.height(PeriodCanvas.canvasHeight),
) {
val dayWidth = (size.width - (PeriodCanvas.BackgroundOffset.x * 2).toPx()) / 14

val endDateToCompare = endDate ?: LocalDate.now().plusDays(6)
val endDateAvailable = !startDate.isEqual(endDate)
val endDateToCompare = if (endDateAvailable) endDate else LocalDate.now().plusDays(6)

val periodWidth = startDate.until(endDateToCompare.plusDays(1), ChronoUnit.DAYS) * dayWidth
val yOffset = size.height - PeriodCanvas.BackgroundOffset.y.toPx()
Expand All @@ -50,7 +50,7 @@ fun PeriodCanvas(modifier: Modifier = Modifier, startDate: LocalDate, endDate: L
yOffset = yOffset,
dayWidth = dayWidth,
periodWidth = periodWidth,
endDateAvailable = endDate != null,
endDateAvailable = endDateAvailable,
)

val startTextLayoutResult = textMeasurer.measure(TextUtils.formatDate(startDate, "MMM d"))
Expand All @@ -68,7 +68,7 @@ fun PeriodCanvas(modifier: Modifier = Modifier, startDate: LocalDate, endDate: L
),
)

endDate?.let {
if (endDateAvailable) {
val endTextLayoutResult = textMeasurer.measure(TextUtils.formatDate(endDate, "MMM d"))

drawDayIndicator(
Expand Down Expand Up @@ -165,7 +165,7 @@ fun PeriodCanvasPreview() {
PeriodCanvas(
Modifier.background(MaterialTheme.colorScheme.background),
startDate = LocalDate.now().minusDays(3),
endDate = null,
endDate = LocalDate.now().minusDays(3),
)
PeriodCanvas(
Modifier.background(MaterialTheme.colorScheme.background),
Expand Down
Expand Up @@ -17,11 +17,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.health.connect.client.records.MenstruationPeriodRecord
import com.eevajonna.period.R
import com.eevajonna.period.ui.screens.Period
import com.eevajonna.period.data.endDateToLocalDate
import com.eevajonna.period.data.startDateToLocalDate

@Composable
fun PeriodRow(period: Period, onEditIconClick: () -> Unit) {
fun PeriodRow(period: MenstruationPeriodRecord, onEditIconClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
Expand All @@ -32,7 +34,7 @@ fun PeriodRow(period: Period, onEditIconClick: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(PeriodRow.horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
PeriodCanvas(startDate = period.startDate, endDate = period.endDate, modifier = Modifier.weight(6f))
PeriodCanvas(startDate = period.startDateToLocalDate(), endDate = period.endDateToLocalDate(), modifier = Modifier.weight(6f))
IconButton(onClick = { onEditIconClick() }, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.Edit, stringResource(R.string.button_edit))
}
Expand Down
48 changes: 30 additions & 18 deletions app/src/main/java/com/eevajonna/period/ui/screens/MainScreen.kt
Expand Up @@ -30,21 +30,23 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.records.MenstruationPeriodRecord
import androidx.lifecycle.viewmodel.compose.viewModel
import com.eevajonna.period.R
import com.eevajonna.period.data.HealthConnectManager
import com.eevajonna.period.data.PERMISSIONS
import com.eevajonna.period.data.startDateToLocalDate
import com.eevajonna.period.ui.PeriodViewModel
import com.eevajonna.period.ui.PeriodViewModelFactory
import com.eevajonna.period.ui.components.DateRangePickerDialog
import com.eevajonna.period.ui.components.PeriodRow
import java.time.LocalDate
import android.health.connect.HealthConnectManager as HCM
import java.time.Instant

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(healthConnectManager: HealthConnectManager) {
val periods = emptyList<Period>()
val context = LocalContext.current

val viewModel: PeriodViewModel = viewModel(
Expand All @@ -55,19 +57,23 @@ fun MainScreen(healthConnectManager: HealthConnectManager) {

val permissionsLauncher =
rememberLauncherForActivityResult(viewModel.permissionsLauncher) {
// TODO
viewModel.getInitialRecords()
}

var showDatePickerDialog by remember {
mutableStateOf(false)
}
var selectedPeriod by remember {
mutableStateOf(Period.EMPTY)
mutableStateOf(MainScreen.emptyRecord)
}

fun saveMenstruationPeriod(menstruationPeriodRecord: MenstruationPeriodRecord) {
viewModel.writeMenstruationRecord(menstruationPeriodRecord)
}

LaunchedEffect(Unit) {
if (viewModel.permissionsGranted) {
// TODO
viewModel.getInitialRecords()
} else {
permissionsLauncher.launch(PERMISSIONS)
}
Expand All @@ -79,14 +85,14 @@ fun MainScreen(healthConnectManager: HealthConnectManager) {
},
floatingActionButton = {
FloatingActionButton(onClick = {
selectedPeriod = Period.EMPTY
selectedPeriod = MainScreen.emptyRecord
showDatePickerDialog = true
}) {
Icon(Icons.Default.Add, stringResource(R.string.button_add_new_period))
}
},
) { paddingVals ->
val periodsPerYear = periods.groupBy { period -> period.startDate.year }
val periodsPerYear = viewModel.periods.groupBy { period -> period.startDateToLocalDate().year }
LazyColumn(
modifier = Modifier
.padding(paddingVals)
Expand Down Expand Up @@ -119,8 +125,17 @@ fun MainScreen(healthConnectManager: HealthConnectManager) {
modifier = Modifier.semantics { heading() },
)
}
items(periodsForYear) {
PeriodRow(period = it) {
items(
periodsForYear
.sortedWith(
compareByDescending {
it.startDateToLocalDate()
},
),
) {
PeriodRow(
period = it
) {
selectedPeriod = it
showDatePickerDialog = true
}
Expand All @@ -134,21 +149,18 @@ fun MainScreen(healthConnectManager: HealthConnectManager) {
) { updatedSelectedPeriod ->
showDatePickerDialog = false
selectedPeriod = updatedSelectedPeriod
saveMenstruationPeriod(updatedSelectedPeriod)
}
}
}
}

data class Period(
val startDate: LocalDate,
val endDate: LocalDate?,
) {
val isCurrent = endDate == null
companion object {
val EMPTY = Period(LocalDate.now(), null)
}
}

object MainScreen {
val padding = 16.dp
val emptyRecord = MenstruationPeriodRecord(
startTime = Instant.now(),
endTime = Instant.now(),
startZoneOffset = null,
endZoneOffset = null,
)
}
5 changes: 4 additions & 1 deletion app/src/main/java/com/eevajonna/period/ui/utils/TimeUtils.kt
Expand Up @@ -6,7 +6,6 @@ import java.time.ZoneId
import java.time.ZonedDateTime

object TimeUtils {
// TODO: ofEpochDay / toEpochDay (LocalDate)
fun localDateToMilliseconds(date: LocalDate): Long {
return ZonedDateTime.of(date.atStartOfDay(), ZoneId.of("UTC")).toInstant().toEpochMilli()
}
Expand All @@ -16,4 +15,8 @@ object TimeUtils {
val instant = Instant.ofEpochMilli(millis)
return instant.atZone(ZoneId.of("UTC")).toLocalDateTime().toLocalDate()
}

fun instantToLocalDate(instant: Instant): LocalDate {
return instant.atZone(ZoneId.of("UTC")).toLocalDateTime().toLocalDate()
}
}

0 comments on commit 8e34516

Please sign in to comment.