1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.v17.leanback.widget.picker;
18
19import android.content.Context;
20import android.content.res.TypedArray;
21import android.support.annotation.IntRange;
22import android.support.v17.leanback.R;
23import android.text.TextUtils;
24import android.text.format.DateFormat;
25import android.util.AttributeSet;
26import android.view.View;
27
28import java.text.SimpleDateFormat;
29import java.util.ArrayList;
30import java.util.Calendar;
31import java.util.List;
32import java.util.Locale;
33
34/**
35 * {@link TimePicker} is a direct subclass of {@link Picker}.
36 * <p>
37 * This class is a widget for selecting time and displays it according to the formatting for the
38 * current system locale. The time can be selected by hour, minute, and AM/PM picker columns.
39 * The AM/PM mode is determined by either explicitly setting the current mode through
40 * {@link #setIs24Hour(boolean)} or the widget attribute {@code is24HourFormat} (true for 24-hour
41 * mode, false for 12-hour mode). Otherwise, TimePicker retrieves the mode based on the current
42 * context. In 24-hour mode, TimePicker displays only the hour and minute columns.
43 * <p>
44 * This widget can show the current time as the initial value if {@code useCurrentTime} is set to
45 * true. Each individual time picker field can be set at any time by calling {@link #setHour(int)},
46 * {@link #setMinute(int)} using 24-hour time format. The time format can also be changed at any
47 * time by calling {@link #setIs24Hour(boolean)}, and the AM/PM picker column will be activated or
48 * deactivated accordingly.
49 *
50 * @attr ref R.styleable#lbTimePicker_is24HourFormat
51 * @attr ref R.styleable#lbTimePicker_useCurrentTime
52 */
53public class TimePicker extends Picker {
54
55    static final String TAG = "TimePicker";
56
57    private static final int AM_INDEX = 0;
58    private static final int PM_INDEX = 1;
59
60    private static final int HOURS_IN_HALF_DAY = 12;
61    PickerColumn mHourColumn;
62    PickerColumn mMinuteColumn;
63    PickerColumn mAmPmColumn;
64    int mColHourIndex;
65    int mColMinuteIndex;
66    int mColAmPmIndex;
67
68    private final PickerUtility.TimeConstant mConstant;
69
70    private boolean mIs24hFormat;
71
72    private int mCurrentHour;
73    private int mCurrentMinute;
74    private int mCurrentAmPmIndex;
75
76    private String mTimePickerFormat;
77
78    /**
79     * Constructor called when inflating a TimePicker widget. This version uses a default style of
80     * 0, so the only attribute values applied are those in the Context's Theme and the given
81     * AttributeSet.
82     *
83     * @param context the context this TimePicker widget is associated with through which we can
84     *                access the current theme attributes and resources
85     * @param attrs the attributes of the XML tag that is inflating the TimePicker widget
86     */
87    public TimePicker(Context context, AttributeSet attrs) {
88        this(context, attrs, 0);
89    }
90
91    /**
92     * Constructor called when inflating a TimePicker widget.
93     *
94     * @param context the context this TimePicker widget is associated with through which we can
95     *                access the current theme attributes and resources
96     * @param attrs the attributes of the XML tag that is inflating the TimePicker widget
97     * @param defStyleAttr An attribute in the current theme that contains a reference to a style
98     *                     resource that supplies default values for the widget. Can be 0 to not
99     *                     look for defaults.
100     */
101    public TimePicker(Context context, AttributeSet attrs, int defStyleAttr) {
102        super(context, attrs, defStyleAttr);
103
104        mConstant = PickerUtility.getTimeConstantInstance(Locale.getDefault(),
105                context.getResources());
106
107        final TypedArray attributesArray = context.obtainStyledAttributes(attrs,
108                R.styleable.lbTimePicker);
109        mIs24hFormat = attributesArray.getBoolean(R.styleable.lbTimePicker_is24HourFormat,
110                DateFormat.is24HourFormat(context));
111        boolean useCurrentTime = attributesArray.getBoolean(R.styleable.lbTimePicker_useCurrentTime,
112                true);
113
114        // The following 2 methods must be called after setting mIs24hFormat since this attribute is
115        // used to extract the time format string.
116        updateColumns();
117        updateColumnsRange();
118
119        if (useCurrentTime) {
120            Calendar currentDate = PickerUtility.getCalendarForLocale(null,
121                    mConstant.locale);
122            setHour(currentDate.get(Calendar.HOUR_OF_DAY));
123            setMinute(currentDate.get(Calendar.MINUTE));
124            setAmPmValue();
125        }
126    }
127
128    private static boolean updateMin(PickerColumn column, int value) {
129        if (value != column.getMinValue()) {
130            column.setMinValue(value);
131            return true;
132        }
133        return false;
134    }
135
136    private static boolean updateMax(PickerColumn column, int value) {
137        if (value != column.getMaxValue()) {
138            column.setMaxValue(value);
139            return true;
140        }
141        return false;
142    }
143
144    /**
145     * @return The best localized representation of time for the current locale
146     */
147    String getBestHourMinutePattern() {
148        final String hourPattern;
149        if (PickerUtility.SUPPORTS_BEST_DATE_TIME_PATTERN) {
150            hourPattern = DateFormat.getBestDateTimePattern(mConstant.locale, mIs24hFormat ? "Hma"
151                    : "hma");
152        } else {
153            // Using short style to avoid picking extra fields e.g. time zone in the returned time
154            // format.
155            final java.text.DateFormat dateFormat =
156                    SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT, mConstant.locale);
157            if (dateFormat instanceof SimpleDateFormat) {
158                String defaultPattern = ((SimpleDateFormat) dateFormat).toPattern();
159                defaultPattern = defaultPattern.replace("s", "");
160                if (mIs24hFormat) {
161                    defaultPattern = defaultPattern.replace('h', 'H').replace("a", "");
162                }
163                hourPattern = defaultPattern;
164            } else {
165                hourPattern = mIs24hFormat ? "H:mma" : "h:mma";
166            }
167        }
168        return TextUtils.isEmpty(hourPattern) ? "h:mma" : hourPattern;
169    }
170
171    /**
172     * Extracts the separators used to separate time fields (including before the first and after
173     * the last time field). The separators can vary based on the individual locale and 12 or
174     * 24 hour time format, defined in the Unicode CLDR and cannot be supposed to be ":".
175     *
176     * See http://unicode.org/cldr/trac/browser/trunk/common/main
177     *
178     * For example, for english in 12 hour format
179     * (time pattern of "h:mm a"), this will return {"", ":", "", ""}, where the first separator
180     * indicates nothing needs to be displayed to the left of the hour field, ":" needs to be
181     * displayed to the right of hour field, and so forth.
182     *
183     * @return The ArrayList of separators to populate between the actual time fields in the
184     * TimePicker.
185     */
186    List<CharSequence> extractSeparators() {
187        // Obtain the time format string per the current locale (e.g. h:mm a)
188        String hmaPattern = getBestHourMinutePattern();
189
190        List<CharSequence> separators = new ArrayList<>();
191        StringBuilder sb = new StringBuilder();
192        char lastChar = '\0';
193        // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats
194        final char[] timeFormats = {'H', 'h', 'K', 'k', 'm', 'M', 'a'};
195        boolean processingQuote = false;
196        for (int i = 0; i < hmaPattern.length(); i++) {
197            char c = hmaPattern.charAt(i);
198            if (c == ' ') {
199                continue;
200            }
201            if (c == '\'') {
202                if (!processingQuote) {
203                    sb.setLength(0);
204                    processingQuote = true;
205                } else {
206                    processingQuote = false;
207                }
208                continue;
209            }
210            if (processingQuote) {
211                sb.append(c);
212            } else {
213                if (isAnyOf(c, timeFormats)) {
214                    if (c != lastChar) {
215                        separators.add(sb.toString());
216                        sb.setLength(0);
217                    }
218                } else {
219                    sb.append(c);
220                }
221            }
222            lastChar = c;
223        }
224        separators.add(sb.toString());
225        return separators;
226    }
227
228    private static boolean isAnyOf(char c, char[] any) {
229        for (int i = 0; i < any.length; i++) {
230            if (c == any[i]) {
231                return true;
232            }
233        }
234        return false;
235    }
236
237    /**
238     *
239     * @return the time picker format string based on the current system locale and the layout
240     *         direction
241     */
242    private String extractTimeFields() {
243        // Obtain the time format string per the current locale (e.g. h:mm a)
244        String hmaPattern = getBestHourMinutePattern();
245
246        boolean isRTL = TextUtils.getLayoutDirectionFromLocale(mConstant.locale) == View
247                .LAYOUT_DIRECTION_RTL;
248        boolean isAmPmAtEnd = (hmaPattern.indexOf('a') >= 0)
249                ? (hmaPattern.indexOf("a") > hmaPattern.indexOf("m")) : true;
250        // Hour will always appear to the left of minutes regardless of layout direction.
251        String timePickerFormat = isRTL ? "mh" : "hm";
252
253        if (is24Hour()) {
254            return timePickerFormat;
255        } else {
256            return isAmPmAtEnd ? (timePickerFormat + "a") : ("a" + timePickerFormat);
257        }
258    }
259
260    private void updateColumns() {
261        String timePickerFormat = getBestHourMinutePattern();
262        if (TextUtils.equals(timePickerFormat, mTimePickerFormat)) {
263            return;
264        }
265        mTimePickerFormat = timePickerFormat;
266
267        String timeFieldsPattern = extractTimeFields();
268        List<CharSequence> separators = extractSeparators();
269        if (separators.size() != (timeFieldsPattern.length() + 1)) {
270            throw new IllegalStateException("Separators size: " + separators.size() + " must equal"
271                    + " the size of timeFieldsPattern: " + timeFieldsPattern.length() + " + 1");
272        }
273        setSeparators(separators);
274        timeFieldsPattern = timeFieldsPattern.toUpperCase();
275
276        mHourColumn = mMinuteColumn = mAmPmColumn = null;
277        mColHourIndex = mColMinuteIndex = mColAmPmIndex = -1;
278
279        ArrayList<PickerColumn> columns = new ArrayList<>(3);
280        for (int i = 0; i < timeFieldsPattern.length(); i++) {
281            switch (timeFieldsPattern.charAt(i)) {
282                case 'H':
283                    columns.add(mHourColumn = new PickerColumn());
284                    mHourColumn.setStaticLabels(mConstant.hours24);
285                    mColHourIndex = i;
286                    break;
287                case 'M':
288                    columns.add(mMinuteColumn = new PickerColumn());
289                    mMinuteColumn.setStaticLabels(mConstant.minutes);
290                    mColMinuteIndex = i;
291                    break;
292                case 'A':
293                    columns.add(mAmPmColumn = new PickerColumn());
294                    mAmPmColumn.setStaticLabels(mConstant.ampm);
295                    mColAmPmIndex = i;
296                    updateMin(mAmPmColumn, 0);
297                    updateMax(mAmPmColumn, 1);
298                    break;
299                default:
300                    throw new IllegalArgumentException("Invalid time picker format.");
301            }
302        }
303        setColumns(columns);
304    }
305
306    private void updateColumnsRange() {
307        // updateHourColumn(false);
308        updateMin(mHourColumn, mIs24hFormat ? 0 : 1);
309        updateMax(mHourColumn, mIs24hFormat ? 23 : 12);
310
311        updateMin(mMinuteColumn, 0);
312        updateMax(mMinuteColumn, 59);
313
314        if (mAmPmColumn != null) {
315            updateMin(mAmPmColumn, 0);
316            updateMax(mAmPmColumn, 1);
317        }
318    }
319
320    /**
321     * Updates the value of AM/PM column for a 12 hour time format. The correct value should already
322     * be calculated before this method is called by calling setHour.
323     */
324    private void setAmPmValue() {
325        if (!is24Hour()) {
326            setColumnValue(mColAmPmIndex, mCurrentAmPmIndex, false);
327        }
328    }
329
330    /**
331     * Sets the currently selected hour using a 24-hour time.
332     *
333     * @param hour the hour to set, in the range (0-23)
334     * @see #getHour()
335     */
336    public void setHour(@IntRange(from = 0, to = 23) int hour) {
337        if (hour < 0 || hour > 23) {
338            throw new IllegalArgumentException("hour: " + hour + " is not in [0-23] range in");
339        }
340        mCurrentHour = hour;
341        if (!is24Hour()) {
342            if (mCurrentHour >= HOURS_IN_HALF_DAY) {
343                mCurrentAmPmIndex = PM_INDEX;
344                if (mCurrentHour > HOURS_IN_HALF_DAY) {
345                    mCurrentHour -= HOURS_IN_HALF_DAY;
346                }
347            } else {
348                mCurrentAmPmIndex = AM_INDEX;
349                if (mCurrentHour == 0) {
350                    mCurrentHour = HOURS_IN_HALF_DAY;
351                }
352            }
353            setAmPmValue();
354        }
355        setColumnValue(mColHourIndex, mCurrentHour, false);
356    }
357
358    /**
359     * Returns the currently selected hour using 24-hour time.
360     *
361     * @return the currently selected hour in the range (0-23)
362     * @see #setHour(int)
363     */
364    public int getHour() {
365        if (mIs24hFormat) {
366            return mCurrentHour;
367        }
368        if (mCurrentAmPmIndex == AM_INDEX) {
369            return mCurrentHour % HOURS_IN_HALF_DAY;
370        }
371        return (mCurrentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY;
372    }
373
374    /**
375     * Sets the currently selected minute.
376     *
377     * @param minute the minute to set, in the range (0-59)
378     * @see #getMinute()
379     */
380    public void setMinute(@IntRange(from = 0, to = 59) int minute) {
381        if (minute < 0 || minute > 59) {
382            throw new IllegalArgumentException("minute: " + minute + " is not in [0-59] range.");
383        }
384        mCurrentMinute = minute;
385        setColumnValue(mColMinuteIndex, mCurrentMinute, false);
386    }
387
388    /**
389     * Returns the currently selected minute.
390     *
391     * @return the currently selected minute, in the range (0-59)
392     * @see #setMinute(int)
393     */
394    public int getMinute() {
395        return mCurrentMinute;
396    }
397
398    /**
399     * Sets whether this widget displays a 24-hour mode or a 12-hour mode with an AM/PM picker.
400     *
401     * @param is24Hour {@code true} to display in 24-hour mode,
402     *                 {@code false} ti display in 12-hour mode with AM/PM.
403     * @see #is24Hour()
404     */
405    public void setIs24Hour(boolean is24Hour) {
406        if (mIs24hFormat == is24Hour) {
407            return;
408        }
409        // the ordering of these statements is important
410        int currentHour = getHour();
411        int currentMinute = getMinute();
412        mIs24hFormat = is24Hour;
413        updateColumns();
414        updateColumnsRange();
415
416        setHour(currentHour);
417        setMinute(currentMinute);
418        setAmPmValue();
419    }
420
421    /**
422     * @return {@code true} if this widget displays time in 24-hour mode,
423     *         {@code false} otherwise.
424     *
425     * @see #setIs24Hour(boolean)
426     */
427    public boolean is24Hour() {
428        return mIs24hFormat;
429    }
430
431    /**
432     * Only meaningful for a 12-hour time.
433     *
434     * @return {@code true} if the currently selected time is in PM,
435     *         {@code false} if the currently selected time in in AM.
436     */
437    public boolean isPm() {
438        return (mCurrentAmPmIndex == PM_INDEX);
439    }
440
441    @Override
442    public void onColumnValueChanged(int columnIndex, int newValue) {
443        if (columnIndex == mColHourIndex) {
444            mCurrentHour = newValue;
445        } else if (columnIndex == mColMinuteIndex) {
446            mCurrentMinute = newValue;
447        } else if (columnIndex == mColAmPmIndex) {
448            mCurrentAmPmIndex = newValue;
449        } else {
450            throw new IllegalArgumentException("Invalid column index.");
451        }
452    }
453}
454