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