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