TimePickerClockDelegate.java revision ffb46bf2956d89e3190007ccf2ef3ce3eed005fe
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.content.Context; 20import android.content.res.ColorStateList; 21import android.content.res.Configuration; 22import android.content.res.Resources; 23import android.content.res.TypedArray; 24import android.os.Parcel; 25import android.os.Parcelable; 26import android.text.TextUtils; 27import android.text.format.DateFormat; 28import android.text.format.DateUtils; 29import android.util.AttributeSet; 30import android.util.Log; 31import android.util.TypedValue; 32import android.view.HapticFeedbackConstants; 33import android.view.KeyCharacterMap; 34import android.view.KeyEvent; 35import android.view.LayoutInflater; 36import android.view.View; 37import android.view.ViewGroup; 38import android.view.accessibility.AccessibilityEvent; 39import android.view.accessibility.AccessibilityNodeInfo; 40 41import com.android.internal.R; 42 43import java.util.ArrayList; 44import java.util.Calendar; 45import java.util.Locale; 46 47/** 48 * A delegate implementing the radial clock-based TimePicker. 49 */ 50class TimePickerClockDelegate extends TimePicker.AbstractTimePickerDelegate implements 51 RadialTimePickerView.OnValueSelectedListener { 52 53 private static final String TAG = "TimePickerClockDelegate"; 54 55 // Index used by RadialPickerLayout 56 private static final int HOUR_INDEX = 0; 57 private static final int MINUTE_INDEX = 1; 58 59 // NOT a real index for the purpose of what's showing. 60 private static final int AMPM_INDEX = 2; 61 62 // Also NOT a real index, just used for keyboard mode. 63 private static final int ENABLE_PICKER_INDEX = 3; 64 65 static final int AM = 0; 66 static final int PM = 1; 67 68 private static final boolean DEFAULT_ENABLED_STATE = true; 69 private boolean mIsEnabled = DEFAULT_ENABLED_STATE; 70 71 private static final int HOURS_IN_HALF_DAY = 12; 72 73 private final View mHeaderView; 74 private final TextView mHourView; 75 private final TextView mMinuteView; 76 private final View mAmPmLayout; 77 private final CheckedTextView mAmLabel; 78 private final CheckedTextView mPmLabel; 79 private final RadialTimePickerView mRadialTimePickerView; 80 private final TextView mSeparatorView; 81 82 private final String mAmText; 83 private final String mPmText; 84 85 private final float mDisabledAlpha; 86 87 private boolean mAllowAutoAdvance; 88 private int mInitialHourOfDay; 89 private int mInitialMinute; 90 private boolean mIs24HourView; 91 92 // For hardware IME input. 93 private char mPlaceholderText; 94 private String mDoublePlaceholderText; 95 private String mDeletedKeyFormat; 96 private boolean mInKbMode; 97 private ArrayList<Integer> mTypedTimes = new ArrayList<Integer>(); 98 private Node mLegalTimesTree; 99 private int mAmKeyCode; 100 private int mPmKeyCode; 101 102 // Accessibility strings. 103 private String mHourPickerDescription; 104 private String mSelectHours; 105 private String mMinutePickerDescription; 106 private String mSelectMinutes; 107 108 // Most recent time announcement values for accessibility. 109 private CharSequence mLastAnnouncedText; 110 private boolean mLastAnnouncedIsHour; 111 112 private Calendar mTempCalendar; 113 114 public TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs, 115 int defStyleAttr, int defStyleRes) { 116 super(delegator, context); 117 118 // process style attributes 119 final TypedArray a = mContext.obtainStyledAttributes(attrs, 120 R.styleable.TimePicker, defStyleAttr, defStyleRes); 121 final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService( 122 Context.LAYOUT_INFLATER_SERVICE); 123 final Resources res = mContext.getResources(); 124 125 mHourPickerDescription = res.getString(R.string.hour_picker_description); 126 mSelectHours = res.getString(R.string.select_hours); 127 mMinutePickerDescription = res.getString(R.string.minute_picker_description); 128 mSelectMinutes = res.getString(R.string.select_minutes); 129 130 String[] amPmStrings = TimePickerSpinnerDelegate.getAmPmStrings(context); 131 mAmText = amPmStrings[0]; 132 mPmText = amPmStrings[1]; 133 134 final int layoutResourceId = a.getResourceId(R.styleable.TimePicker_internalLayout, 135 R.layout.time_picker_holo); 136 final View mainView = inflater.inflate(layoutResourceId, delegator); 137 138 mHeaderView = mainView.findViewById(R.id.time_header); 139 mHeaderView.setBackground(a.getDrawable(R.styleable.TimePicker_headerBackground)); 140 141 // Set up hour/minute labels. 142 mHourView = (TextView) mHeaderView.findViewById(R.id.hours); 143 mHourView.setOnClickListener(mClickListener); 144 mSeparatorView = (TextView) mHeaderView.findViewById(R.id.separator); 145 mMinuteView = (TextView) mHeaderView.findViewById(R.id.minutes); 146 mMinuteView.setOnClickListener(mClickListener); 147 148 final int headerTimeTextAppearance = a.getResourceId( 149 R.styleable.TimePicker_headerTimeTextAppearance, 0); 150 if (headerTimeTextAppearance != 0) { 151 mHourView.setTextAppearance(context, headerTimeTextAppearance); 152 mSeparatorView.setTextAppearance(context, headerTimeTextAppearance); 153 mMinuteView.setTextAppearance(context, headerTimeTextAppearance); 154 } 155 156 // TODO: This can be removed once we support themed color state lists. 157 final int headerSelectedTextColor = a.getColor( 158 R.styleable.TimePicker_headerSelectedTextColor, 159 res.getColor(R.color.timepicker_default_selector_color_material)); 160 mHourView.setTextColor(ColorStateList.addFirstIfMissing(mHourView.getTextColors(), 161 R.attr.state_selected, headerSelectedTextColor)); 162 mMinuteView.setTextColor(ColorStateList.addFirstIfMissing(mMinuteView.getTextColors(), 163 R.attr.state_selected, headerSelectedTextColor)); 164 165 // Set up AM/PM labels. 166 mAmPmLayout = mHeaderView.findViewById(R.id.ampm_layout); 167 mAmLabel = (CheckedTextView) mAmPmLayout.findViewById(R.id.am_label); 168 mAmLabel.setText(amPmStrings[0]); 169 mAmLabel.setOnClickListener(mClickListener); 170 mPmLabel = (CheckedTextView) mAmPmLayout.findViewById(R.id.pm_label); 171 mPmLabel.setText(amPmStrings[1]); 172 mPmLabel.setOnClickListener(mClickListener); 173 174 final int headerAmPmTextAppearance = a.getResourceId( 175 R.styleable.TimePicker_headerAmPmTextAppearance, 0); 176 if (headerAmPmTextAppearance != 0) { 177 mAmLabel.setTextAppearance(context, headerAmPmTextAppearance); 178 mPmLabel.setTextAppearance(context, headerAmPmTextAppearance); 179 } 180 181 a.recycle(); 182 183 // Pull disabled alpha from theme. 184 final TypedValue outValue = new TypedValue(); 185 context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true); 186 mDisabledAlpha = outValue.getFloat(); 187 188 mRadialTimePickerView = (RadialTimePickerView) mainView.findViewById( 189 R.id.radial_picker); 190 191 setupListeners(); 192 193 mAllowAutoAdvance = true; 194 195 // Set up for keyboard mode. 196 mDoublePlaceholderText = res.getString(R.string.time_placeholder); 197 mDeletedKeyFormat = res.getString(R.string.deleted_key); 198 mPlaceholderText = mDoublePlaceholderText.charAt(0); 199 mAmKeyCode = mPmKeyCode = -1; 200 generateLegalTimesTree(); 201 202 // Initialize with current time 203 final Calendar calendar = Calendar.getInstance(mCurrentLocale); 204 final int currentHour = calendar.get(Calendar.HOUR_OF_DAY); 205 final int currentMinute = calendar.get(Calendar.MINUTE); 206 initialize(currentHour, currentMinute, false /* 12h */, HOUR_INDEX); 207 } 208 209 private void initialize(int hourOfDay, int minute, boolean is24HourView, int index) { 210 mInitialHourOfDay = hourOfDay; 211 mInitialMinute = minute; 212 mIs24HourView = is24HourView; 213 mInKbMode = false; 214 updateUI(index); 215 } 216 217 private void setupListeners() { 218 mHeaderView.setOnKeyListener(mKeyListener); 219 mHeaderView.setOnFocusChangeListener(mFocusListener); 220 mHeaderView.setFocusable(true); 221 222 mRadialTimePickerView.setOnValueSelectedListener(this); 223 } 224 225 private void updateUI(int index) { 226 // Update RadialPicker values 227 updateRadialPicker(index); 228 // Enable or disable the AM/PM view. 229 updateHeaderAmPm(); 230 // Update Hour and Minutes 231 updateHeaderHour(mInitialHourOfDay, false); 232 // Update time separator 233 updateHeaderSeparator(); 234 // Update Minutes 235 updateHeaderMinute(mInitialMinute, false); 236 // Invalidate everything 237 mDelegator.invalidate(); 238 } 239 240 private void updateRadialPicker(int index) { 241 mRadialTimePickerView.initialize(mInitialHourOfDay, mInitialMinute, mIs24HourView); 242 setCurrentItemShowing(index, false, true); 243 } 244 245 private int computeMaxWidthOfNumbers(int max) { 246 TextView tempView = new TextView(mContext); 247 tempView.setTextAppearance(mContext, R.style.TextAppearance_Material_TimePicker_TimeLabel); 248 ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 249 ViewGroup.LayoutParams.WRAP_CONTENT); 250 tempView.setLayoutParams(lp); 251 int maxWidth = 0; 252 for (int minutes = 0; minutes < max; minutes++) { 253 final String text = String.format("%02d", minutes); 254 tempView.setText(text); 255 tempView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); 256 maxWidth = Math.max(maxWidth, tempView.getMeasuredWidth()); 257 } 258 return maxWidth; 259 } 260 261 private void updateHeaderAmPm() { 262 if (mIs24HourView) { 263 mAmPmLayout.setVisibility(View.GONE); 264 } else { 265 final String bestDateTimePattern = DateFormat.getBestDateTimePattern( 266 mCurrentLocale, "hm"); 267 boolean amPmOnLeft = bestDateTimePattern.startsWith("a"); 268 if (TextUtils.getLayoutDirectionFromLocale(mCurrentLocale) == 269 View.LAYOUT_DIRECTION_RTL) { 270 amPmOnLeft = !amPmOnLeft; 271 } 272 273 final ViewGroup.MarginLayoutParams params = 274 (ViewGroup.MarginLayoutParams) mAmPmLayout.getLayoutParams(); 275 276 if (amPmOnLeft) { 277 params.leftMargin = 0; 278 params.rightMargin = computeMaxWidthOfNumbers(12 /* for hours */); 279 } else { 280 params.leftMargin = computeMaxWidthOfNumbers(60 /* for minutes */); 281 params.rightMargin = 0; 282 } 283 284 mAmPmLayout.setLayoutParams(params); 285 mAmPmLayout.setVisibility(View.VISIBLE); 286 287 updateAmPmLabelStates(mInitialHourOfDay < 12 ? AM : PM); 288 } 289 } 290 291 /** 292 * Set the current hour. 293 */ 294 @Override 295 public void setCurrentHour(Integer currentHour) { 296 if (mInitialHourOfDay == currentHour) { 297 return; 298 } 299 mInitialHourOfDay = currentHour; 300 updateHeaderHour(currentHour, true); 301 updateHeaderAmPm(); 302 mRadialTimePickerView.setCurrentHour(currentHour); 303 mRadialTimePickerView.setAmOrPm(mInitialHourOfDay < 12 ? AM : PM); 304 mDelegator.invalidate(); 305 onTimeChanged(); 306 } 307 308 /** 309 * @return The current hour in the range (0-23). 310 */ 311 @Override 312 public Integer getCurrentHour() { 313 int currentHour = mRadialTimePickerView.getCurrentHour(); 314 if (mIs24HourView) { 315 return currentHour; 316 } else { 317 switch(mRadialTimePickerView.getAmOrPm()) { 318 case PM: 319 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 320 case AM: 321 default: 322 return currentHour % HOURS_IN_HALF_DAY; 323 } 324 } 325 } 326 327 /** 328 * Set the current minute (0-59). 329 */ 330 @Override 331 public void setCurrentMinute(Integer currentMinute) { 332 if (mInitialMinute == currentMinute) { 333 return; 334 } 335 mInitialMinute = currentMinute; 336 updateHeaderMinute(currentMinute, true); 337 mRadialTimePickerView.setCurrentMinute(currentMinute); 338 mDelegator.invalidate(); 339 onTimeChanged(); 340 } 341 342 /** 343 * @return The current minute. 344 */ 345 @Override 346 public Integer getCurrentMinute() { 347 return mRadialTimePickerView.getCurrentMinute(); 348 } 349 350 /** 351 * Set whether in 24 hour or AM/PM mode. 352 * 353 * @param is24HourView True = 24 hour mode. False = AM/PM. 354 */ 355 @Override 356 public void setIs24HourView(Boolean is24HourView) { 357 if (is24HourView == mIs24HourView) { 358 return; 359 } 360 mIs24HourView = is24HourView; 361 generateLegalTimesTree(); 362 int hour = mRadialTimePickerView.getCurrentHour(); 363 mInitialHourOfDay = hour; 364 updateHeaderHour(hour, false); 365 updateHeaderAmPm(); 366 updateRadialPicker(mRadialTimePickerView.getCurrentItemShowing()); 367 mDelegator.invalidate(); 368 } 369 370 /** 371 * @return true if this is in 24 hour view else false. 372 */ 373 @Override 374 public boolean is24HourView() { 375 return mIs24HourView; 376 } 377 378 @Override 379 public void setOnTimeChangedListener(TimePicker.OnTimeChangedListener callback) { 380 mOnTimeChangedListener = callback; 381 } 382 383 @Override 384 public void setEnabled(boolean enabled) { 385 mHourView.setEnabled(enabled); 386 mMinuteView.setEnabled(enabled); 387 mAmLabel.setEnabled(enabled); 388 mPmLabel.setEnabled(enabled); 389 mRadialTimePickerView.setEnabled(enabled); 390 mIsEnabled = enabled; 391 } 392 393 @Override 394 public boolean isEnabled() { 395 return mIsEnabled; 396 } 397 398 @Override 399 public int getBaseline() { 400 // does not support baseline alignment 401 return -1; 402 } 403 404 @Override 405 public void onConfigurationChanged(Configuration newConfig) { 406 updateUI(mRadialTimePickerView.getCurrentItemShowing()); 407 } 408 409 @Override 410 public Parcelable onSaveInstanceState(Parcelable superState) { 411 return new SavedState(superState, getCurrentHour(), getCurrentMinute(), 412 is24HourView(), inKbMode(), getTypedTimes(), getCurrentItemShowing()); 413 } 414 415 @Override 416 public void onRestoreInstanceState(Parcelable state) { 417 SavedState ss = (SavedState) state; 418 setInKbMode(ss.inKbMode()); 419 setTypedTimes(ss.getTypesTimes()); 420 initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing()); 421 mRadialTimePickerView.invalidate(); 422 if (mInKbMode) { 423 tryStartingKbMode(-1); 424 mHourView.invalidate(); 425 } 426 } 427 428 @Override 429 public void setCurrentLocale(Locale locale) { 430 super.setCurrentLocale(locale); 431 mTempCalendar = Calendar.getInstance(locale); 432 } 433 434 @Override 435 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 436 onPopulateAccessibilityEvent(event); 437 return true; 438 } 439 440 @Override 441 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 442 int flags = DateUtils.FORMAT_SHOW_TIME; 443 if (mIs24HourView) { 444 flags |= DateUtils.FORMAT_24HOUR; 445 } else { 446 flags |= DateUtils.FORMAT_12HOUR; 447 } 448 mTempCalendar.set(Calendar.HOUR_OF_DAY, getCurrentHour()); 449 mTempCalendar.set(Calendar.MINUTE, getCurrentMinute()); 450 String selectedDate = DateUtils.formatDateTime(mContext, 451 mTempCalendar.getTimeInMillis(), flags); 452 event.getText().add(selectedDate); 453 } 454 455 @Override 456 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 457 event.setClassName(TimePicker.class.getName()); 458 } 459 460 @Override 461 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { 462 info.setClassName(TimePicker.class.getName()); 463 } 464 465 /** 466 * Set whether in keyboard mode or not. 467 * 468 * @param inKbMode True means in keyboard mode. 469 */ 470 private void setInKbMode(boolean inKbMode) { 471 mInKbMode = inKbMode; 472 } 473 474 /** 475 * @return true if in keyboard mode 476 */ 477 private boolean inKbMode() { 478 return mInKbMode; 479 } 480 481 private void setTypedTimes(ArrayList<Integer> typeTimes) { 482 mTypedTimes = typeTimes; 483 } 484 485 /** 486 * @return an array of typed times 487 */ 488 private ArrayList<Integer> getTypedTimes() { 489 return mTypedTimes; 490 } 491 492 /** 493 * @return the index of the current item showing 494 */ 495 private int getCurrentItemShowing() { 496 return mRadialTimePickerView.getCurrentItemShowing(); 497 } 498 499 /** 500 * Propagate the time change 501 */ 502 private void onTimeChanged() { 503 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 504 if (mOnTimeChangedListener != null) { 505 mOnTimeChangedListener.onTimeChanged(mDelegator, 506 getCurrentHour(), getCurrentMinute()); 507 } 508 } 509 510 /** 511 * Used to save / restore state of time picker 512 */ 513 private static class SavedState extends View.BaseSavedState { 514 515 private final int mHour; 516 private final int mMinute; 517 private final boolean mIs24HourMode; 518 private final boolean mInKbMode; 519 private final ArrayList<Integer> mTypedTimes; 520 private final int mCurrentItemShowing; 521 522 private SavedState(Parcelable superState, int hour, int minute, boolean is24HourMode, 523 boolean isKbMode, ArrayList<Integer> typedTimes, 524 int currentItemShowing) { 525 super(superState); 526 mHour = hour; 527 mMinute = minute; 528 mIs24HourMode = is24HourMode; 529 mInKbMode = isKbMode; 530 mTypedTimes = typedTimes; 531 mCurrentItemShowing = currentItemShowing; 532 } 533 534 private SavedState(Parcel in) { 535 super(in); 536 mHour = in.readInt(); 537 mMinute = in.readInt(); 538 mIs24HourMode = (in.readInt() == 1); 539 mInKbMode = (in.readInt() == 1); 540 mTypedTimes = in.readArrayList(getClass().getClassLoader()); 541 mCurrentItemShowing = in.readInt(); 542 } 543 544 public int getHour() { 545 return mHour; 546 } 547 548 public int getMinute() { 549 return mMinute; 550 } 551 552 public boolean is24HourMode() { 553 return mIs24HourMode; 554 } 555 556 public boolean inKbMode() { 557 return mInKbMode; 558 } 559 560 public ArrayList<Integer> getTypesTimes() { 561 return mTypedTimes; 562 } 563 564 public int getCurrentItemShowing() { 565 return mCurrentItemShowing; 566 } 567 568 @Override 569 public void writeToParcel(Parcel dest, int flags) { 570 super.writeToParcel(dest, flags); 571 dest.writeInt(mHour); 572 dest.writeInt(mMinute); 573 dest.writeInt(mIs24HourMode ? 1 : 0); 574 dest.writeInt(mInKbMode ? 1 : 0); 575 dest.writeList(mTypedTimes); 576 dest.writeInt(mCurrentItemShowing); 577 } 578 579 @SuppressWarnings({"unused", "hiding"}) 580 public static final Parcelable.Creator<SavedState> CREATOR = new Creator<SavedState>() { 581 public SavedState createFromParcel(Parcel in) { 582 return new SavedState(in); 583 } 584 585 public SavedState[] newArray(int size) { 586 return new SavedState[size]; 587 } 588 }; 589 } 590 591 private void tryVibrate() { 592 mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); 593 } 594 595 private void updateAmPmLabelStates(int amOrPm) { 596 final boolean isAm = amOrPm == AM; 597 mAmLabel.setChecked(isAm); 598 mAmLabel.setAlpha(isAm ? 1 : mDisabledAlpha); 599 600 final boolean isPm = amOrPm == PM; 601 mPmLabel.setChecked(isPm); 602 mPmLabel.setAlpha(isPm ? 1 : mDisabledAlpha); 603 } 604 605 /** 606 * Called by the picker for updating the header display. 607 */ 608 @Override 609 public void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance) { 610 if (pickerIndex == HOUR_INDEX) { 611 if (mAllowAutoAdvance && autoAdvance) { 612 updateHeaderHour(newValue, false); 613 setCurrentItemShowing(MINUTE_INDEX, true, false); 614 mDelegator.announceForAccessibility(newValue + ". " + mSelectMinutes); 615 } else { 616 updateHeaderHour(newValue, true); 617 } 618 } else if (pickerIndex == MINUTE_INDEX){ 619 updateHeaderMinute(newValue, true); 620 } else if (pickerIndex == AMPM_INDEX) { 621 updateAmPmLabelStates(newValue); 622 } else if (pickerIndex == ENABLE_PICKER_INDEX) { 623 if (!isTypedTimeFullyLegal()) { 624 mTypedTimes.clear(); 625 } 626 finishKbMode(); 627 } 628 } 629 630 private void updateHeaderHour(int value, boolean announce) { 631 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale, 632 (mIs24HourView) ? "Hm" : "hm"); 633 final int lengthPattern = bestDateTimePattern.length(); 634 boolean hourWithTwoDigit = false; 635 char hourFormat = '\0'; 636 // Check if the returned pattern is single or double 'H', 'h', 'K', 'k'. We also save 637 // the hour format that we found. 638 for (int i = 0; i < lengthPattern; i++) { 639 final char c = bestDateTimePattern.charAt(i); 640 if (c == 'H' || c == 'h' || c == 'K' || c == 'k') { 641 hourFormat = c; 642 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) { 643 hourWithTwoDigit = true; 644 } 645 break; 646 } 647 } 648 final String format; 649 if (hourWithTwoDigit) { 650 format = "%02d"; 651 } else { 652 format = "%d"; 653 } 654 if (mIs24HourView) { 655 // 'k' means 1-24 hour 656 if (hourFormat == 'k' && value == 0) { 657 value = 24; 658 } 659 } else { 660 // 'K' means 0-11 hour 661 value = modulo12(value, hourFormat == 'K'); 662 } 663 CharSequence text = String.format(format, value); 664 mHourView.setText(text); 665 if (announce) { 666 tryAnnounceForAccessibility(text, true); 667 } 668 } 669 670 private void tryAnnounceForAccessibility(CharSequence text, boolean isHour) { 671 if (mLastAnnouncedIsHour != isHour || !text.equals(mLastAnnouncedText)) { 672 // TODO: Find a better solution, potentially live regions? 673 mDelegator.announceForAccessibility(text); 674 mLastAnnouncedText = text; 675 mLastAnnouncedIsHour = isHour; 676 } 677 } 678 679 private static int modulo12(int n, boolean startWithZero) { 680 int value = n % 12; 681 if (value == 0 && !startWithZero) { 682 value = 12; 683 } 684 return value; 685 } 686 687 /** 688 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 689 * 690 * See http://unicode.org/cldr/trac/browser/trunk/common/main 691 * 692 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 693 * separator as the character which is just after the hour marker in the returned pattern. 694 */ 695 private void updateHeaderSeparator() { 696 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale, 697 (mIs24HourView) ? "Hm" : "hm"); 698 final String separatorText; 699 // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats 700 final char[] hourFormats = {'H', 'h', 'K', 'k'}; 701 int hIndex = lastIndexOfAny(bestDateTimePattern, hourFormats); 702 if (hIndex == -1) { 703 // Default case 704 separatorText = ":"; 705 } else { 706 separatorText = Character.toString(bestDateTimePattern.charAt(hIndex + 1)); 707 } 708 mSeparatorView.setText(separatorText); 709 } 710 711 static private int lastIndexOfAny(String str, char[] any) { 712 final int lengthAny = any.length; 713 if (lengthAny > 0) { 714 for (int i = str.length() - 1; i >= 0; i--) { 715 char c = str.charAt(i); 716 for (int j = 0; j < lengthAny; j++) { 717 if (c == any[j]) { 718 return i; 719 } 720 } 721 } 722 } 723 return -1; 724 } 725 726 private void updateHeaderMinute(int value, boolean announceForAccessibility) { 727 if (value == 60) { 728 value = 0; 729 } 730 final CharSequence text = String.format(mCurrentLocale, "%02d", value); 731 mMinuteView.setText(text); 732 if (announceForAccessibility) { 733 tryAnnounceForAccessibility(text, false); 734 } 735 } 736 737 /** 738 * Show either Hours or Minutes. 739 */ 740 private void setCurrentItemShowing(int index, boolean animateCircle, boolean announce) { 741 mRadialTimePickerView.setCurrentItemShowing(index, animateCircle); 742 743 if (index == HOUR_INDEX) { 744 if (announce) { 745 mDelegator.announceForAccessibility(mSelectHours); 746 } 747 } else { 748 if (announce) { 749 mDelegator.announceForAccessibility(mSelectMinutes); 750 } 751 } 752 753 mHourView.setSelected(index == HOUR_INDEX); 754 mMinuteView.setSelected(index == MINUTE_INDEX); 755 } 756 757 private void setAmOrPm(int amOrPm) { 758 updateAmPmLabelStates(amOrPm); 759 mRadialTimePickerView.setAmOrPm(amOrPm); 760 } 761 762 /** 763 * For keyboard mode, processes key events. 764 * 765 * @param keyCode the pressed key. 766 * 767 * @return true if the key was successfully processed, false otherwise. 768 */ 769 private boolean processKeyUp(int keyCode) { 770 if (keyCode == KeyEvent.KEYCODE_DEL) { 771 if (mInKbMode) { 772 if (!mTypedTimes.isEmpty()) { 773 int deleted = deleteLastTypedKey(); 774 String deletedKeyStr; 775 if (deleted == getAmOrPmKeyCode(AM)) { 776 deletedKeyStr = mAmText; 777 } else if (deleted == getAmOrPmKeyCode(PM)) { 778 deletedKeyStr = mPmText; 779 } else { 780 deletedKeyStr = String.format("%d", getValFromKeyCode(deleted)); 781 } 782 mDelegator.announceForAccessibility( 783 String.format(mDeletedKeyFormat, deletedKeyStr)); 784 updateDisplay(true); 785 } 786 } 787 } else if (keyCode == KeyEvent.KEYCODE_0 || keyCode == KeyEvent.KEYCODE_1 788 || keyCode == KeyEvent.KEYCODE_2 || keyCode == KeyEvent.KEYCODE_3 789 || keyCode == KeyEvent.KEYCODE_4 || keyCode == KeyEvent.KEYCODE_5 790 || keyCode == KeyEvent.KEYCODE_6 || keyCode == KeyEvent.KEYCODE_7 791 || keyCode == KeyEvent.KEYCODE_8 || keyCode == KeyEvent.KEYCODE_9 792 || (!mIs24HourView && 793 (keyCode == getAmOrPmKeyCode(AM) || keyCode == getAmOrPmKeyCode(PM)))) { 794 if (!mInKbMode) { 795 if (mRadialTimePickerView == null) { 796 // Something's wrong, because time picker should definitely not be null. 797 Log.e(TAG, "Unable to initiate keyboard mode, TimePicker was null."); 798 return true; 799 } 800 mTypedTimes.clear(); 801 tryStartingKbMode(keyCode); 802 return true; 803 } 804 // We're already in keyboard mode. 805 if (addKeyIfLegal(keyCode)) { 806 updateDisplay(false); 807 } 808 return true; 809 } 810 return false; 811 } 812 813 /** 814 * Try to start keyboard mode with the specified key. 815 * 816 * @param keyCode The key to use as the first press. Keyboard mode will not be started if the 817 * key is not legal to start with. Or, pass in -1 to get into keyboard mode without a starting 818 * key. 819 */ 820 private void tryStartingKbMode(int keyCode) { 821 if (keyCode == -1 || addKeyIfLegal(keyCode)) { 822 mInKbMode = true; 823 onValidationChanged(false); 824 updateDisplay(false); 825 mRadialTimePickerView.setInputEnabled(false); 826 } 827 } 828 829 private boolean addKeyIfLegal(int keyCode) { 830 // If we're in 24hour mode, we'll need to check if the input is full. If in AM/PM mode, 831 // we'll need to see if AM/PM have been typed. 832 if ((mIs24HourView && mTypedTimes.size() == 4) || 833 (!mIs24HourView && isTypedTimeFullyLegal())) { 834 return false; 835 } 836 837 mTypedTimes.add(keyCode); 838 if (!isTypedTimeLegalSoFar()) { 839 deleteLastTypedKey(); 840 return false; 841 } 842 843 int val = getValFromKeyCode(keyCode); 844 mDelegator.announceForAccessibility(String.format("%d", val)); 845 // Automatically fill in 0's if AM or PM was legally entered. 846 if (isTypedTimeFullyLegal()) { 847 if (!mIs24HourView && mTypedTimes.size() <= 3) { 848 mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0); 849 mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0); 850 } 851 onValidationChanged(true); 852 } 853 854 return true; 855 } 856 857 /** 858 * Traverse the tree to see if the keys that have been typed so far are legal as is, 859 * or may become legal as more keys are typed (excluding backspace). 860 */ 861 private boolean isTypedTimeLegalSoFar() { 862 Node node = mLegalTimesTree; 863 for (int keyCode : mTypedTimes) { 864 node = node.canReach(keyCode); 865 if (node == null) { 866 return false; 867 } 868 } 869 return true; 870 } 871 872 /** 873 * Check if the time that has been typed so far is completely legal, as is. 874 */ 875 private boolean isTypedTimeFullyLegal() { 876 if (mIs24HourView) { 877 // For 24-hour mode, the time is legal if the hours and minutes are each legal. Note: 878 // getEnteredTime() will ONLY call isTypedTimeFullyLegal() when NOT in 24hour mode. 879 int[] values = getEnteredTime(null); 880 return (values[0] >= 0 && values[1] >= 0 && values[1] < 60); 881 } else { 882 // For AM/PM mode, the time is legal if it contains an AM or PM, as those can only be 883 // legally added at specific times based on the tree's algorithm. 884 return (mTypedTimes.contains(getAmOrPmKeyCode(AM)) || 885 mTypedTimes.contains(getAmOrPmKeyCode(PM))); 886 } 887 } 888 889 private int deleteLastTypedKey() { 890 int deleted = mTypedTimes.remove(mTypedTimes.size() - 1); 891 if (!isTypedTimeFullyLegal()) { 892 onValidationChanged(false); 893 } 894 return deleted; 895 } 896 897 /** 898 * Get out of keyboard mode. If there is nothing in typedTimes, revert to TimePicker's time. 899 */ 900 private void finishKbMode() { 901 mInKbMode = false; 902 if (!mTypedTimes.isEmpty()) { 903 int values[] = getEnteredTime(null); 904 mRadialTimePickerView.setCurrentHour(values[0]); 905 mRadialTimePickerView.setCurrentMinute(values[1]); 906 if (!mIs24HourView) { 907 mRadialTimePickerView.setAmOrPm(values[2]); 908 } 909 mTypedTimes.clear(); 910 } 911 updateDisplay(false); 912 mRadialTimePickerView.setInputEnabled(true); 913 } 914 915 /** 916 * Update the hours, minutes, and AM/PM displays with the typed times. If the typedTimes is 917 * empty, either show an empty display (filled with the placeholder text), or update from the 918 * timepicker's values. 919 * 920 * @param allowEmptyDisplay if true, then if the typedTimes is empty, use the placeholder text. 921 * Otherwise, revert to the timepicker's values. 922 */ 923 private void updateDisplay(boolean allowEmptyDisplay) { 924 if (!allowEmptyDisplay && mTypedTimes.isEmpty()) { 925 int hour = mRadialTimePickerView.getCurrentHour(); 926 int minute = mRadialTimePickerView.getCurrentMinute(); 927 updateHeaderHour(hour, false); 928 updateHeaderMinute(minute, false); 929 if (!mIs24HourView) { 930 updateAmPmLabelStates(hour < 12 ? AM : PM); 931 } 932 setCurrentItemShowing(mRadialTimePickerView.getCurrentItemShowing(), true, true); 933 onValidationChanged(true); 934 } else { 935 boolean[] enteredZeros = {false, false}; 936 int[] values = getEnteredTime(enteredZeros); 937 String hourFormat = enteredZeros[0] ? "%02d" : "%2d"; 938 String minuteFormat = (enteredZeros[1]) ? "%02d" : "%2d"; 939 String hourStr = (values[0] == -1) ? mDoublePlaceholderText : 940 String.format(hourFormat, values[0]).replace(' ', mPlaceholderText); 941 String minuteStr = (values[1] == -1) ? mDoublePlaceholderText : 942 String.format(minuteFormat, values[1]).replace(' ', mPlaceholderText); 943 mHourView.setText(hourStr); 944 mHourView.setSelected(false); 945 mMinuteView.setText(minuteStr); 946 mMinuteView.setSelected(false); 947 if (!mIs24HourView) { 948 updateAmPmLabelStates(values[2]); 949 } 950 } 951 } 952 953 private int getValFromKeyCode(int keyCode) { 954 switch (keyCode) { 955 case KeyEvent.KEYCODE_0: 956 return 0; 957 case KeyEvent.KEYCODE_1: 958 return 1; 959 case KeyEvent.KEYCODE_2: 960 return 2; 961 case KeyEvent.KEYCODE_3: 962 return 3; 963 case KeyEvent.KEYCODE_4: 964 return 4; 965 case KeyEvent.KEYCODE_5: 966 return 5; 967 case KeyEvent.KEYCODE_6: 968 return 6; 969 case KeyEvent.KEYCODE_7: 970 return 7; 971 case KeyEvent.KEYCODE_8: 972 return 8; 973 case KeyEvent.KEYCODE_9: 974 return 9; 975 default: 976 return -1; 977 } 978 } 979 980 /** 981 * Get the currently-entered time, as integer values of the hours and minutes typed. 982 * 983 * @param enteredZeros A size-2 boolean array, which the caller should initialize, and which 984 * may then be used for the caller to know whether zeros had been explicitly entered as either 985 * hours of minutes. This is helpful for deciding whether to show the dashes, or actual 0's. 986 * 987 * @return A size-3 int array. The first value will be the hours, the second value will be the 988 * minutes, and the third will be either AM or PM. 989 */ 990 private int[] getEnteredTime(boolean[] enteredZeros) { 991 int amOrPm = -1; 992 int startIndex = 1; 993 if (!mIs24HourView && isTypedTimeFullyLegal()) { 994 int keyCode = mTypedTimes.get(mTypedTimes.size() - 1); 995 if (keyCode == getAmOrPmKeyCode(AM)) { 996 amOrPm = AM; 997 } else if (keyCode == getAmOrPmKeyCode(PM)){ 998 amOrPm = PM; 999 } 1000 startIndex = 2; 1001 } 1002 int minute = -1; 1003 int hour = -1; 1004 for (int i = startIndex; i <= mTypedTimes.size(); i++) { 1005 int val = getValFromKeyCode(mTypedTimes.get(mTypedTimes.size() - i)); 1006 if (i == startIndex) { 1007 minute = val; 1008 } else if (i == startIndex+1) { 1009 minute += 10 * val; 1010 if (enteredZeros != null && val == 0) { 1011 enteredZeros[1] = true; 1012 } 1013 } else if (i == startIndex+2) { 1014 hour = val; 1015 } else if (i == startIndex+3) { 1016 hour += 10 * val; 1017 if (enteredZeros != null && val == 0) { 1018 enteredZeros[0] = true; 1019 } 1020 } 1021 } 1022 1023 return new int[] { hour, minute, amOrPm }; 1024 } 1025 1026 /** 1027 * Get the keycode value for AM and PM in the current language. 1028 */ 1029 private int getAmOrPmKeyCode(int amOrPm) { 1030 // Cache the codes. 1031 if (mAmKeyCode == -1 || mPmKeyCode == -1) { 1032 // Find the first character in the AM/PM text that is unique. 1033 KeyCharacterMap kcm = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); 1034 char amChar; 1035 char pmChar; 1036 for (int i = 0; i < Math.max(mAmText.length(), mPmText.length()); i++) { 1037 amChar = mAmText.toLowerCase(mCurrentLocale).charAt(i); 1038 pmChar = mPmText.toLowerCase(mCurrentLocale).charAt(i); 1039 if (amChar != pmChar) { 1040 KeyEvent[] events = kcm.getEvents(new char[]{amChar, pmChar}); 1041 // There should be 4 events: a down and up for both AM and PM. 1042 if (events != null && events.length == 4) { 1043 mAmKeyCode = events[0].getKeyCode(); 1044 mPmKeyCode = events[2].getKeyCode(); 1045 } else { 1046 Log.e(TAG, "Unable to find keycodes for AM and PM."); 1047 } 1048 break; 1049 } 1050 } 1051 } 1052 if (amOrPm == AM) { 1053 return mAmKeyCode; 1054 } else if (amOrPm == PM) { 1055 return mPmKeyCode; 1056 } 1057 1058 return -1; 1059 } 1060 1061 /** 1062 * Create a tree for deciding what keys can legally be typed. 1063 */ 1064 private void generateLegalTimesTree() { 1065 // Create a quick cache of numbers to their keycodes. 1066 final int k0 = KeyEvent.KEYCODE_0; 1067 final int k1 = KeyEvent.KEYCODE_1; 1068 final int k2 = KeyEvent.KEYCODE_2; 1069 final int k3 = KeyEvent.KEYCODE_3; 1070 final int k4 = KeyEvent.KEYCODE_4; 1071 final int k5 = KeyEvent.KEYCODE_5; 1072 final int k6 = KeyEvent.KEYCODE_6; 1073 final int k7 = KeyEvent.KEYCODE_7; 1074 final int k8 = KeyEvent.KEYCODE_8; 1075 final int k9 = KeyEvent.KEYCODE_9; 1076 1077 // The root of the tree doesn't contain any numbers. 1078 mLegalTimesTree = new Node(); 1079 if (mIs24HourView) { 1080 // We'll be re-using these nodes, so we'll save them. 1081 Node minuteFirstDigit = new Node(k0, k1, k2, k3, k4, k5); 1082 Node minuteSecondDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); 1083 // The first digit must be followed by the second digit. 1084 minuteFirstDigit.addChild(minuteSecondDigit); 1085 1086 // The first digit may be 0-1. 1087 Node firstDigit = new Node(k0, k1); 1088 mLegalTimesTree.addChild(firstDigit); 1089 1090 // When the first digit is 0-1, the second digit may be 0-5. 1091 Node secondDigit = new Node(k0, k1, k2, k3, k4, k5); 1092 firstDigit.addChild(secondDigit); 1093 // We may now be followed by the first minute digit. E.g. 00:09, 15:58. 1094 secondDigit.addChild(minuteFirstDigit); 1095 1096 // When the first digit is 0-1, and the second digit is 0-5, the third digit may be 6-9. 1097 Node thirdDigit = new Node(k6, k7, k8, k9); 1098 // The time must now be finished. E.g. 0:55, 1:08. 1099 secondDigit.addChild(thirdDigit); 1100 1101 // When the first digit is 0-1, the second digit may be 6-9. 1102 secondDigit = new Node(k6, k7, k8, k9); 1103 firstDigit.addChild(secondDigit); 1104 // We must now be followed by the first minute digit. E.g. 06:50, 18:20. 1105 secondDigit.addChild(minuteFirstDigit); 1106 1107 // The first digit may be 2. 1108 firstDigit = new Node(k2); 1109 mLegalTimesTree.addChild(firstDigit); 1110 1111 // When the first digit is 2, the second digit may be 0-3. 1112 secondDigit = new Node(k0, k1, k2, k3); 1113 firstDigit.addChild(secondDigit); 1114 // We must now be followed by the first minute digit. E.g. 20:50, 23:09. 1115 secondDigit.addChild(minuteFirstDigit); 1116 1117 // When the first digit is 2, the second digit may be 4-5. 1118 secondDigit = new Node(k4, k5); 1119 firstDigit.addChild(secondDigit); 1120 // We must now be followd by the last minute digit. E.g. 2:40, 2:53. 1121 secondDigit.addChild(minuteSecondDigit); 1122 1123 // The first digit may be 3-9. 1124 firstDigit = new Node(k3, k4, k5, k6, k7, k8, k9); 1125 mLegalTimesTree.addChild(firstDigit); 1126 // We must now be followed by the first minute digit. E.g. 3:57, 8:12. 1127 firstDigit.addChild(minuteFirstDigit); 1128 } else { 1129 // We'll need to use the AM/PM node a lot. 1130 // Set up AM and PM to respond to "a" and "p". 1131 Node ampm = new Node(getAmOrPmKeyCode(AM), getAmOrPmKeyCode(PM)); 1132 1133 // The first hour digit may be 1. 1134 Node firstDigit = new Node(k1); 1135 mLegalTimesTree.addChild(firstDigit); 1136 // We'll allow quick input of on-the-hour times. E.g. 1pm. 1137 firstDigit.addChild(ampm); 1138 1139 // When the first digit is 1, the second digit may be 0-2. 1140 Node secondDigit = new Node(k0, k1, k2); 1141 firstDigit.addChild(secondDigit); 1142 // Also for quick input of on-the-hour times. E.g. 10pm, 12am. 1143 secondDigit.addChild(ampm); 1144 1145 // When the first digit is 1, and the second digit is 0-2, the third digit may be 0-5. 1146 Node thirdDigit = new Node(k0, k1, k2, k3, k4, k5); 1147 secondDigit.addChild(thirdDigit); 1148 // The time may be finished now. E.g. 1:02pm, 1:25am. 1149 thirdDigit.addChild(ampm); 1150 1151 // When the first digit is 1, the second digit is 0-2, and the third digit is 0-5, 1152 // the fourth digit may be 0-9. 1153 Node fourthDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); 1154 thirdDigit.addChild(fourthDigit); 1155 // The time must be finished now. E.g. 10:49am, 12:40pm. 1156 fourthDigit.addChild(ampm); 1157 1158 // When the first digit is 1, and the second digit is 0-2, the third digit may be 6-9. 1159 thirdDigit = new Node(k6, k7, k8, k9); 1160 secondDigit.addChild(thirdDigit); 1161 // The time must be finished now. E.g. 1:08am, 1:26pm. 1162 thirdDigit.addChild(ampm); 1163 1164 // When the first digit is 1, the second digit may be 3-5. 1165 secondDigit = new Node(k3, k4, k5); 1166 firstDigit.addChild(secondDigit); 1167 1168 // When the first digit is 1, and the second digit is 3-5, the third digit may be 0-9. 1169 thirdDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); 1170 secondDigit.addChild(thirdDigit); 1171 // The time must be finished now. E.g. 1:39am, 1:50pm. 1172 thirdDigit.addChild(ampm); 1173 1174 // The hour digit may be 2-9. 1175 firstDigit = new Node(k2, k3, k4, k5, k6, k7, k8, k9); 1176 mLegalTimesTree.addChild(firstDigit); 1177 // We'll allow quick input of on-the-hour-times. E.g. 2am, 5pm. 1178 firstDigit.addChild(ampm); 1179 1180 // When the first digit is 2-9, the second digit may be 0-5. 1181 secondDigit = new Node(k0, k1, k2, k3, k4, k5); 1182 firstDigit.addChild(secondDigit); 1183 1184 // When the first digit is 2-9, and the second digit is 0-5, the third digit may be 0-9. 1185 thirdDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9); 1186 secondDigit.addChild(thirdDigit); 1187 // The time must be finished now. E.g. 2:57am, 9:30pm. 1188 thirdDigit.addChild(ampm); 1189 } 1190 } 1191 1192 /** 1193 * Simple node class to be used for traversal to check for legal times. 1194 * mLegalKeys represents the keys that can be typed to get to the node. 1195 * mChildren are the children that can be reached from this node. 1196 */ 1197 private class Node { 1198 private int[] mLegalKeys; 1199 private ArrayList<Node> mChildren; 1200 1201 public Node(int... legalKeys) { 1202 mLegalKeys = legalKeys; 1203 mChildren = new ArrayList<Node>(); 1204 } 1205 1206 public void addChild(Node child) { 1207 mChildren.add(child); 1208 } 1209 1210 public boolean containsKey(int key) { 1211 for (int i = 0; i < mLegalKeys.length; i++) { 1212 if (mLegalKeys[i] == key) { 1213 return true; 1214 } 1215 } 1216 return false; 1217 } 1218 1219 public Node canReach(int key) { 1220 if (mChildren == null) { 1221 return null; 1222 } 1223 for (Node child : mChildren) { 1224 if (child.containsKey(key)) { 1225 return child; 1226 } 1227 } 1228 return null; 1229 } 1230 } 1231 1232 private final View.OnClickListener mClickListener = new View.OnClickListener() { 1233 @Override 1234 public void onClick(View v) { 1235 1236 final int amOrPm; 1237 switch (v.getId()) { 1238 case R.id.am_label: 1239 setAmOrPm(AM); 1240 break; 1241 case R.id.pm_label: 1242 setAmOrPm(PM); 1243 break; 1244 case R.id.hours: 1245 setCurrentItemShowing(HOUR_INDEX, true, true); 1246 break; 1247 case R.id.minutes: 1248 setCurrentItemShowing(MINUTE_INDEX, true, true); 1249 break; 1250 default: 1251 // Failed to handle this click, don't vibrate. 1252 return; 1253 } 1254 1255 tryVibrate(); 1256 } 1257 }; 1258 1259 private final View.OnKeyListener mKeyListener = new View.OnKeyListener() { 1260 @Override 1261 public boolean onKey(View v, int keyCode, KeyEvent event) { 1262 if (event.getAction() == KeyEvent.ACTION_UP) { 1263 return processKeyUp(keyCode); 1264 } 1265 return false; 1266 } 1267 }; 1268 1269 private final View.OnFocusChangeListener mFocusListener = new View.OnFocusChangeListener() { 1270 @Override 1271 public void onFocusChange(View v, boolean hasFocus) { 1272 if (!hasFocus && mInKbMode && isTypedTimeFullyLegal()) { 1273 finishKbMode(); 1274 1275 if (mOnTimeChangedListener != null) { 1276 mOnTimeChangedListener.onTimeChanged(mDelegator, 1277 mRadialTimePickerView.getCurrentHour(), 1278 mRadialTimePickerView.getCurrentMinute()); 1279 } 1280 } 1281 } 1282 }; 1283} 1284