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