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