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
15package android.support.v17.leanback.widget.picker;
16
17import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
18
19import android.content.Context;
20import android.content.res.TypedArray;
21import android.support.annotation.RestrictTo;
22import android.support.v17.leanback.R;
23import android.text.TextUtils;
24import android.util.AttributeSet;
25import android.util.Log;
26
27import java.text.DateFormat;
28import java.text.ParseException;
29import java.text.SimpleDateFormat;
30import java.util.ArrayList;
31import java.util.Calendar;
32import java.util.Locale;
33import java.util.TimeZone;
34
35/**
36 * {@link DatePicker} is a directly subclass of {@link Picker}.
37 * This class is a widget for selecting a date. The date can be selected by a
38 * year, month, and day Columns. The "minDate" and "maxDate" from which dates to be selected
39 * can be customized.  The columns can be customized by attribute "datePickerFormat" or
40 * {@link #setDatePickerFormat(String)}.
41 *
42 * @attr ref R.styleable#lbDatePicker_android_maxDate
43 * @attr ref R.styleable#lbDatePicker_android_minDate
44 * @attr ref R.styleable#lbDatePicker_datePickerFormat
45 * @hide
46 */
47@RestrictTo(LIBRARY_GROUP)
48public class DatePicker extends Picker {
49
50    static final String LOG_TAG = "DatePicker";
51
52    private String mDatePickerFormat;
53    PickerColumn mMonthColumn;
54    PickerColumn mDayColumn;
55    PickerColumn mYearColumn;
56    int mColMonthIndex;
57    int mColDayIndex;
58    int mColYearIndex;
59
60    final static String DATE_FORMAT = "MM/dd/yyyy";
61    final DateFormat mDateFormat = new SimpleDateFormat(DATE_FORMAT);
62    PickerUtility.DateConstant mConstant;
63
64    Calendar mMinDate;
65    Calendar mMaxDate;
66    Calendar mCurrentDate;
67    Calendar mTempDate;
68
69    public DatePicker(Context context, AttributeSet attrs) {
70        this(context, attrs, 0);
71    }
72
73    public DatePicker(Context context, AttributeSet attrs, int defStyleAttr) {
74        super(context, attrs, defStyleAttr);
75
76        updateCurrentLocale();
77        setSeparator(mConstant.dateSeparator);
78
79        final TypedArray attributesArray = context.obtainStyledAttributes(attrs,
80                R.styleable.lbDatePicker);
81        String minDate = attributesArray.getString(R.styleable.lbDatePicker_android_minDate);
82        String maxDate = attributesArray.getString(R.styleable.lbDatePicker_android_maxDate);
83        mTempDate.clear();
84        if (!TextUtils.isEmpty(minDate)) {
85            if (!parseDate(minDate, mTempDate)) {
86                mTempDate.set(1900, 0, 1);
87            }
88        } else {
89            mTempDate.set(1900, 0, 1);
90        }
91        mMinDate.setTimeInMillis(mTempDate.getTimeInMillis());
92
93        mTempDate.clear();
94        if (!TextUtils.isEmpty(maxDate)) {
95            if (!parseDate(maxDate, mTempDate)) {
96                mTempDate.set(2100, 0, 1);
97            }
98        } else {
99            mTempDate.set(2100, 0, 1);
100        }
101        mMaxDate.setTimeInMillis(mTempDate.getTimeInMillis());
102
103        String datePickerFormat = attributesArray
104                .getString(R.styleable.lbDatePicker_datePickerFormat);
105        if (TextUtils.isEmpty(datePickerFormat)) {
106            datePickerFormat = new String(
107                    android.text.format.DateFormat.getDateFormatOrder(context));
108        }
109        setDatePickerFormat(datePickerFormat);
110    }
111
112    private boolean parseDate(String date, Calendar outDate) {
113        try {
114            outDate.setTime(mDateFormat.parse(date));
115            return true;
116        } catch (ParseException e) {
117            Log.w(LOG_TAG, "Date: " + date + " not in format: " + DATE_FORMAT);
118            return false;
119        }
120    }
121
122    /**
123     * Changes format of showing dates.  For example "YMD".
124     * @param datePickerFormat Format of showing dates.
125     */
126    public void setDatePickerFormat(String datePickerFormat) {
127        if (TextUtils.isEmpty(datePickerFormat)) {
128            datePickerFormat = new String(
129                    android.text.format.DateFormat.getDateFormatOrder(getContext()));
130        }
131        datePickerFormat = datePickerFormat.toUpperCase();
132        if (TextUtils.equals(mDatePickerFormat, datePickerFormat)) {
133            return;
134        }
135        mDatePickerFormat = datePickerFormat;
136        mYearColumn = mMonthColumn = mDayColumn = null;
137        mColYearIndex = mColDayIndex = mColMonthIndex = -1;
138        ArrayList<PickerColumn> columns = new ArrayList<PickerColumn>(3);
139        for (int i = 0; i < datePickerFormat.length(); i++) {
140            switch (datePickerFormat.charAt(i)) {
141            case 'Y':
142                if (mYearColumn != null) {
143                    throw new IllegalArgumentException("datePicker format error");
144                }
145                columns.add(mYearColumn = new PickerColumn());
146                mColYearIndex = i;
147                mYearColumn.setLabelFormat("%d");
148                break;
149            case 'M':
150                if (mMonthColumn != null) {
151                    throw new IllegalArgumentException("datePicker format error");
152                }
153                columns.add(mMonthColumn = new PickerColumn());
154                mMonthColumn.setStaticLabels(mConstant.months);
155                mColMonthIndex = i;
156                break;
157            case 'D':
158                if (mDayColumn != null) {
159                    throw new IllegalArgumentException("datePicker format error");
160                }
161                columns.add(mDayColumn = new PickerColumn());
162                mDayColumn.setLabelFormat("%02d");
163                mColDayIndex = i;
164                break;
165            default:
166                throw new IllegalArgumentException("datePicker format error");
167            }
168        }
169        setColumns(columns);
170        updateSpinners(false);
171    }
172
173    /**
174     * Get format of showing dates.  For example "YMD".  Default value is from
175     * {@link android.text.format.DateFormat#getDateFormatOrder(Context)}.
176     * @return Format of showing dates.
177     */
178    public String getDatePickerFormat() {
179        return mDatePickerFormat;
180    }
181
182    private void updateCurrentLocale() {
183        mConstant = PickerUtility.getDateConstantInstance(Locale.getDefault(),
184                getContext().getResources());
185        mTempDate = PickerUtility.getCalendarForLocale(mTempDate, mConstant.locale);
186        mMinDate = PickerUtility.getCalendarForLocale(mMinDate, mConstant.locale);
187        mMaxDate = PickerUtility.getCalendarForLocale(mMaxDate, mConstant.locale);
188        mCurrentDate = PickerUtility.getCalendarForLocale(mCurrentDate, mConstant.locale);
189
190        if (mMonthColumn != null) {
191            mMonthColumn.setStaticLabels(mConstant.months);
192            setColumnAt(mColMonthIndex, mMonthColumn);
193        }
194    }
195
196    @Override
197    public final void onColumnValueChanged(int column, int newVal) {
198        mTempDate.setTimeInMillis(mCurrentDate.getTimeInMillis());
199        // take care of wrapping of days and months to update greater fields
200        int oldVal = getColumnAt(column).getCurrentValue();
201        if (column == mColDayIndex) {
202            mTempDate.add(Calendar.DAY_OF_MONTH, newVal - oldVal);
203        } else if (column == mColMonthIndex) {
204            mTempDate.add(Calendar.MONTH, newVal - oldVal);
205        } else if (column == mColYearIndex) {
206            mTempDate.add(Calendar.YEAR, newVal - oldVal);
207        } else {
208            throw new IllegalArgumentException();
209        }
210        setDate(mTempDate.get(Calendar.YEAR), mTempDate.get(Calendar.MONTH),
211                mTempDate.get(Calendar.DAY_OF_MONTH));
212        updateSpinners(false);
213    }
214
215
216    /**
217     * Sets the minimal date supported by this {@link DatePicker} in
218     * milliseconds since January 1, 1970 00:00:00 in
219     * {@link TimeZone#getDefault()} time zone.
220     *
221     * @param minDate The minimal supported date.
222     */
223    public void setMinDate(long minDate) {
224        mTempDate.setTimeInMillis(minDate);
225        if (mTempDate.get(Calendar.YEAR) == mMinDate.get(Calendar.YEAR)
226                && mTempDate.get(Calendar.DAY_OF_YEAR) != mMinDate.get(Calendar.DAY_OF_YEAR)) {
227            return;
228        }
229        mMinDate.setTimeInMillis(minDate);
230        if (mCurrentDate.before(mMinDate)) {
231            mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
232        }
233        updateSpinners(false);
234    }
235
236
237    /**
238     * Gets the minimal date supported by this {@link DatePicker} in
239     * milliseconds since January 1, 1970 00:00:00 in
240     * {@link TimeZone#getDefault()} time zone.
241     * <p>
242     * Note: The default minimal date is 01/01/1900.
243     * <p>
244     *
245     * @return The minimal supported date.
246     */
247    public long getMinDate() {
248        return mMinDate.getTimeInMillis();
249    }
250
251    /**
252     * Sets the maximal date supported by this {@link DatePicker} in
253     * milliseconds since January 1, 1970 00:00:00 in
254     * {@link TimeZone#getDefault()} time zone.
255     *
256     * @param maxDate The maximal supported date.
257     */
258    public void setMaxDate(long maxDate) {
259        mTempDate.setTimeInMillis(maxDate);
260        if (mTempDate.get(Calendar.YEAR) == mMaxDate.get(Calendar.YEAR)
261                && mTempDate.get(Calendar.DAY_OF_YEAR) != mMaxDate.get(Calendar.DAY_OF_YEAR)) {
262            return;
263        }
264        mMaxDate.setTimeInMillis(maxDate);
265        if (mCurrentDate.after(mMaxDate)) {
266            mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
267        }
268        updateSpinners(false);
269    }
270
271    /**
272     * Gets the maximal date supported by this {@link DatePicker} in
273     * milliseconds since January 1, 1970 00:00:00 in
274     * {@link TimeZone#getDefault()} time zone.
275     * <p>
276     * Note: The default maximal date is 12/31/2100.
277     * <p>
278     *
279     * @return The maximal supported date.
280     */
281    public long getMaxDate() {
282        return mMaxDate.getTimeInMillis();
283    }
284
285    /**
286     * Gets current date value in milliseconds since January 1, 1970 00:00:00 in
287     * {@link TimeZone#getDefault()} time zone.
288     *
289     * @return Current date values.
290     */
291    public long getDate() {
292        return mCurrentDate.getTimeInMillis();
293    }
294
295    private void setDate(int year, int month, int dayOfMonth) {
296        mCurrentDate.set(year, month, dayOfMonth);
297        if (mCurrentDate.before(mMinDate)) {
298            mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
299        } else if (mCurrentDate.after(mMaxDate)) {
300            mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
301        }
302    }
303
304    /**
305     * Update the current date.
306     *
307     * @param year The year.
308     * @param month The month which is <strong>starting from zero</strong>.
309     * @param dayOfMonth The day of the month.
310     * @param animation True to run animation to scroll the column.
311     */
312    public void updateDate(int year, int month, int dayOfMonth, boolean animation) {
313        if (!isNewDate(year, month, dayOfMonth)) {
314            return;
315        }
316        setDate(year, month, dayOfMonth);
317        updateSpinners(animation);
318    }
319
320    private boolean isNewDate(int year, int month, int dayOfMonth) {
321        return (mCurrentDate.get(Calendar.YEAR) != year
322                || mCurrentDate.get(Calendar.MONTH) != dayOfMonth
323                || mCurrentDate.get(Calendar.DAY_OF_MONTH) != month);
324    }
325
326    private static boolean updateMin(PickerColumn column, int value) {
327        if (value != column.getMinValue()) {
328            column.setMinValue(value);
329            return true;
330        }
331        return false;
332    }
333
334    private static boolean updateMax(PickerColumn column, int value) {
335        if (value != column.getMaxValue()) {
336            column.setMaxValue(value);
337            return true;
338        }
339        return false;
340    }
341
342    private static int[] DATE_FIELDS = {Calendar.DAY_OF_MONTH, Calendar.MONTH, Calendar.YEAR};
343
344    // Following implementation always keeps up-to-date date ranges (min & max values) no matter
345    // what the currently selected date is. This prevents the constant updating of date values while
346    // scrolling vertically and thus fixes the animation jumps that used to happen when we reached
347    // the endpoint date field values since the adapter values do not change while scrolling up
348    // & down across a single field.
349    void updateSpinnersImpl(boolean animation) {
350        // set the spinner ranges respecting the min and max dates
351        int dateFieldIndices[] = {mColDayIndex, mColMonthIndex, mColYearIndex};
352
353        boolean allLargerDateFieldsHaveBeenEqualToMinDate = true;
354        boolean allLargerDateFieldsHaveBeenEqualToMaxDate = true;
355        for(int i = DATE_FIELDS.length - 1; i >= 0; i--) {
356            boolean dateFieldChanged = false;
357            if (dateFieldIndices[i] < 0)
358                continue;
359
360            int currField = DATE_FIELDS[i];
361            PickerColumn currPickerColumn = getColumnAt(dateFieldIndices[i]);
362
363            if (allLargerDateFieldsHaveBeenEqualToMinDate) {
364                dateFieldChanged |= updateMin(currPickerColumn,
365                        mMinDate.get(currField));
366            } else {
367                dateFieldChanged |= updateMin(currPickerColumn,
368                        mCurrentDate.getActualMinimum(currField));
369            }
370
371            if (allLargerDateFieldsHaveBeenEqualToMaxDate) {
372                dateFieldChanged |= updateMax(currPickerColumn,
373                        mMaxDate.get(currField));
374            } else {
375                dateFieldChanged |= updateMax(currPickerColumn,
376                        mCurrentDate.getActualMaximum(currField));
377            }
378
379            allLargerDateFieldsHaveBeenEqualToMinDate &=
380                    (mCurrentDate.get(currField) == mMinDate.get(currField));
381            allLargerDateFieldsHaveBeenEqualToMaxDate &=
382                    (mCurrentDate.get(currField) == mMaxDate.get(currField));
383
384            if (dateFieldChanged) {
385                setColumnAt(dateFieldIndices[i], currPickerColumn);
386            }
387            setColumnValue(dateFieldIndices[i], mCurrentDate.get(currField), animation);
388        }
389    }
390
391    private void updateSpinners(final boolean animation) {
392        // update range in a post call.  The reason is that RV does not allow notifyDataSetChange()
393        // in scroll pass.  UpdateSpinner can be called in a scroll pass, UpdateSpinner() may
394        // notifyDataSetChange to update the range.
395        post(new Runnable() {
396            @Override
397            public void run() {
398                updateSpinnersImpl(animation);
399            }
400        });
401    }
402}