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