1 package com.android.healthconnect.controller.data.entries.datenavigation
2 
3 import android.content.Context
4 import android.view.View
5 import android.view.ViewGroup
6 import android.widget.ArrayAdapter
7 import android.widget.TextView
8 import com.android.healthconnect.controller.R
9 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod.PERIOD_DAY
10 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod.PERIOD_MONTH
11 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod.PERIOD_WEEK
12 import com.android.healthconnect.controller.utils.LocalDateTimeFormatter
13 import com.android.healthconnect.controller.utils.SystemTimeSource
14 import com.android.healthconnect.controller.utils.TimeSource
15 import java.time.Instant
16 import java.time.LocalDate
17 import java.time.Period
18 import java.time.temporal.TemporalAdjusters
19 import java.time.temporal.WeekFields
20 import java.util.Locale
21 
22 /** Adapter for the date picker in [DateNavigationView]. */
23 class DatePickerSpinnerAdapter(
24     context: Context,
25     private var displayedStartDate: Instant,
26     var period: DateNavigationPeriod,
27     private val timeSource: TimeSource = SystemTimeSource
28 ) :
29     ArrayAdapter<String>(
30         context,
31         R.layout.date_navigation_spinner_item,
32         listOf(
33             context.getString(R.string.date_picker_day),
34             context.getString(R.string.date_picker_week),
35             context.getString(R.string.date_picker_month))) {
36     private val dateFormatter = LocalDateTimeFormatter(context)
37 
setStartTimeAndPeriodnull38     fun setStartTimeAndPeriod(displayedStartTime: Instant, period: DateNavigationPeriod) {
39         this.displayedStartDate = displayedStartTime
40         this.period = period
41         notifyDataSetChanged()
42     }
43 
getViewnull44     override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
45         val view = super.getView(position, convertView, parent)
46         if (view is TextView) {
47             getItem(position)?.let {
48                 val dateView = formatDateTimeForTimePeriod(displayedStartDate, period)
49                 view.text = maybeReplaceWithTemporalDeixis(dateView, displayedStartDate, period)
50             }
51         }
52         return view
53     }
54 
55     /**
56      * Formats [startTime] and [period] as follows:
57      * * Day: "Sun, Aug 20" or "Mon, Aug 20, 2022"
58      * * Week: "Aug 21-27" or "Aug 21-27, 2022"
59      * * Month: "August" or "August 2022"
60      */
formatDateTimeForTimePeriodnull61     private fun formatDateTimeForTimePeriod(
62         startTime: Instant,
63         period: DateNavigationPeriod
64     ): String {
65         if (areInSameYear(startTime, Instant.ofEpochMilli(timeSource.currentTimeMillis()))) {
66             return when (period) {
67                 PERIOD_DAY -> {
68                     dateFormatter.formatWeekdayDateWithoutYear(startTime)
69                 }
70                 PERIOD_WEEK -> {
71                     dateFormatter.formatDateRangeWithoutYear(
72                         startTime, startTime.plus(Period.ofWeeks(1)))
73                 }
74                 PERIOD_MONTH -> {
75                     dateFormatter.formatMonthWithoutYear(startTime)
76                 }
77             }
78         }
79         return when (period) {
80             PERIOD_DAY -> {
81                 dateFormatter.formatWeekdayDateWithYear(startTime)
82             }
83             PERIOD_WEEK -> {
84                 dateFormatter.formatDateRangeWithYear(startTime, startTime.plus(Period.ofWeeks(1)))
85             }
86             PERIOD_MONTH -> {
87                 dateFormatter.formatMonthWithYear(startTime)
88             }
89         }
90     }
91 
92     /**
93      * Replaces recent dates with:
94      * * Day: "Today", "Yesterday"
95      * * Week: "This week", "Last week"
96      * * Month: "This month", "Last month"
97      *
98      * <p>No-op for other dates.
99      */
maybeReplaceWithTemporalDeixisnull100     private fun maybeReplaceWithTemporalDeixis(
101         dateView: String,
102         selectedDate: Instant,
103         period: DateNavigationPeriod
104     ): String {
105         val currentPeriod =
106             LocalDate.ofInstant(
107                     Instant.ofEpochMilli(timeSource.currentTimeMillis()),
108                     timeSource.deviceZoneOffset())
109                 .atStartOfDay(timeSource.deviceZoneOffset())
110                 .toInstant()
111         val previousPeriod =
112             LocalDate.ofInstant(currentPeriod, timeSource.deviceZoneOffset())
113                 .minus(toPeriod(period))
114                 .atStartOfDay(timeSource.deviceZoneOffset())
115                 .toInstant()
116 
117         return if (!areInSameYear(selectedDate, currentPeriod)) {
118             dateView
119         } else if (areInSamePeriod(selectedDate, currentPeriod, period)) {
120             temporalDeixisForCurrentPeriod(period)
121         } else if (areInSamePeriod(selectedDate, previousPeriod, period)) {
122             temporalDeixisForLastPeriod(period)
123         } else {
124             dateView
125         }
126     }
127 
128     /** Returns "Today", "This week", "This month". */
temporalDeixisForCurrentPeriodnull129     private fun temporalDeixisForCurrentPeriod(period: DateNavigationPeriod): String {
130         return when (period) {
131             PERIOD_DAY -> context.getString(R.string.today_header)
132             PERIOD_WEEK -> context.getString(R.string.this_week_header)
133             PERIOD_MONTH -> context.getString(R.string.this_month_header)
134         }
135     }
136 
137     /** Returns "Yesterday", "Last week", "Last month". */
temporalDeixisForLastPeriodnull138     private fun temporalDeixisForLastPeriod(period: DateNavigationPeriod): String {
139         return when (period) {
140             PERIOD_DAY -> context.getString(R.string.yesterday_header)
141             PERIOD_WEEK -> context.getString(R.string.last_week_header)
142             PERIOD_MONTH -> context.getString(R.string.last_month_header)
143         }
144     }
145 
146     /** Whether [instant1] and [instant2] are in the same [DateNavigationPeriod]. */
areInSamePeriodnull147     private fun areInSamePeriod(
148         instant1: Instant,
149         instant2: Instant,
150         period: DateNavigationPeriod
151     ): Boolean {
152         return when (period) {
153             PERIOD_DAY -> areOnSameDay(instant1, instant2)
154             PERIOD_WEEK -> areOnSameWeek(instant1, instant2)
155             PERIOD_MONTH -> areInSameMonth(instant1, instant2)
156         }
157     }
158 
159     /** Whether [instant1] and [instant2] are in the same calendar day. */
areOnSameDaynull160     private fun areOnSameDay(instant1: Instant, instant2: Instant): Boolean {
161         val localDate1 = instant1.atZone(timeSource.deviceZoneOffset()).toLocalDate()
162         val localDate2 = instant2.atZone(timeSource.deviceZoneOffset()).toLocalDate()
163         return localDate1 == localDate2
164     }
165 
166     /** Whether [instant1] and [instant2] are on the same calendar week. */
areOnSameWeeknull167     private fun areOnSameWeek(instant1: Instant, instant2: Instant): Boolean {
168         val firstDayOfWeekField = WeekFields.of(Locale.getDefault()).firstDayOfWeek
169         val localDate1 = instant1.atZone(timeSource.deviceZoneOffset()).toLocalDate()
170         val localDate2 = instant2.atZone(timeSource.deviceZoneOffset()).toLocalDate()
171         val firstDayOfWeek1 = localDate1.with(TemporalAdjusters.previousOrSame(firstDayOfWeekField))
172         val firstDayOfWeek2 = localDate2.with(TemporalAdjusters.previousOrSame(firstDayOfWeekField))
173         return firstDayOfWeek1 == firstDayOfWeek2
174     }
175 
176     /** Whether [instant1] and [instant2] are inn the same calendar month. */
areInSameMonthnull177     private fun areInSameMonth(instant1: Instant, instant2: Instant): Boolean {
178         val monthOfYear1 = instant1.atZone(timeSource.deviceZoneOffset()).toLocalDate().month
179         val monthOfYear2 = instant2.atZone(timeSource.deviceZoneOffset()).toLocalDate().month
180         return monthOfYear1 == monthOfYear2
181     }
182 
183     /** Whether [instant1] and [instant2] are inn the same calendar year. */
areInSameYearnull184     private fun areInSameYear(instant1: Instant, instant2: Instant): Boolean {
185         val year1 = instant1.atZone(timeSource.deviceZoneOffset()).toLocalDate().year
186         val year2 = instant2.atZone(timeSource.deviceZoneOffset()).toLocalDate().year
187         return year1 == year2
188     }
189 }
190