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