1 /* 2 * Copyright (C) 2013 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 android.widget; 18 19 import android.annotation.IntDef; 20 import android.annotation.Nullable; 21 import android.annotation.TestApi; 22 import android.content.Context; 23 import android.content.res.ColorStateList; 24 import android.content.res.Resources; 25 import android.content.res.TypedArray; 26 import android.icu.text.DecimalFormatSymbols; 27 import android.os.Parcelable; 28 import android.text.SpannableStringBuilder; 29 import android.text.TextUtils; 30 import android.text.format.DateFormat; 31 import android.text.format.DateUtils; 32 import android.text.style.TtsSpan; 33 import android.util.AttributeSet; 34 import android.util.StateSet; 35 import android.view.HapticFeedbackConstants; 36 import android.view.LayoutInflater; 37 import android.view.MotionEvent; 38 import android.view.View; 39 import android.view.View.AccessibilityDelegate; 40 import android.view.View.MeasureSpec; 41 import android.view.ViewGroup; 42 import android.view.accessibility.AccessibilityEvent; 43 import android.view.accessibility.AccessibilityNodeInfo; 44 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 45 import android.view.inputmethod.InputMethodManager; 46 import android.widget.RadialTimePickerView.OnValueSelectedListener; 47 import android.widget.TextInputTimePickerView.OnValueTypedListener; 48 49 import com.android.internal.R; 50 import com.android.internal.widget.NumericTextView; 51 import com.android.internal.widget.NumericTextView.OnValueChangedListener; 52 53 54 import java.lang.annotation.Retention; 55 import java.lang.annotation.RetentionPolicy; 56 import java.util.Calendar; 57 58 /** 59 * A delegate implementing the radial clock-based TimePicker. 60 */ 61 class TimePickerClockDelegate extends TimePicker.AbstractTimePickerDelegate { 62 /** 63 * Delay in milliseconds before valid but potentially incomplete, for 64 * example "1" but not "12", keyboard edits are propagated from the 65 * hour / minute fields to the radial picker. 66 */ 67 private static final long DELAY_COMMIT_MILLIS = 2000; 68 69 @IntDef({FROM_EXTERNAL_API, FROM_RADIAL_PICKER, FROM_INPUT_PICKER}) 70 @Retention(RetentionPolicy.SOURCE) 71 private @interface ChangeSource {} 72 private static final int FROM_EXTERNAL_API = 0; 73 private static final int FROM_RADIAL_PICKER = 1; 74 private static final int FROM_INPUT_PICKER = 2; 75 76 // Index used by RadialPickerLayout 77 private static final int HOUR_INDEX = RadialTimePickerView.HOURS; 78 private static final int MINUTE_INDEX = RadialTimePickerView.MINUTES; 79 80 private static final int[] ATTRS_TEXT_COLOR = new int[] {R.attr.textColor}; 81 private static final int[] ATTRS_DISABLED_ALPHA = new int[] {R.attr.disabledAlpha}; 82 83 private static final int AM = 0; 84 private static final int PM = 1; 85 86 private static final int HOURS_IN_HALF_DAY = 12; 87 88 private final NumericTextView mHourView; 89 private final NumericTextView mMinuteView; 90 private final View mAmPmLayout; 91 private final RadioButton mAmLabel; 92 private final RadioButton mPmLabel; 93 private final RadialTimePickerView mRadialTimePickerView; 94 private final TextView mSeparatorView; 95 96 private boolean mRadialPickerModeEnabled = true; 97 private final ImageButton mRadialTimePickerModeButton; 98 private final String mRadialTimePickerModeEnabledDescription; 99 private final String mTextInputPickerModeEnabledDescription; 100 private final View mRadialTimePickerHeader; 101 private final View mTextInputPickerHeader; 102 103 private final TextInputTimePickerView mTextInputPickerView; 104 105 private final Calendar mTempCalendar; 106 107 // Accessibility strings. 108 private final String mSelectHours; 109 private final String mSelectMinutes; 110 111 private boolean mIsEnabled = true; 112 private boolean mAllowAutoAdvance; 113 private int mCurrentHour; 114 private int mCurrentMinute; 115 private boolean mIs24Hour; 116 117 // The portrait layout puts AM/PM at the right by default. 118 private boolean mIsAmPmAtLeft = false; 119 // The landscape layouts put AM/PM at the bottom by default. 120 private boolean mIsAmPmAtTop = false; 121 122 // Localization data. 123 private boolean mHourFormatShowLeadingZero; 124 private boolean mHourFormatStartsAtZero; 125 126 // Most recent time announcement values for accessibility. 127 private CharSequence mLastAnnouncedText; 128 private boolean mLastAnnouncedIsHour; 129 TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)130 public TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs, 131 int defStyleAttr, int defStyleRes) { 132 super(delegator, context); 133 134 // process style attributes 135 final TypedArray a = mContext.obtainStyledAttributes(attrs, 136 R.styleable.TimePicker, defStyleAttr, defStyleRes); 137 final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService( 138 Context.LAYOUT_INFLATER_SERVICE); 139 final Resources res = mContext.getResources(); 140 141 mSelectHours = res.getString(R.string.select_hours); 142 mSelectMinutes = res.getString(R.string.select_minutes); 143 144 final int layoutResourceId = a.getResourceId(R.styleable.TimePicker_internalLayout, 145 R.layout.time_picker_material); 146 final View mainView = inflater.inflate(layoutResourceId, delegator); 147 mainView.setSaveFromParentEnabled(false); 148 mRadialTimePickerHeader = mainView.findViewById(R.id.time_header); 149 mRadialTimePickerHeader.setOnTouchListener(new NearestTouchDelegate()); 150 151 // Set up hour/minute labels. 152 mHourView = (NumericTextView) mainView.findViewById(R.id.hours); 153 mHourView.setOnClickListener(mClickListener); 154 mHourView.setOnFocusChangeListener(mFocusListener); 155 mHourView.setOnDigitEnteredListener(mDigitEnteredListener); 156 mHourView.setAccessibilityDelegate( 157 new ClickActionDelegate(context, R.string.select_hours)); 158 mSeparatorView = (TextView) mainView.findViewById(R.id.separator); 159 mMinuteView = (NumericTextView) mainView.findViewById(R.id.minutes); 160 mMinuteView.setOnClickListener(mClickListener); 161 mMinuteView.setOnFocusChangeListener(mFocusListener); 162 mMinuteView.setOnDigitEnteredListener(mDigitEnteredListener); 163 mMinuteView.setAccessibilityDelegate( 164 new ClickActionDelegate(context, R.string.select_minutes)); 165 mMinuteView.setRange(0, 59); 166 167 // Set up AM/PM labels. 168 mAmPmLayout = mainView.findViewById(R.id.ampm_layout); 169 mAmPmLayout.setOnTouchListener(new NearestTouchDelegate()); 170 171 final String[] amPmStrings = TimePicker.getAmPmStrings(context); 172 mAmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.am_label); 173 mAmLabel.setText(obtainVerbatim(amPmStrings[0])); 174 mAmLabel.setOnClickListener(mClickListener); 175 ensureMinimumTextWidth(mAmLabel); 176 177 mPmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.pm_label); 178 mPmLabel.setText(obtainVerbatim(amPmStrings[1])); 179 mPmLabel.setOnClickListener(mClickListener); 180 ensureMinimumTextWidth(mPmLabel); 181 182 // For the sake of backwards compatibility, attempt to extract the text 183 // color from the header time text appearance. If it's set, we'll let 184 // that override the "real" header text color. 185 ColorStateList headerTextColor = null; 186 187 @SuppressWarnings("deprecation") 188 final int timeHeaderTextAppearance = a.getResourceId( 189 R.styleable.TimePicker_headerTimeTextAppearance, 0); 190 if (timeHeaderTextAppearance != 0) { 191 final TypedArray textAppearance = mContext.obtainStyledAttributes(null, 192 ATTRS_TEXT_COLOR, 0, timeHeaderTextAppearance); 193 final ColorStateList legacyHeaderTextColor = textAppearance.getColorStateList(0); 194 headerTextColor = applyLegacyColorFixes(legacyHeaderTextColor); 195 textAppearance.recycle(); 196 } 197 198 if (headerTextColor == null) { 199 headerTextColor = a.getColorStateList(R.styleable.TimePicker_headerTextColor); 200 } 201 202 mTextInputPickerHeader = mainView.findViewById(R.id.input_header); 203 204 if (headerTextColor != null) { 205 mHourView.setTextColor(headerTextColor); 206 mSeparatorView.setTextColor(headerTextColor); 207 mMinuteView.setTextColor(headerTextColor); 208 mAmLabel.setTextColor(headerTextColor); 209 mPmLabel.setTextColor(headerTextColor); 210 } 211 212 // Set up header background, if available. 213 if (a.hasValueOrEmpty(R.styleable.TimePicker_headerBackground)) { 214 mRadialTimePickerHeader.setBackground(a.getDrawable( 215 R.styleable.TimePicker_headerBackground)); 216 mTextInputPickerHeader.setBackground(a.getDrawable( 217 R.styleable.TimePicker_headerBackground)); 218 } 219 220 a.recycle(); 221 222 mRadialTimePickerView = (RadialTimePickerView) mainView.findViewById(R.id.radial_picker); 223 mRadialTimePickerView.applyAttributes(attrs, defStyleAttr, defStyleRes); 224 mRadialTimePickerView.setOnValueSelectedListener(mOnValueSelectedListener); 225 226 mTextInputPickerView = (TextInputTimePickerView) mainView.findViewById(R.id.input_mode); 227 mTextInputPickerView.setListener(mOnValueTypedListener); 228 229 mRadialTimePickerModeButton = 230 (ImageButton) mainView.findViewById(R.id.toggle_mode); 231 mRadialTimePickerModeButton.setOnClickListener(new View.OnClickListener() { 232 @Override 233 public void onClick(View v) { 234 toggleRadialPickerMode(); 235 } 236 }); 237 mRadialTimePickerModeEnabledDescription = context.getResources().getString( 238 R.string.time_picker_radial_mode_description); 239 mTextInputPickerModeEnabledDescription = context.getResources().getString( 240 R.string.time_picker_text_input_mode_description); 241 242 mAllowAutoAdvance = true; 243 244 updateHourFormat(); 245 246 // Initialize with current time. 247 mTempCalendar = Calendar.getInstance(mLocale); 248 final int currentHour = mTempCalendar.get(Calendar.HOUR_OF_DAY); 249 final int currentMinute = mTempCalendar.get(Calendar.MINUTE); 250 initialize(currentHour, currentMinute, mIs24Hour, HOUR_INDEX); 251 } 252 toggleRadialPickerMode()253 private void toggleRadialPickerMode() { 254 if (mRadialPickerModeEnabled) { 255 mRadialTimePickerView.setVisibility(View.GONE); 256 mRadialTimePickerHeader.setVisibility(View.GONE); 257 mTextInputPickerHeader.setVisibility(View.VISIBLE); 258 mTextInputPickerView.setVisibility(View.VISIBLE); 259 mRadialTimePickerModeButton.setImageResource(R.drawable.btn_clock_material); 260 mRadialTimePickerModeButton.setContentDescription( 261 mRadialTimePickerModeEnabledDescription); 262 mRadialPickerModeEnabled = false; 263 } else { 264 mRadialTimePickerView.setVisibility(View.VISIBLE); 265 mRadialTimePickerHeader.setVisibility(View.VISIBLE); 266 mTextInputPickerHeader.setVisibility(View.GONE); 267 mTextInputPickerView.setVisibility(View.GONE); 268 mRadialTimePickerModeButton.setImageResource(R.drawable.btn_keyboard_key_material); 269 mRadialTimePickerModeButton.setContentDescription( 270 mTextInputPickerModeEnabledDescription); 271 updateTextInputPicker(); 272 InputMethodManager imm = mContext.getSystemService(InputMethodManager.class); 273 if (imm != null) { 274 imm.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 275 } 276 mRadialPickerModeEnabled = true; 277 } 278 } 279 280 @Override validateInput()281 public boolean validateInput() { 282 return mTextInputPickerView.validateInput(); 283 } 284 285 /** 286 * Ensures that a TextView is wide enough to contain its text without 287 * wrapping or clipping. Measures the specified view and sets the minimum 288 * width to the view's desired width. 289 * 290 * @param v the text view to measure 291 */ ensureMinimumTextWidth(TextView v)292 private static void ensureMinimumTextWidth(TextView v) { 293 v.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 294 295 // Set both the TextView and the View version of minimum 296 // width because they are subtly different. 297 final int minWidth = v.getMeasuredWidth(); 298 v.setMinWidth(minWidth); 299 v.setMinimumWidth(minWidth); 300 } 301 302 /** 303 * Updates hour formatting based on the current locale and 24-hour mode. 304 * <p> 305 * Determines how the hour should be formatted, sets member variables for 306 * leading zero and starting hour, and sets the hour view's presentation. 307 */ updateHourFormat()308 private void updateHourFormat() { 309 final String bestDateTimePattern = DateFormat.getBestDateTimePattern( 310 mLocale, mIs24Hour ? "Hm" : "hm"); 311 final int lengthPattern = bestDateTimePattern.length(); 312 boolean showLeadingZero = false; 313 char hourFormat = '\0'; 314 315 for (int i = 0; i < lengthPattern; i++) { 316 final char c = bestDateTimePattern.charAt(i); 317 if (c == 'H' || c == 'h' || c == 'K' || c == 'k') { 318 hourFormat = c; 319 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) { 320 showLeadingZero = true; 321 } 322 break; 323 } 324 } 325 326 mHourFormatShowLeadingZero = showLeadingZero; 327 mHourFormatStartsAtZero = hourFormat == 'K' || hourFormat == 'H'; 328 329 // Update hour text field. 330 final int minHour = mHourFormatStartsAtZero ? 0 : 1; 331 final int maxHour = (mIs24Hour ? 23 : 11) + minHour; 332 mHourView.setRange(minHour, maxHour); 333 mHourView.setShowLeadingZeroes(mHourFormatShowLeadingZero); 334 335 final String[] digits = DecimalFormatSymbols.getInstance(mLocale).getDigitStrings(); 336 int maxCharLength = 0; 337 for (int i = 0; i < 10; i++) { 338 maxCharLength = Math.max(maxCharLength, digits[i].length()); 339 } 340 mTextInputPickerView.setHourFormat(maxCharLength * 2); 341 } 342 obtainVerbatim(String text)343 static final CharSequence obtainVerbatim(String text) { 344 return new SpannableStringBuilder().append(text, 345 new TtsSpan.VerbatimBuilder(text).build(), 0); 346 } 347 348 /** 349 * The legacy text color might have been poorly defined. Ensures that it 350 * has an appropriate activated state, using the selected state if one 351 * exists or modifying the default text color otherwise. 352 * 353 * @param color a legacy text color, or {@code null} 354 * @return a color state list with an appropriate activated state, or 355 * {@code null} if a valid activated state could not be generated 356 */ 357 @Nullable applyLegacyColorFixes(@ullable ColorStateList color)358 private ColorStateList applyLegacyColorFixes(@Nullable ColorStateList color) { 359 if (color == null || color.hasState(R.attr.state_activated)) { 360 return color; 361 } 362 363 final int activatedColor; 364 final int defaultColor; 365 if (color.hasState(R.attr.state_selected)) { 366 activatedColor = color.getColorForState(StateSet.get( 367 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_SELECTED), 0); 368 defaultColor = color.getColorForState(StateSet.get( 369 StateSet.VIEW_STATE_ENABLED), 0); 370 } else { 371 activatedColor = color.getDefaultColor(); 372 373 // Generate a non-activated color using the disabled alpha. 374 final TypedArray ta = mContext.obtainStyledAttributes(ATTRS_DISABLED_ALPHA); 375 final float disabledAlpha = ta.getFloat(0, 0.30f); 376 defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha); 377 } 378 379 if (activatedColor == 0 || defaultColor == 0) { 380 // We somehow failed to obtain the colors. 381 return null; 382 } 383 384 final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}}; 385 final int[] colors = new int[] { activatedColor, defaultColor }; 386 return new ColorStateList(stateSet, colors); 387 } 388 multiplyAlphaComponent(int color, float alphaMod)389 private int multiplyAlphaComponent(int color, float alphaMod) { 390 final int srcRgb = color & 0xFFFFFF; 391 final int srcAlpha = (color >> 24) & 0xFF; 392 final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f); 393 return srcRgb | (dstAlpha << 24); 394 } 395 396 private static class ClickActionDelegate extends AccessibilityDelegate { 397 private final AccessibilityAction mClickAction; 398 ClickActionDelegate(Context context, int resId)399 public ClickActionDelegate(Context context, int resId) { 400 mClickAction = new AccessibilityAction( 401 AccessibilityNodeInfo.ACTION_CLICK, context.getString(resId)); 402 } 403 404 @Override onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info)405 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 406 super.onInitializeAccessibilityNodeInfo(host, info); 407 408 info.addAction(mClickAction); 409 } 410 } 411 initialize(int hourOfDay, int minute, boolean is24HourView, int index)412 private void initialize(int hourOfDay, int minute, boolean is24HourView, int index) { 413 mCurrentHour = hourOfDay; 414 mCurrentMinute = minute; 415 mIs24Hour = is24HourView; 416 updateUI(index); 417 } 418 updateUI(int index)419 private void updateUI(int index) { 420 updateHeaderAmPm(); 421 updateHeaderHour(mCurrentHour, false); 422 updateHeaderSeparator(); 423 updateHeaderMinute(mCurrentMinute, false); 424 updateRadialPicker(index); 425 updateTextInputPicker(); 426 427 mDelegator.invalidate(); 428 } 429 updateTextInputPicker()430 private void updateTextInputPicker() { 431 mTextInputPickerView.updateTextInputValues(getLocalizedHour(mCurrentHour), mCurrentMinute, 432 mCurrentHour < 12 ? AM : PM, mIs24Hour, mHourFormatStartsAtZero); 433 } 434 435 private void updateRadialPicker(int index) { 436 mRadialTimePickerView.initialize(mCurrentHour, mCurrentMinute, mIs24Hour); 437 setCurrentItemShowing(index, false, true); 438 } 439 440 private void updateHeaderAmPm() { 441 if (mIs24Hour) { 442 mAmPmLayout.setVisibility(View.GONE); 443 } else { 444 // Find the location of AM/PM based on locale information. 445 final String dateTimePattern = DateFormat.getBestDateTimePattern(mLocale, "hm"); 446 final boolean isAmPmAtStart = dateTimePattern.startsWith("a"); 447 setAmPmStart(isAmPmAtStart); 448 updateAmPmLabelStates(mCurrentHour < 12 ? AM : PM); 449 } 450 } 451 452 private void setAmPmStart(boolean isAmPmAtStart) { 453 final RelativeLayout.LayoutParams params = 454 (RelativeLayout.LayoutParams) mAmPmLayout.getLayoutParams(); 455 if (params.getRule(RelativeLayout.RIGHT_OF) != 0 456 || params.getRule(RelativeLayout.LEFT_OF) != 0) { 457 final int margin = (int) (mContext.getResources().getDisplayMetrics().density * 8); 458 // Horizontal mode, with AM/PM appearing to left/right of hours and minutes. 459 final boolean isAmPmAtLeft; 460 if (TextUtils.getLayoutDirectionFromLocale(mLocale) == View.LAYOUT_DIRECTION_LTR) { 461 isAmPmAtLeft = isAmPmAtStart; 462 } else { 463 isAmPmAtLeft = !isAmPmAtStart; 464 } 465 466 if (isAmPmAtLeft) { 467 params.removeRule(RelativeLayout.RIGHT_OF); 468 params.addRule(RelativeLayout.LEFT_OF, mHourView.getId()); 469 } else { 470 params.removeRule(RelativeLayout.LEFT_OF); 471 params.addRule(RelativeLayout.RIGHT_OF, mMinuteView.getId()); 472 } 473 474 if (isAmPmAtStart) { 475 params.setMarginStart(0); 476 params.setMarginEnd(margin); 477 } else { 478 params.setMarginStart(margin); 479 params.setMarginEnd(0); 480 } 481 mIsAmPmAtLeft = isAmPmAtLeft; 482 } else if (params.getRule(RelativeLayout.BELOW) != 0 483 || params.getRule(RelativeLayout.ABOVE) != 0) { 484 // Vertical mode, with AM/PM appearing to top/bottom of hours and minutes. 485 if (mIsAmPmAtTop == isAmPmAtStart) { 486 // AM/PM is already at the correct location. No change needed. 487 return; 488 } 489 490 final int otherViewId; 491 if (isAmPmAtStart) { 492 otherViewId = params.getRule(RelativeLayout.BELOW); 493 params.removeRule(RelativeLayout.BELOW); 494 params.addRule(RelativeLayout.ABOVE, otherViewId); 495 } else { 496 otherViewId = params.getRule(RelativeLayout.ABOVE); 497 params.removeRule(RelativeLayout.ABOVE); 498 params.addRule(RelativeLayout.BELOW, otherViewId); 499 } 500 501 // Switch the top and bottom paddings on the other view. 502 final View otherView = mRadialTimePickerHeader.findViewById(otherViewId); 503 final int top = otherView.getPaddingTop(); 504 final int bottom = otherView.getPaddingBottom(); 505 final int left = otherView.getPaddingLeft(); 506 final int right = otherView.getPaddingRight(); 507 otherView.setPadding(left, bottom, right, top); 508 509 mIsAmPmAtTop = isAmPmAtStart; 510 } 511 512 mAmPmLayout.setLayoutParams(params); 513 } 514 515 @Override 516 public void setDate(int hour, int minute) { 517 setHourInternal(hour, FROM_EXTERNAL_API, true, false); 518 setMinuteInternal(minute, FROM_EXTERNAL_API, false); 519 520 onTimeChanged(); 521 } 522 523 /** 524 * Set the current hour. 525 */ 526 @Override 527 public void setHour(int hour) { 528 setHourInternal(hour, FROM_EXTERNAL_API, true, true); 529 } 530 531 private void setHourInternal(int hour, @ChangeSource int source, boolean announce, 532 boolean notify) { 533 if (mCurrentHour == hour) { 534 return; 535 } 536 537 resetAutofilledValue(); 538 mCurrentHour = hour; 539 updateHeaderHour(hour, announce); 540 updateHeaderAmPm(); 541 542 if (source != FROM_RADIAL_PICKER) { 543 mRadialTimePickerView.setCurrentHour(hour); 544 mRadialTimePickerView.setAmOrPm(hour < 12 ? AM : PM); 545 } 546 if (source != FROM_INPUT_PICKER) { 547 updateTextInputPicker(); 548 } 549 550 mDelegator.invalidate(); 551 if (notify) { 552 onTimeChanged(); 553 } 554 } 555 556 /** 557 * @return the current hour in the range (0-23) 558 */ 559 @Override 560 public int getHour() { 561 final int currentHour = mRadialTimePickerView.getCurrentHour(); 562 if (mIs24Hour) { 563 return currentHour; 564 } 565 566 if (mRadialTimePickerView.getAmOrPm() == PM) { 567 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 568 } else { 569 return currentHour % HOURS_IN_HALF_DAY; 570 } 571 } 572 573 /** 574 * Set the current minute (0-59). 575 */ 576 @Override 577 public void setMinute(int minute) { 578 setMinuteInternal(minute, FROM_EXTERNAL_API, true); 579 } 580 581 private void setMinuteInternal(int minute, @ChangeSource int source, boolean notify) { 582 if (mCurrentMinute == minute) { 583 return; 584 } 585 586 resetAutofilledValue(); 587 mCurrentMinute = minute; 588 updateHeaderMinute(minute, true); 589 590 if (source != FROM_RADIAL_PICKER) { 591 mRadialTimePickerView.setCurrentMinute(minute); 592 } 593 if (source != FROM_INPUT_PICKER) { 594 updateTextInputPicker(); 595 } 596 597 mDelegator.invalidate(); 598 if (notify) { 599 onTimeChanged(); 600 } 601 } 602 603 /** 604 * @return The current minute. 605 */ 606 @Override 607 public int getMinute() { 608 return mRadialTimePickerView.getCurrentMinute(); 609 } 610 611 /** 612 * Sets whether time is displayed in 24-hour mode or 12-hour mode with 613 * AM/PM indicators. 614 * 615 * @param is24Hour {@code true} to display time in 24-hour mode or 616 * {@code false} for 12-hour mode with AM/PM 617 */ 618 public void setIs24Hour(boolean is24Hour) { 619 if (mIs24Hour != is24Hour) { 620 mIs24Hour = is24Hour; 621 mCurrentHour = getHour(); 622 623 updateHourFormat(); 624 updateUI(mRadialTimePickerView.getCurrentItemShowing()); 625 } 626 } 627 628 /** 629 * @return {@code true} if time is displayed in 24-hour mode, or 630 * {@code false} if time is displayed in 12-hour mode with AM/PM 631 * indicators 632 */ 633 @Override 634 public boolean is24Hour() { 635 return mIs24Hour; 636 } 637 638 @Override 639 public void setEnabled(boolean enabled) { 640 mHourView.setEnabled(enabled); 641 mMinuteView.setEnabled(enabled); 642 mAmLabel.setEnabled(enabled); 643 mPmLabel.setEnabled(enabled); 644 mRadialTimePickerView.setEnabled(enabled); 645 mIsEnabled = enabled; 646 } 647 648 @Override 649 public boolean isEnabled() { 650 return mIsEnabled; 651 } 652 653 @Override 654 public int getBaseline() { 655 // does not support baseline alignment 656 return -1; 657 } 658 659 @Override 660 public Parcelable onSaveInstanceState(Parcelable superState) { 661 return new SavedState(superState, getHour(), getMinute(), 662 is24Hour(), getCurrentItemShowing()); 663 } 664 665 @Override 666 public void onRestoreInstanceState(Parcelable state) { 667 if (state instanceof SavedState) { 668 final SavedState ss = (SavedState) state; 669 initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing()); 670 mRadialTimePickerView.invalidate(); 671 } 672 } 673 674 @Override 675 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 676 onPopulateAccessibilityEvent(event); 677 return true; 678 } 679 680 @Override 681 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 682 int flags = DateUtils.FORMAT_SHOW_TIME; 683 if (mIs24Hour) { 684 flags |= DateUtils.FORMAT_24HOUR; 685 } else { 686 flags |= DateUtils.FORMAT_12HOUR; 687 } 688 689 mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour()); 690 mTempCalendar.set(Calendar.MINUTE, getMinute()); 691 692 final String selectedTime = DateUtils.formatDateTime(mContext, 693 mTempCalendar.getTimeInMillis(), flags); 694 final String selectionMode = mRadialTimePickerView.getCurrentItemShowing() == HOUR_INDEX ? 695 mSelectHours : mSelectMinutes; 696 event.getText().add(selectedTime + " " + selectionMode); 697 } 698 699 /** @hide */ 700 @Override 701 @TestApi 702 public View getHourView() { 703 return mHourView; 704 } 705 706 /** @hide */ 707 @Override 708 @TestApi 709 public View getMinuteView() { 710 return mMinuteView; 711 } 712 713 /** @hide */ 714 @Override 715 @TestApi 716 public View getAmView() { 717 return mAmLabel; 718 } 719 720 /** @hide */ 721 @Override 722 @TestApi 723 public View getPmView() { 724 return mPmLabel; 725 } 726 727 /** 728 * @return the index of the current item showing 729 */ 730 private int getCurrentItemShowing() { 731 return mRadialTimePickerView.getCurrentItemShowing(); 732 } 733 734 /** 735 * Propagate the time change 736 */ 737 private void onTimeChanged() { 738 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 739 if (mOnTimeChangedListener != null) { 740 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 741 } 742 if (mAutoFillChangeListener != null) { 743 mAutoFillChangeListener.onTimeChanged(mDelegator, getHour(), getMinute()); 744 } 745 } 746 747 private void tryVibrate() { 748 mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); 749 } 750 751 private void updateAmPmLabelStates(int amOrPm) { 752 final boolean isAm = amOrPm == AM; 753 mAmLabel.setActivated(isAm); 754 mAmLabel.setChecked(isAm); 755 756 final boolean isPm = amOrPm == PM; 757 mPmLabel.setActivated(isPm); 758 mPmLabel.setChecked(isPm); 759 } 760 761 /** 762 * Converts hour-of-day (0-23) time into a localized hour number. 763 * <p> 764 * The localized value may be in the range (0-23), (1-24), (0-11), or 765 * (1-12) depending on the locale. This method does not handle leading 766 * zeroes. 767 * 768 * @param hourOfDay the hour-of-day (0-23) 769 * @return a localized hour number 770 */ 771 private int getLocalizedHour(int hourOfDay) { 772 if (!mIs24Hour) { 773 // Convert to hour-of-am-pm. 774 hourOfDay %= 12; 775 } 776 777 if (!mHourFormatStartsAtZero && hourOfDay == 0) { 778 // Convert to clock-hour (either of-day or of-am-pm). 779 hourOfDay = mIs24Hour ? 24 : 12; 780 } 781 782 return hourOfDay; 783 } 784 785 private void updateHeaderHour(int hourOfDay, boolean announce) { 786 final int localizedHour = getLocalizedHour(hourOfDay); 787 mHourView.setValue(localizedHour); 788 789 if (announce) { 790 tryAnnounceForAccessibility(mHourView.getText(), true); 791 } 792 } 793 794 private void updateHeaderMinute(int minuteOfHour, boolean announce) { 795 mMinuteView.setValue(minuteOfHour); 796 797 if (announce) { 798 tryAnnounceForAccessibility(mMinuteView.getText(), false); 799 } 800 } 801 802 /** 803 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 804 * 805 * See http://unicode.org/cldr/trac/browser/trunk/common/main 806 * 807 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 808 * separator as the character which is just after the hour marker in the returned pattern. 809 */ 810 private void updateHeaderSeparator() { 811 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 812 (mIs24Hour) ? "Hm" : "hm"); 813 final String separatorText = getHourMinSeparatorFromPattern(bestDateTimePattern); 814 mSeparatorView.setText(separatorText); 815 mTextInputPickerView.updateSeparator(separatorText); 816 } 817 818 /** 819 * This helper method extracts the time separator from the {@code datetimePattern}. 820 * 821 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 822 * 823 * See http://unicode.org/cldr/trac/browser/trunk/common/main 824 * 825 * @return Separator string. This is the character or set of quoted characters just after the 826 * hour marker in {@code dateTimePattern}. Returns a colon (:) if it can't locate the 827 * separator. 828 * 829 * @hide 830 */ 831 private static String getHourMinSeparatorFromPattern(String dateTimePattern) { 832 final String defaultSeparator = ":"; 833 boolean foundHourPattern = false; 834 for (int i = 0; i < dateTimePattern.length(); i++) { 835 switch (dateTimePattern.charAt(i)) { 836 // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats. 837 case 'H': 838 case 'h': 839 case 'K': 840 case 'k': 841 foundHourPattern = true; 842 continue; 843 case ' ': // skip spaces 844 continue; 845 case '\'': 846 if (!foundHourPattern) { 847 continue; 848 } 849 SpannableStringBuilder quotedSubstring = new SpannableStringBuilder( 850 dateTimePattern.substring(i)); 851 int quotedTextLength = DateFormat.appendQuotedText(quotedSubstring, 0); 852 return quotedSubstring.subSequence(0, quotedTextLength).toString(); 853 default: 854 if (!foundHourPattern) { 855 continue; 856 } 857 return Character.toString(dateTimePattern.charAt(i)); 858 } 859 } 860 return defaultSeparator; 861 } 862 863 static private int lastIndexOfAny(String str, char[] any) { 864 final int lengthAny = any.length; 865 if (lengthAny > 0) { 866 for (int i = str.length() - 1; i >= 0; i--) { 867 char c = str.charAt(i); 868 for (int j = 0; j < lengthAny; j++) { 869 if (c == any[j]) { 870 return i; 871 } 872 } 873 } 874 } 875 return -1; 876 } 877 878 private void tryAnnounceForAccessibility(CharSequence text, boolean isHour) { 879 if (mLastAnnouncedIsHour != isHour || !text.equals(mLastAnnouncedText)) { 880 // TODO: Find a better solution, potentially live regions? 881 mDelegator.announceForAccessibility(text); 882 mLastAnnouncedText = text; 883 mLastAnnouncedIsHour = isHour; 884 } 885 } 886 887 /** 888 * Show either Hours or Minutes. 889 */ 890 private void setCurrentItemShowing(int index, boolean animateCircle, boolean announce) { 891 mRadialTimePickerView.setCurrentItemShowing(index, animateCircle); 892 893 if (index == HOUR_INDEX) { 894 if (announce) { 895 mDelegator.announceForAccessibility(mSelectHours); 896 } 897 } else { 898 if (announce) { 899 mDelegator.announceForAccessibility(mSelectMinutes); 900 } 901 } 902 903 mHourView.setActivated(index == HOUR_INDEX); 904 mMinuteView.setActivated(index == MINUTE_INDEX); 905 } 906 907 private void setAmOrPm(int amOrPm) { 908 updateAmPmLabelStates(amOrPm); 909 910 if (mRadialTimePickerView.setAmOrPm(amOrPm)) { 911 mCurrentHour = getHour(); 912 updateTextInputPicker(); 913 if (mOnTimeChangedListener != null) { 914 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 915 } 916 } 917 } 918 919 /** Listener for RadialTimePickerView interaction. */ 920 private final OnValueSelectedListener mOnValueSelectedListener = new OnValueSelectedListener() { 921 @Override 922 public void onValueSelected(int pickerType, int newValue, boolean autoAdvance) { 923 boolean valueChanged = false; 924 switch (pickerType) { 925 case RadialTimePickerView.HOURS: 926 if (getHour() != newValue) { 927 valueChanged = true; 928 } 929 final boolean isTransition = mAllowAutoAdvance && autoAdvance; 930 setHourInternal(newValue, FROM_RADIAL_PICKER, !isTransition, true); 931 if (isTransition) { 932 setCurrentItemShowing(MINUTE_INDEX, true, false); 933 934 final int localizedHour = getLocalizedHour(newValue); 935 mDelegator.announceForAccessibility(localizedHour + ". " + mSelectMinutes); 936 } 937 break; 938 case RadialTimePickerView.MINUTES: 939 if (getMinute() != newValue) { 940 valueChanged = true; 941 } 942 setMinuteInternal(newValue, FROM_RADIAL_PICKER, true); 943 break; 944 } 945 946 if (mOnTimeChangedListener != null && valueChanged) { 947 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 948 } 949 } 950 }; 951 952 private final OnValueTypedListener mOnValueTypedListener = new OnValueTypedListener() { 953 @Override 954 public void onValueChanged(int pickerType, int newValue) { 955 switch (pickerType) { 956 case TextInputTimePickerView.HOURS: 957 setHourInternal(newValue, FROM_INPUT_PICKER, false, true); 958 break; 959 case TextInputTimePickerView.MINUTES: 960 setMinuteInternal(newValue, FROM_INPUT_PICKER, true); 961 break; 962 case TextInputTimePickerView.AMPM: 963 setAmOrPm(newValue); 964 break; 965 } 966 } 967 }; 968 969 /** Listener for keyboard interaction. */ 970 private final OnValueChangedListener mDigitEnteredListener = new OnValueChangedListener() { 971 @Override 972 public void onValueChanged(NumericTextView view, int value, 973 boolean isValid, boolean isFinished) { 974 final Runnable commitCallback; 975 final View nextFocusTarget; 976 if (view == mHourView) { 977 commitCallback = mCommitHour; 978 nextFocusTarget = view.isFocused() ? mMinuteView : null; 979 } else if (view == mMinuteView) { 980 commitCallback = mCommitMinute; 981 nextFocusTarget = null; 982 } else { 983 return; 984 } 985 986 view.removeCallbacks(commitCallback); 987 988 if (isValid) { 989 if (isFinished) { 990 // Done with hours entry, make visual updates 991 // immediately and move to next focus if needed. 992 commitCallback.run(); 993 994 if (nextFocusTarget != null) { 995 nextFocusTarget.requestFocus(); 996 } 997 } else { 998 // May still be making changes. Postpone visual 999 // updates to prevent distracting the user. 1000 view.postDelayed(commitCallback, DELAY_COMMIT_MILLIS); 1001 } 1002 } 1003 } 1004 }; 1005 1006 private final Runnable mCommitHour = new Runnable() { 1007 @Override 1008 public void run() { 1009 setHour(mHourView.getValue()); 1010 } 1011 }; 1012 1013 private final Runnable mCommitMinute = new Runnable() { 1014 @Override 1015 public void run() { 1016 setMinute(mMinuteView.getValue()); 1017 } 1018 }; 1019 1020 private final View.OnFocusChangeListener mFocusListener = new View.OnFocusChangeListener() { 1021 @Override 1022 public void onFocusChange(View v, boolean focused) { 1023 if (focused) { 1024 switch (v.getId()) { 1025 case R.id.am_label: 1026 setAmOrPm(AM); 1027 break; 1028 case R.id.pm_label: 1029 setAmOrPm(PM); 1030 break; 1031 case R.id.hours: 1032 setCurrentItemShowing(HOUR_INDEX, true, true); 1033 break; 1034 case R.id.minutes: 1035 setCurrentItemShowing(MINUTE_INDEX, true, true); 1036 break; 1037 default: 1038 // Failed to handle this click, don't vibrate. 1039 return; 1040 } 1041 1042 tryVibrate(); 1043 } 1044 } 1045 }; 1046 1047 private final View.OnClickListener mClickListener = new View.OnClickListener() { 1048 @Override 1049 public void onClick(View v) { 1050 1051 final int amOrPm; 1052 switch (v.getId()) { 1053 case R.id.am_label: 1054 setAmOrPm(AM); 1055 break; 1056 case R.id.pm_label: 1057 setAmOrPm(PM); 1058 break; 1059 case R.id.hours: 1060 setCurrentItemShowing(HOUR_INDEX, true, true); 1061 break; 1062 case R.id.minutes: 1063 setCurrentItemShowing(MINUTE_INDEX, true, true); 1064 break; 1065 default: 1066 // Failed to handle this click, don't vibrate. 1067 return; 1068 } 1069 1070 tryVibrate(); 1071 } 1072 }; 1073 1074 /** 1075 * Delegates unhandled touches in a view group to the nearest child view. 1076 */ 1077 private static class NearestTouchDelegate implements View.OnTouchListener { 1078 private View mInitialTouchTarget; 1079 1080 @Override 1081 public boolean onTouch(View view, MotionEvent motionEvent) { 1082 final int actionMasked = motionEvent.getActionMasked(); 1083 if (actionMasked == MotionEvent.ACTION_DOWN) { 1084 if (view instanceof ViewGroup) { 1085 mInitialTouchTarget = findNearestChild((ViewGroup) view, 1086 (int) motionEvent.getX(), (int) motionEvent.getY()); 1087 } else { 1088 mInitialTouchTarget = null; 1089 } 1090 } 1091 1092 final View child = mInitialTouchTarget; 1093 if (child == null) { 1094 return false; 1095 } 1096 1097 final float offsetX = view.getScrollX() - child.getLeft(); 1098 final float offsetY = view.getScrollY() - child.getTop(); 1099 motionEvent.offsetLocation(offsetX, offsetY); 1100 final boolean handled = child.dispatchTouchEvent(motionEvent); 1101 motionEvent.offsetLocation(-offsetX, -offsetY); 1102 1103 if (actionMasked == MotionEvent.ACTION_UP 1104 || actionMasked == MotionEvent.ACTION_CANCEL) { 1105 mInitialTouchTarget = null; 1106 } 1107 1108 return handled; 1109 } 1110 1111 private View findNearestChild(ViewGroup v, int x, int y) { 1112 View bestChild = null; 1113 int bestDist = Integer.MAX_VALUE; 1114 1115 for (int i = 0, count = v.getChildCount(); i < count; i++) { 1116 final View child = v.getChildAt(i); 1117 final int dX = x - (child.getLeft() + child.getWidth() / 2); 1118 final int dY = y - (child.getTop() + child.getHeight() / 2); 1119 final int dist = dX * dX + dY * dY; 1120 if (bestDist > dist) { 1121 bestChild = child; 1122 bestDist = dist; 1123 } 1124 } 1125 1126 return bestChild; 1127 } 1128 } 1129 } 1130