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