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