1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 * in compliance with the License. You may obtain a copy of the License at 6 * 7 * ``` 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * ``` 10 * 11 * Unless required by applicable law or agreed to in writing, software distributed under the License 12 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 13 * or implied. See the License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 package com.android.healthconnect.controller.data.entries.datenavigation 17 18 import android.content.Context 19 import android.util.AttributeSet 20 import android.view.View 21 import android.view.accessibility.AccessibilityNodeInfo 22 import android.widget.AdapterView 23 import android.widget.ImageButton 24 import android.widget.Spinner 25 import androidx.constraintlayout.widget.ConstraintLayout 26 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat 27 import com.android.healthconnect.controller.R 28 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod.PERIOD_DAY 29 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod.PERIOD_MONTH 30 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod.PERIOD_WEEK 31 import com.android.healthconnect.controller.utils.SystemTimeSource 32 import com.android.healthconnect.controller.utils.TimeSource 33 import com.android.healthconnect.controller.utils.logging.DataEntriesElement 34 import com.android.healthconnect.controller.utils.logging.HealthConnectLogger 35 import com.android.healthconnect.controller.utils.logging.HealthConnectLoggerEntryPoint 36 import com.android.healthconnect.controller.utils.toLocalDate 37 import dagger.hilt.android.EntryPointAccessors 38 import java.time.DayOfWeek 39 import java.time.Instant 40 import java.time.LocalDate 41 import java.time.Period 42 import java.time.ZoneId 43 44 /** Allows the user to navigate in time to see their past data. */ 45 class DateNavigationView 46 @JvmOverloads 47 constructor( 48 context: Context, 49 attrs: AttributeSet? = null, 50 defStyleAttr: Int = 0, 51 defStyleRes: Int = 0, 52 private val timeSource: TimeSource = SystemTimeSource 53 ) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) { 54 55 private val logger: HealthConnectLogger 56 57 private lateinit var previousDayButton: ImageButton 58 private lateinit var nextDayButton: ImageButton 59 private lateinit var datePickerSpinner: Spinner 60 private var selectedDate = Instant.ofEpochMilli(timeSource.currentTimeMillis()) 61 private var period: DateNavigationPeriod = PERIOD_DAY 62 private var onDateChangedListener: OnDateChangedListener? = null 63 64 init { 65 val hiltEntryPoint = 66 EntryPointAccessors.fromApplication( 67 context.applicationContext, HealthConnectLoggerEntryPoint::class.java) 68 logger = hiltEntryPoint.logger() 69 70 val view = inflate(context, R.layout.widget_date_navigation_with_spinner, this) 71 bindDateTextView(view) 72 bindNextDayButton(view) 73 bindPreviousDayButton(view) 74 updateDisplayedDates() 75 } 76 setDateChangedListenernull77 fun setDateChangedListener(mDateChangedListener: OnDateChangedListener?) { 78 this.onDateChangedListener = mDateChangedListener 79 } 80 setDatenull81 fun setDate(date: Instant) { 82 selectedDate = date 83 updateDisplayedDates() 84 } 85 setPeriodnull86 fun setPeriod(period: DateNavigationPeriod) { 87 this.period = period 88 updateDisplayedDates() 89 } 90 getDatenull91 fun getDate(): Instant { 92 return selectedDate 93 } 94 getPeriodnull95 fun getPeriod(): DateNavigationPeriod { 96 return period 97 } 98 bindNextDayButtonnull99 private fun bindNextDayButton(view: View) { 100 nextDayButton = view.findViewById(R.id.navigation_next_day) as ImageButton 101 logger.logImpression(DataEntriesElement.NEXT_DAY_BUTTON) 102 nextDayButton.setOnClickListener { 103 logger.logInteraction(DataEntriesElement.NEXT_DAY_BUTTON) 104 selectedDate = 105 selectedDate.atZone(ZoneId.systemDefault()).plus(toPeriod(period)).toInstant() 106 updateDisplayedDates() 107 } 108 } 109 bindPreviousDayButtonnull110 private fun bindPreviousDayButton(view: View) { 111 previousDayButton = view.findViewById(R.id.navigation_previous_day) as ImageButton 112 logger.logImpression(DataEntriesElement.PREVIOUS_DAY_BUTTON) 113 previousDayButton.setOnClickListener { 114 logger.logInteraction(DataEntriesElement.PREVIOUS_DAY_BUTTON) 115 selectedDate = 116 selectedDate.atZone(ZoneId.systemDefault()).minus(toPeriod(period)).toInstant() 117 updateDisplayedDates() 118 } 119 } 120 bindDateTextViewnull121 private fun bindDateTextView(view: View) { 122 datePickerSpinner = view.findViewById(R.id.date_picker_spinner) as Spinner 123 124 val adapter = 125 DatePickerSpinnerAdapter(view.context, getDisplayedStartDate(), period, timeSource) 126 adapter.setDropDownViewResource(R.layout.date_navigation_spinner_item) 127 datePickerSpinner.adapter = adapter 128 129 datePickerSpinner.onItemSelectedListener = 130 object : AdapterView.OnItemSelectedListener { 131 override fun onNothingSelected(parent: AdapterView<*>?) = Unit 132 133 override fun onItemSelected( 134 parent: AdapterView<*>?, 135 unused: View?, 136 position: Int, 137 id: Long 138 ) { 139 val period: DateNavigationPeriod = 140 when (position) { 141 0 -> PERIOD_DAY 142 1 -> PERIOD_WEEK 143 2 -> PERIOD_MONTH 144 else -> throw IllegalStateException("Not supported time period.") 145 } 146 setPeriod(period) 147 updateDisplayedDates() 148 } 149 } 150 151 datePickerSpinner.accessibilityDelegate = 152 object : AccessibilityDelegate() { 153 override fun onInitializeAccessibilityNodeInfo( 154 host: View, 155 info: AccessibilityNodeInfo 156 ) { 157 super.onInitializeAccessibilityNodeInfo(host, info) 158 info.addAction( 159 AccessibilityNodeInfo.AccessibilityAction( 160 AccessibilityNodeInfoCompat.ACTION_CLICK, 161 context.getString(R.string.selected_date_view_action_description))) 162 } 163 } 164 } 165 updateDisplayedDatesnull166 private fun updateDisplayedDates() { 167 onDateChangedListener?.onDateChanged(getDisplayedStartDate(), period) 168 val today = 169 LocalDate.ofInstant( 170 Instant.ofEpochMilli(timeSource.currentTimeMillis()), 171 timeSource.deviceZoneOffset()) 172 .atStartOfDay(timeSource.deviceZoneOffset()) 173 .toInstant() 174 175 // This can happen if e.g. today is Monday, user navigates back to Sunday, sets the period 176 // from Day to Week (underlying selected day is still Sunday), navigates to the next week 177 // (underlying selected day is next Sunday), sets the period back to Day => displayed day 178 // would be next Sunday. Instead, display today. 179 if (today.isBefore(selectedDate)) { 180 selectedDate = today 181 } 182 183 val displayedEndDate = 184 getDisplayedStartDate() 185 .toLocalDate() 186 .atStartOfDay(ZoneId.systemDefault()) 187 .plus(toPeriod(period)) 188 .toInstant() 189 nextDayButton.isEnabled = !displayedEndDate.isAfter(today) 190 (datePickerSpinner.adapter as DatePickerSpinnerAdapter).setStartTimeAndPeriod( 191 getDisplayedStartDate(), period) 192 } 193 getDisplayedStartDatenull194 private fun getDisplayedStartDate(): Instant = 195 when (period) { 196 PERIOD_DAY -> { 197 selectedDate 198 .atZone(ZoneId.systemDefault()) 199 .toLocalDate() 200 .atStartOfDay(ZoneId.systemDefault()) 201 .toInstant() 202 } 203 PERIOD_WEEK -> { 204 val dayOfWeek: DayOfWeek = 205 selectedDate.atZone(ZoneId.systemDefault()).toLocalDate().dayOfWeek 206 val dayOfWeekOffset: Int = dayOfWeek.value - 1 207 selectedDate 208 .atZone(ZoneId.systemDefault()) 209 .minus(Period.ofDays(dayOfWeekOffset)) 210 .toLocalDate() 211 .atStartOfDay(ZoneId.systemDefault()) 212 .toInstant() 213 } 214 PERIOD_MONTH -> { 215 val dayOfMonth = 216 selectedDate.atZone(ZoneId.systemDefault()).toLocalDate().dayOfMonth 217 val dayOfMonthOffset: Int = dayOfMonth - 1 218 selectedDate 219 .atZone(ZoneId.systemDefault()) 220 .minus(Period.ofDays(dayOfMonthOffset)) 221 .toLocalDate() 222 .atStartOfDay(ZoneId.systemDefault()) 223 .toInstant() 224 } 225 } 226 227 interface OnDateChangedListener { onDateChangednull228 fun onDateChanged(displayedStartDate: Instant, period: DateNavigationPeriod) 229 } 230 } 231