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