1 /*
2  * Copyright (C) 2015 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  * http://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software distributed under the License
10  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11  * or implied. See the License for the specific language governing permissions and limitations under
12  * the License.
13  */
14 
15 package androidx.leanback.widget.picker;
16 
17 import android.annotation.SuppressLint;
18 import android.content.Context;
19 import android.content.res.TypedArray;
20 import android.text.TextUtils;
21 import android.util.AttributeSet;
22 import android.util.Log;
23 
24 import androidx.annotation.VisibleForTesting;
25 import androidx.core.view.ViewCompat;
26 import androidx.leanback.R;
27 
28 import java.text.DateFormat;
29 import java.text.ParseException;
30 import java.text.SimpleDateFormat;
31 import java.util.ArrayList;
32 import java.util.Calendar;
33 import java.util.List;
34 import java.util.Locale;
35 import java.util.TimeZone;
36 
37 /**
38  * {@link DatePicker} is a directly subclass of {@link Picker}.
39  * This class is a widget for selecting a date. The date can be selected by a
40  * year, month, and day Columns. The "minDate" and "maxDate" from which dates to be selected
41  * can be customized.  The columns can be customized by attribute "datePickerFormat" or
42  * {@link #setDatePickerFormat(String)}.
43  *
44  * {@link android.R.attr#maxDate}
45  * {@link android.R.attr#minDate}
46  * {@link R.attr#datePickerFormat}
47  */
48 public class DatePicker extends Picker {
49 
50     private static final String LOG_TAG = "DatePicker";
51 
52     private String mDatePickerFormat;
53     private PickerColumn mMonthColumn;
54     private PickerColumn mDayColumn;
55     private PickerColumn mYearColumn;
56     private int mColMonthIndex;
57     private int mColDayIndex;
58     private int mColYearIndex;
59 
60     private static final String DATE_FORMAT = "MM/dd/yyyy";
61     private final DateFormat mDateFormat = new SimpleDateFormat(DATE_FORMAT, Locale.getDefault());
62     private PickerUtility.DateConstant mConstant;
63 
64     private Calendar mMinDate;
65     private Calendar mMaxDate;
66     private Calendar mCurrentDate;
67     private Calendar mTempDate;
68 
DatePicker(Context context, AttributeSet attrs)69     public DatePicker(Context context, AttributeSet attrs) {
70         this(context, attrs, R.attr.datePickerStyle);
71     }
72 
73     @SuppressLint("CustomViewStyleable")
DatePicker(Context context, AttributeSet attrs, int defStyleAttr)74     public DatePicker(Context context, AttributeSet attrs, int defStyleAttr) {
75         super(context, attrs, defStyleAttr);
76 
77         updateCurrentLocale();
78 
79         final TypedArray attributesArray = context.obtainStyledAttributes(attrs,
80                 R.styleable.lbDatePicker);
81         ViewCompat.saveAttributeDataForStyleable(
82                 this, context, R.styleable.lbDatePicker, attrs, attributesArray, 0, 0);
83         String minDate;
84         String maxDate;
85         String datePickerFormat;
86         try {
87             minDate = attributesArray.getString(R.styleable.lbDatePicker_android_minDate);
88             maxDate = attributesArray.getString(R.styleable.lbDatePicker_android_maxDate);
89             datePickerFormat = attributesArray
90                     .getString(R.styleable.lbDatePicker_datePickerFormat);
91         } finally {
92             attributesArray.recycle();
93         }
94         mTempDate.clear();
95         if (!TextUtils.isEmpty(minDate)) {
96             if (!parseDate(minDate, mTempDate)) {
97                 mTempDate.set(1900, 0, 1);
98             }
99         } else {
100             mTempDate.set(1900, 0, 1);
101         }
102         mMinDate.setTimeInMillis(mTempDate.getTimeInMillis());
103 
104         mTempDate.clear();
105         if (!TextUtils.isEmpty(maxDate)) {
106             if (!parseDate(maxDate, mTempDate)) {
107                 mTempDate.set(2100, 0, 1);
108             }
109         } else {
110             mTempDate.set(2100, 0, 1);
111         }
112         mMaxDate.setTimeInMillis(mTempDate.getTimeInMillis());
113 
114         if (TextUtils.isEmpty(datePickerFormat)) {
115             datePickerFormat = new String(
116                     android.text.format.DateFormat.getDateFormatOrder(context));
117         }
118         setDatePickerFormat(datePickerFormat);
119     }
120 
parseDate(String date, Calendar outDate)121     private boolean parseDate(String date, Calendar outDate) {
122         try {
123             outDate.setTime(mDateFormat.parse(date));
124             return true;
125         } catch (ParseException e) {
126             Log.w(LOG_TAG, "Date: " + date + " not in format: " + DATE_FORMAT);
127             return false;
128         }
129     }
130 
131     /**
132      * Returns the best localized representation of the date for the given date format and the
133      * current locale.
134      *
135      * @param datePickerFormat The date format skeleton (e.g. "dMy") used to gather the
136      *                         appropriate representation of the date in the current locale.
137      *
138      * @return The best localized representation of the date for the given date format
139      */
140     @VisibleForTesting
getBestYearMonthDayPattern(String datePickerFormat)141     String getBestYearMonthDayPattern(String datePickerFormat) {
142         final String yearPattern = android.text.format.DateFormat.getBestDateTimePattern(
143                 mConstant.locale, datePickerFormat);
144         return TextUtils.isEmpty(yearPattern) ? DATE_FORMAT : yearPattern;
145     }
146 
147     /**
148      * Extracts the separators used to separate date fields (including before the first and after
149      * the last date field). The separators can vary based on the individual locale date format,
150      * defined in the Unicode CLDR and cannot be supposed to be "/".
151      *
152      * See http://unicode.org/cldr/trac/browser/trunk/common/main
153      *
154      * For example, for Croatian in dMy format, the best localized representation is "d. M. y". This
155      * method returns {"", ".", ".", "."}, where the first separator indicates nothing needs to be
156      * displayed to the left of the day field, "." needs to be displayed tos the right of the day
157      * field, and so forth.
158      *
159      * @return The ArrayList of separators to populate between the actual date fields in the
160      * DatePicker.
161      */
162     @VisibleForTesting
extractSeparators()163     List<CharSequence> extractSeparators() {
164         // Obtain the time format string per the current locale (e.g. h:mm a)
165         String hmaPattern = getBestYearMonthDayPattern(mDatePickerFormat);
166 
167         List<CharSequence> separators = new ArrayList<>();
168         StringBuilder sb = new StringBuilder();
169         char lastChar = '\0';
170         // See http://www.unicode.org/reports/tr35/tr35-dates.html for date formats
171         final char[] dateFormats = {'Y', 'y', 'M', 'm', 'D', 'd'};
172         boolean processingQuote = false;
173         for (int i = 0; i < hmaPattern.length(); i++) {
174             char c = hmaPattern.charAt(i);
175             if (c == ' ') {
176                 continue;
177             }
178             if (c == '\'') {
179                 if (!processingQuote) {
180                     sb.setLength(0);
181                     processingQuote = true;
182                 } else {
183                     processingQuote = false;
184                 }
185                 continue;
186             }
187             if (processingQuote) {
188                 sb.append(c);
189             } else {
190                 if (isAnyOf(c, dateFormats)) {
191                     if (c != lastChar) {
192                         separators.add(sb.toString());
193                         sb.setLength(0);
194                     }
195                 } else {
196                     sb.append(c);
197                 }
198             }
199             lastChar = c;
200         }
201         separators.add(sb.toString());
202         return separators;
203     }
204 
isAnyOf(char c, char[] any)205     private static boolean isAnyOf(char c, char[] any) {
206         for (int i = 0; i < any.length; i++) {
207             if (c == any[i]) {
208                 return true;
209             }
210         }
211         return false;
212     }
213 
214     /**
215      * Changes format of showing dates.  For example "YMD".
216      * @param datePickerFormat Format of showing dates.
217      */
setDatePickerFormat(String datePickerFormat)218     public void setDatePickerFormat(String datePickerFormat) {
219         if (TextUtils.isEmpty(datePickerFormat)) {
220             datePickerFormat = new String(
221                     android.text.format.DateFormat.getDateFormatOrder(getContext()));
222         }
223         if (TextUtils.equals(mDatePickerFormat, datePickerFormat)) {
224             return;
225         }
226         mDatePickerFormat = datePickerFormat;
227         List<CharSequence> separators = extractSeparators();
228         if (separators.size() != (datePickerFormat.length() + 1)) {
229             throw new IllegalStateException("Separators size: " + separators.size() + " must equal"
230                     + " the size of datePickerFormat: " + datePickerFormat.length() + " + 1");
231         }
232         setSeparators(separators);
233         mYearColumn = mMonthColumn = mDayColumn = null;
234         mColYearIndex = mColDayIndex = mColMonthIndex = -1;
235         String dateFieldsPattern = datePickerFormat.toUpperCase(mConstant.locale);
236         ArrayList<PickerColumn> columns = new ArrayList<>(3);
237         for (int i = 0; i < dateFieldsPattern.length(); i++) {
238             switch (dateFieldsPattern.charAt(i)) {
239             case 'Y':
240                 if (mYearColumn != null) {
241                     throw new IllegalArgumentException("datePicker format error");
242                 }
243                 columns.add(mYearColumn = new PickerColumn());
244                 mColYearIndex = i;
245                 mYearColumn.setLabelFormat("%d");
246                 break;
247             case 'M':
248                 if (mMonthColumn != null) {
249                     throw new IllegalArgumentException("datePicker format error");
250                 }
251                 columns.add(mMonthColumn = new PickerColumn());
252                 mMonthColumn.setStaticLabels(mConstant.months);
253                 mColMonthIndex = i;
254                 break;
255             case 'D':
256                 if (mDayColumn != null) {
257                     throw new IllegalArgumentException("datePicker format error");
258                 }
259                 columns.add(mDayColumn = new PickerColumn());
260                 mDayColumn.setLabelFormat("%02d");
261                 mColDayIndex = i;
262                 break;
263             default:
264                 throw new IllegalArgumentException("datePicker format error");
265             }
266         }
267         setColumns(columns);
268         updateSpinners(false);
269     }
270 
271     /**
272      * Get format of showing dates.  For example "YMD".  Default value is from
273      * {@link android.text.format.DateFormat#getDateFormatOrder(Context)}.
274      * @return Format of showing dates.
275      */
getDatePickerFormat()276     public String getDatePickerFormat() {
277         return mDatePickerFormat;
278     }
279 
updateCurrentLocale()280     private void updateCurrentLocale() {
281         mConstant = PickerUtility.getDateConstantInstance(Locale.getDefault(),
282                 getContext().getResources());
283         mTempDate = PickerUtility.getCalendarForLocale(mTempDate, mConstant.locale);
284         mMinDate = PickerUtility.getCalendarForLocale(mMinDate, mConstant.locale);
285         mMaxDate = PickerUtility.getCalendarForLocale(mMaxDate, mConstant.locale);
286         mCurrentDate = PickerUtility.getCalendarForLocale(mCurrentDate, mConstant.locale);
287 
288         if (mMonthColumn != null) {
289             mMonthColumn.setStaticLabels(mConstant.months);
290             setColumnAt(mColMonthIndex, mMonthColumn);
291         }
292     }
293 
294     @Override
onColumnValueChanged(int columnIndex, int newValue)295     public final void onColumnValueChanged(int columnIndex, int newValue) {
296         mTempDate.setTimeInMillis(mCurrentDate.getTimeInMillis());
297         // take care of wrapping of days and months to update greater fields
298         int oldVal = getColumnAt(columnIndex).getCurrentValue();
299         if (columnIndex == mColDayIndex) {
300             mTempDate.add(Calendar.DAY_OF_MONTH, newValue - oldVal);
301         } else if (columnIndex == mColMonthIndex) {
302             mTempDate.add(Calendar.MONTH, newValue - oldVal);
303         } else if (columnIndex == mColYearIndex) {
304             mTempDate.add(Calendar.YEAR, newValue - oldVal);
305         } else {
306             throw new IllegalArgumentException();
307         }
308         setDate(mTempDate.get(Calendar.YEAR), mTempDate.get(Calendar.MONTH),
309                 mTempDate.get(Calendar.DAY_OF_MONTH));
310     }
311 
312 
313     /**
314      * Sets the minimal date supported by this {@link DatePicker} in
315      * milliseconds since January 1, 1970 00:00:00 in
316      * {@link TimeZone#getDefault()} time zone.
317      *
318      * @param minDate The minimal supported date.
319      */
setMinDate(long minDate)320     public void setMinDate(long minDate) {
321         mTempDate.setTimeInMillis(minDate);
322         if (mTempDate.get(Calendar.YEAR) == mMinDate.get(Calendar.YEAR)
323                 && mTempDate.get(Calendar.DAY_OF_YEAR) != mMinDate.get(Calendar.DAY_OF_YEAR)) {
324             return;
325         }
326         mMinDate.setTimeInMillis(minDate);
327         if (mCurrentDate.before(mMinDate)) {
328             mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
329         }
330         updateSpinners(false);
331     }
332 
333 
334     /**
335      * Gets the minimal date supported by this {@link DatePicker} in
336      * milliseconds since January 1, 1970 00:00:00 in
337      * {@link TimeZone#getDefault()} time zone.
338      * <p>
339      * Note: The default minimal date is 01/01/1900.
340      * <p>
341      *
342      * @return The minimal supported date.
343      */
getMinDate()344     public long getMinDate() {
345         return mMinDate.getTimeInMillis();
346     }
347 
348     /**
349      * Sets the maximal date supported by this {@link DatePicker} in
350      * milliseconds since January 1, 1970 00:00:00 in
351      * {@link TimeZone#getDefault()} time zone.
352      *
353      * @param maxDate The maximal supported date.
354      */
setMaxDate(long maxDate)355     public void setMaxDate(long maxDate) {
356         mTempDate.setTimeInMillis(maxDate);
357         if (mTempDate.get(Calendar.YEAR) == mMaxDate.get(Calendar.YEAR)
358                 && mTempDate.get(Calendar.DAY_OF_YEAR) != mMaxDate.get(Calendar.DAY_OF_YEAR)) {
359             return;
360         }
361         mMaxDate.setTimeInMillis(maxDate);
362         if (mCurrentDate.after(mMaxDate)) {
363             mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
364         }
365         updateSpinners(false);
366     }
367 
368     /**
369      * Gets the maximal date supported by this {@link DatePicker} in
370      * milliseconds since January 1, 1970 00:00:00 in
371      * {@link TimeZone#getDefault()} time zone.
372      * <p>
373      * Note: The default maximal date is 12/31/2100.
374      * <p>
375      *
376      * @return The maximal supported date.
377      */
getMaxDate()378     public long getMaxDate() {
379         return mMaxDate.getTimeInMillis();
380     }
381 
382     /**
383      * Gets current date value in milliseconds since January 1, 1970 00:00:00 in
384      * {@link TimeZone#getDefault()} time zone.
385      *
386      * @return Current date values.
387      */
getDate()388     public long getDate() {
389         return mCurrentDate.getTimeInMillis();
390     }
391 
392     /**
393      * Update the current date. Equivalent to calling {@link #setDate(int, int, int, boolean)} with
394      * year, month, dayOfMonth, false.
395      *
396      * @param year The year.
397      * @param month The month which is <strong>starting from zero</strong>.
398      * @param dayOfMonth The day of the month.
399      */
setDate(int year, int month, int dayOfMonth)400     private void setDate(int year, int month, int dayOfMonth) {
401         setDate(year, month, dayOfMonth, false);
402     }
403 
404     /**
405      * Update the current date in milliseconds since January 1, 1970 00:00:00 in
406      * {@link TimeZone#getDefault()} time zone.
407      *
408      * @param timeInMilliseconds current date value in milliseconds.
409      */
setDate(long timeInMilliseconds)410     public void setDate(long timeInMilliseconds) {
411         mTempDate.setTimeInMillis(timeInMilliseconds);
412         setDate(mTempDate.get(Calendar.YEAR), mTempDate.get(Calendar.MONTH),
413                 mTempDate.get(Calendar.DAY_OF_MONTH), false);
414     }
415 
416     /**
417      * Update the current date.
418      *
419      * @param year The year.
420      * @param month The month which is <strong>starting from zero</strong>.
421      * @param dayOfMonth The day of the month.
422      * @param animation True to run animation to scroll the column.
423      */
setDate(int year, int month, int dayOfMonth, boolean animation)424     public void setDate(int year, int month, int dayOfMonth, boolean animation) {
425         if (!isNewDate(year, month, dayOfMonth)) {
426             return;
427         }
428         mCurrentDate.set(year, month, dayOfMonth);
429         if (mCurrentDate.before(mMinDate)) {
430             mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
431         } else if (mCurrentDate.after(mMaxDate)) {
432             mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
433         }
434         updateSpinners(animation);
435     }
436 
isNewDate(int year, int month, int dayOfMonth)437     private boolean isNewDate(int year, int month, int dayOfMonth) {
438         return (mCurrentDate.get(Calendar.YEAR) != year
439                 || mCurrentDate.get(Calendar.MONTH) != dayOfMonth
440                 || mCurrentDate.get(Calendar.DAY_OF_MONTH) != month);
441     }
442 
updateMin(PickerColumn column, int value)443     private static boolean updateMin(PickerColumn column, int value) {
444         if (value != column.getMinValue()) {
445             column.setMinValue(value);
446             return true;
447         }
448         return false;
449     }
450 
updateMax(PickerColumn column, int value)451     private static boolean updateMax(PickerColumn column, int value) {
452         if (value != column.getMaxValue()) {
453             column.setMaxValue(value);
454             return true;
455         }
456         return false;
457     }
458 
459     private static final int[] DATE_FIELDS = {Calendar.DAY_OF_MONTH, Calendar.MONTH, Calendar.YEAR};
460 
461     // Following implementation always keeps up-to-date date ranges (min & max values) no matter
462     // what the currently selected date is. This prevents the constant updating of date values while
463     // scrolling vertically and thus fixes the animation jumps that used to happen when we reached
464     // the endpoint date field values since the adapter values do not change while scrolling up
465     // & down across a single field.
updateSpinnersImpl(boolean animation)466     void updateSpinnersImpl(boolean animation) {
467         // set the spinner ranges respecting the min and max dates
468         int dateFieldIndices[] = {mColDayIndex, mColMonthIndex, mColYearIndex};
469 
470         boolean allLargerDateFieldsHaveBeenEqualToMinDate = true;
471         boolean allLargerDateFieldsHaveBeenEqualToMaxDate = true;
472         for(int i = DATE_FIELDS.length - 1; i >= 0; i--) {
473             boolean dateFieldChanged = false;
474             if (dateFieldIndices[i] < 0)
475                 continue;
476 
477             int currField = DATE_FIELDS[i];
478             PickerColumn currPickerColumn = getColumnAt(dateFieldIndices[i]);
479 
480             if (allLargerDateFieldsHaveBeenEqualToMinDate) {
481                 dateFieldChanged |= updateMin(currPickerColumn,
482                         mMinDate.get(currField));
483             } else {
484                 dateFieldChanged |= updateMin(currPickerColumn,
485                         mCurrentDate.getActualMinimum(currField));
486             }
487 
488             if (allLargerDateFieldsHaveBeenEqualToMaxDate) {
489                 dateFieldChanged |= updateMax(currPickerColumn,
490                         mMaxDate.get(currField));
491             } else {
492                 dateFieldChanged |= updateMax(currPickerColumn,
493                         mCurrentDate.getActualMaximum(currField));
494             }
495 
496             allLargerDateFieldsHaveBeenEqualToMinDate &=
497                     (mCurrentDate.get(currField) == mMinDate.get(currField));
498             allLargerDateFieldsHaveBeenEqualToMaxDate &=
499                     (mCurrentDate.get(currField) == mMaxDate.get(currField));
500 
501             if (dateFieldChanged) {
502                 setColumnAt(dateFieldIndices[i], currPickerColumn);
503             }
504             setColumnValue(dateFieldIndices[i], mCurrentDate.get(currField), animation);
505         }
506     }
507 
updateSpinners(final boolean animation)508     private void updateSpinners(final boolean animation) {
509         // update range in a post call.  The reason is that RV does not allow notifyDataSetChange()
510         // in scroll pass.  UpdateSpinner can be called in a scroll pass, UpdateSpinner() may
511         // notifyDataSetChange to update the range.
512         post(new Runnable() {
513             @Override
514             public void run() {
515                 updateSpinnersImpl(animation);
516             }
517         });
518     }
519 }
520