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