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