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