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 com.android.datetimepicker.time;
18
19import android.animation.AnimatorSet;
20import android.animation.ObjectAnimator;
21import android.annotation.SuppressLint;
22import android.content.Context;
23import android.content.res.Resources;
24import android.os.Bundle;
25import android.os.Handler;
26import android.text.format.DateUtils;
27import android.text.format.Time;
28import android.util.AttributeSet;
29import android.util.Log;
30import android.view.MotionEvent;
31import android.view.View;
32import android.view.View.OnTouchListener;
33import android.view.ViewConfiguration;
34import android.view.ViewGroup;
35import android.view.accessibility.AccessibilityEvent;
36import android.view.accessibility.AccessibilityManager;
37import android.view.accessibility.AccessibilityNodeInfo;
38import android.widget.FrameLayout;
39
40import com.android.datetimepicker.HapticFeedbackController;
41import com.android.datetimepicker.R;
42
43/**
44 * The primary layout to hold the circular picker, and the am/pm buttons. This view well measure
45 * itself to end up as a square. It also handles touches to be passed in to views that need to know
46 * when they'd been touched.
47 */
48public class RadialPickerLayout extends FrameLayout implements OnTouchListener {
49    private static final String TAG = "RadialPickerLayout";
50
51    private final int TOUCH_SLOP;
52    private final int TAP_TIMEOUT;
53
54    private static final int VISIBLE_DEGREES_STEP_SIZE = 30;
55    private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = VISIBLE_DEGREES_STEP_SIZE;
56    private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6;
57    private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX;
58    private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX;
59    private static final int AMPM_INDEX = TimePickerDialog.AMPM_INDEX;
60    private static final int ENABLE_PICKER_INDEX = TimePickerDialog.ENABLE_PICKER_INDEX;
61    private static final int AM = TimePickerDialog.AM;
62    private static final int PM = TimePickerDialog.PM;
63
64    private int mLastValueSelected;
65
66    private HapticFeedbackController mHapticFeedbackController;
67    private OnValueSelectedListener mListener;
68    private boolean mTimeInitialized;
69    private int mCurrentHoursOfDay;
70    private int mCurrentMinutes;
71    private boolean mIs24HourMode;
72    private boolean mHideAmPm;
73    private int mCurrentItemShowing;
74
75    private CircleView mCircleView;
76    private AmPmCirclesView mAmPmCirclesView;
77    private RadialTextsView mHourRadialTextsView;
78    private RadialTextsView mMinuteRadialTextsView;
79    private RadialSelectorView mHourRadialSelectorView;
80    private RadialSelectorView mMinuteRadialSelectorView;
81    private View mGrayBox;
82
83    private int[] mSnapPrefer30sMap;
84    private boolean mInputEnabled;
85    private int mIsTouchingAmOrPm = -1;
86    private boolean mDoingMove;
87    private boolean mDoingTouch;
88    private int mDownDegrees;
89    private float mDownX;
90    private float mDownY;
91    private AccessibilityManager mAccessibilityManager;
92
93    private AnimatorSet mTransition;
94    private Handler mHandler = new Handler();
95
96    public interface OnValueSelectedListener {
97        void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance);
98    }
99
100    public RadialPickerLayout(Context context, AttributeSet attrs) {
101        super(context, attrs);
102
103        setOnTouchListener(this);
104        ViewConfiguration vc = ViewConfiguration.get(context);
105        TOUCH_SLOP = vc.getScaledTouchSlop();
106        TAP_TIMEOUT = ViewConfiguration.getTapTimeout();
107        mDoingMove = false;
108
109        mCircleView = new CircleView(context);
110        addView(mCircleView);
111
112        mAmPmCirclesView = new AmPmCirclesView(context);
113        addView(mAmPmCirclesView);
114
115        mHourRadialTextsView = new RadialTextsView(context);
116        addView(mHourRadialTextsView);
117        mMinuteRadialTextsView = new RadialTextsView(context);
118        addView(mMinuteRadialTextsView);
119
120        mHourRadialSelectorView = new RadialSelectorView(context);
121        addView(mHourRadialSelectorView);
122        mMinuteRadialSelectorView = new RadialSelectorView(context);
123        addView(mMinuteRadialSelectorView);
124
125        // Prepare mapping to snap touchable degrees to selectable degrees.
126        preparePrefer30sMap();
127
128        mLastValueSelected = -1;
129
130        mInputEnabled = true;
131        mGrayBox = new View(context);
132        mGrayBox.setLayoutParams(new ViewGroup.LayoutParams(
133                ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
134        mGrayBox.setBackgroundColor(getResources().getColor(R.color.transparent_black));
135        mGrayBox.setVisibility(View.INVISIBLE);
136        addView(mGrayBox);
137
138        mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
139
140        mTimeInitialized = false;
141    }
142
143    /**
144     * Measure the view to end up as a square, based on the minimum of the height and width.
145     */
146    @Override
147    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
148        int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
149        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
150        int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
151        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
152        int minDimension = Math.min(measuredWidth, measuredHeight);
153
154        super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode),
155                MeasureSpec.makeMeasureSpec(minDimension, heightMode));
156    }
157
158    public void setOnValueSelectedListener(OnValueSelectedListener listener) {
159        mListener = listener;
160    }
161
162    /**
163     * Initialize the Layout with starting values.
164     * @param context
165     * @param initialHoursOfDay
166     * @param initialMinutes
167     * @param is24HourMode
168     */
169    public void initialize(Context context, HapticFeedbackController hapticFeedbackController,
170            int initialHoursOfDay, int initialMinutes, boolean is24HourMode) {
171        if (mTimeInitialized) {
172            Log.e(TAG, "Time has already been initialized.");
173            return;
174        }
175
176        mHapticFeedbackController = hapticFeedbackController;
177        mIs24HourMode = is24HourMode;
178        mHideAmPm = mAccessibilityManager.isTouchExplorationEnabled()? true : mIs24HourMode;
179
180        // Initialize the circle and AM/PM circles if applicable.
181        mCircleView.initialize(context, mHideAmPm);
182        mCircleView.invalidate();
183        if (!mHideAmPm) {
184            mAmPmCirclesView.initialize(context, initialHoursOfDay < 12? AM : PM);
185            mAmPmCirclesView.invalidate();
186        }
187
188        // Initialize the hours and minutes numbers.
189        Resources res = context.getResources();
190        int[] hours = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
191        int[] hours_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23};
192        int[] minutes = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55};
193        String[] hoursTexts = new String[12];
194        String[] innerHoursTexts = new String[12];
195        String[] minutesTexts = new String[12];
196        for (int i = 0; i < 12; i++) {
197            hoursTexts[i] = is24HourMode?
198                    String.format("%02d", hours_24[i]) : String.format("%d", hours[i]);
199            innerHoursTexts[i] = String.format("%d", hours[i]);
200            minutesTexts[i] = String.format("%02d", minutes[i]);
201        }
202        mHourRadialTextsView.initialize(res,
203                hoursTexts, (is24HourMode? innerHoursTexts : null), mHideAmPm, true);
204        mHourRadialTextsView.invalidate();
205        mMinuteRadialTextsView.initialize(res, minutesTexts, null, mHideAmPm, false);
206        mMinuteRadialTextsView.invalidate();
207
208        // Initialize the currently-selected hour and minute.
209        setValueForItem(HOUR_INDEX, initialHoursOfDay);
210        setValueForItem(MINUTE_INDEX, initialMinutes);
211        int hourDegrees = (initialHoursOfDay % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
212        mHourRadialSelectorView.initialize(context, mHideAmPm, is24HourMode, true,
213                hourDegrees, isHourInnerCircle(initialHoursOfDay));
214        int minuteDegrees = initialMinutes * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
215        mMinuteRadialSelectorView.initialize(context, mHideAmPm, false, false,
216                minuteDegrees, false);
217
218        mTimeInitialized = true;
219    }
220
221    /* package */ void setTheme(Context context, boolean themeDark) {
222        mCircleView.setTheme(context, themeDark);
223        mAmPmCirclesView.setTheme(context, themeDark);
224        mHourRadialTextsView.setTheme(context, themeDark);
225        mMinuteRadialTextsView.setTheme(context, themeDark);
226        mHourRadialSelectorView.setTheme(context, themeDark);
227        mMinuteRadialSelectorView.setTheme(context, themeDark);
228   }
229
230    public void setTime(int hours, int minutes) {
231        setItem(HOUR_INDEX, hours);
232        setItem(MINUTE_INDEX, minutes);
233    }
234
235    /**
236     * Set either the hour or the minute. Will set the internal value, and set the selection.
237     */
238    private void setItem(int index, int value) {
239        if (index == HOUR_INDEX) {
240            setValueForItem(HOUR_INDEX, value);
241            int hourDegrees = (value % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
242            mHourRadialSelectorView.setSelection(hourDegrees, isHourInnerCircle(value), false);
243            mHourRadialSelectorView.invalidate();
244        } else if (index == MINUTE_INDEX) {
245            setValueForItem(MINUTE_INDEX, value);
246            int minuteDegrees = value * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
247            mMinuteRadialSelectorView.setSelection(minuteDegrees, false, false);
248            mMinuteRadialSelectorView.invalidate();
249        }
250    }
251
252    /**
253     * Check if a given hour appears in the outer circle or the inner circle
254     * @return true if the hour is in the inner circle, false if it's in the outer circle.
255     */
256    private boolean isHourInnerCircle(int hourOfDay) {
257        // We'll have the 00 hours on the outside circle.
258        return mIs24HourMode && (hourOfDay <= 12 && hourOfDay != 0);
259    }
260
261    public int getHours() {
262        return mCurrentHoursOfDay;
263    }
264
265    public int getMinutes() {
266        return mCurrentMinutes;
267    }
268
269    /**
270     * If the hours are showing, return the current hour. If the minutes are showing, return the
271     * current minute.
272     */
273    private int getCurrentlyShowingValue() {
274        int currentIndex = getCurrentItemShowing();
275        if (currentIndex == HOUR_INDEX) {
276            return mCurrentHoursOfDay;
277        } else if (currentIndex == MINUTE_INDEX) {
278            return mCurrentMinutes;
279        } else {
280            return -1;
281        }
282    }
283
284    public int getIsCurrentlyAmOrPm() {
285        if (mCurrentHoursOfDay < 12) {
286            return AM;
287        } else if (mCurrentHoursOfDay < 24) {
288            return PM;
289        }
290        return -1;
291    }
292
293    /**
294     * Set the internal value for the hour, minute, or AM/PM.
295     */
296    private void setValueForItem(int index, int value) {
297        if (index == HOUR_INDEX) {
298            mCurrentHoursOfDay = value;
299        } else if (index == MINUTE_INDEX){
300            mCurrentMinutes = value;
301        } else if (index == AMPM_INDEX) {
302            if (value == AM) {
303                mCurrentHoursOfDay = mCurrentHoursOfDay % 12;
304            } else if (value == PM) {
305                mCurrentHoursOfDay = (mCurrentHoursOfDay % 12) + 12;
306            }
307        }
308    }
309
310    /**
311     * Set the internal value as either AM or PM, and update the AM/PM circle displays.
312     * @param amOrPm
313     */
314    public void setAmOrPm(int amOrPm) {
315        mAmPmCirclesView.setAmOrPm(amOrPm);
316        mAmPmCirclesView.invalidate();
317        setValueForItem(AMPM_INDEX, amOrPm);
318    }
319
320    /**
321     * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger
322     * selectable area to each of the 12 visible values, such that the ratio of space apportioned
323     * to a visible value : space apportioned to a non-visible value will be 14 : 4.
324     * E.g. the output of 30 degrees should have a higher range of input associated with it than
325     * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock
326     * circle (5 on the minutes, 1 or 13 on the hours).
327     */
328    private void preparePrefer30sMap() {
329        // We'll split up the visible output and the non-visible output such that each visible
330        // output will correspond to a range of 14 associated input degrees, and each non-visible
331        // output will correspond to a range of 4 associate input degrees, so visible numbers
332        // are more than 3 times easier to get than non-visible numbers:
333        // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc.
334        //
335        // If an output of 30 degrees should correspond to a range of 14 associated degrees, then
336        // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should
337        // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you
338        // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this
339        // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the
340        // ability to aggressively prefer the visible values by a factor of more than 3:1, which
341        // greatly contributes to the selectability of these values.
342
343        // Our input will be 0 through 360.
344        mSnapPrefer30sMap = new int[361];
345
346        // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}.
347        int snappedOutputDegrees = 0;
348        // Count of how many inputs we've designated to the specified output.
349        int count = 1;
350        // How many input we expect for a specified output. This will be 14 for output divisible
351        // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so
352        // the caller can decide which they need.
353        int expectedCount = 8;
354        // Iterate through the input.
355        for (int degrees = 0; degrees < 361; degrees++) {
356            // Save the input-output mapping.
357            mSnapPrefer30sMap[degrees] = snappedOutputDegrees;
358            // If this is the last input for the specified output, calculate the next output and
359            // the next expected count.
360            if (count == expectedCount) {
361                snappedOutputDegrees += 6;
362                if (snappedOutputDegrees == 360) {
363                    expectedCount = 7;
364                } else if (snappedOutputDegrees % 30 == 0) {
365                    expectedCount = 14;
366                } else {
367                    expectedCount = 4;
368                }
369                count = 1;
370            } else {
371                count++;
372            }
373        }
374    }
375
376    /**
377     * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees,
378     * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be
379     * weighted heavier than the degrees corresponding to non-visible numbers.
380     * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the
381     * mapping.
382     */
383    private int snapPrefer30s(int degrees) {
384        if (mSnapPrefer30sMap == null) {
385            return -1;
386        }
387        return mSnapPrefer30sMap[degrees];
388    }
389
390    /**
391     * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all
392     * multiples of 30), where the input will be "snapped" to the closest visible degrees.
393     * @param degrees The input degrees
394     * @param forceAboveOrBelow The output may be forced to either the higher or lower step, or may
395     * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force
396     * strictly lower, and 0 to snap to the closer one.
397     * @return output degrees, will be a multiple of 30
398     */
399    private static int snapOnly30s(int degrees, int forceHigherOrLower) {
400        int stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
401        int floor = (degrees / stepSize) * stepSize;
402        int ceiling = floor + stepSize;
403        if (forceHigherOrLower == 1) {
404            degrees = ceiling;
405        } else if (forceHigherOrLower == -1) {
406            if (degrees == floor) {
407                floor -= stepSize;
408            }
409            degrees = floor;
410        } else {
411            if ((degrees - floor) < (ceiling - degrees)) {
412                degrees = floor;
413            } else {
414                degrees = ceiling;
415            }
416        }
417        return degrees;
418    }
419
420    /**
421     * For the currently showing view (either hours or minutes), re-calculate the position for the
422     * selector, and redraw it at that position. The input degrees will be snapped to a selectable
423     * value.
424     * @param degrees Degrees which should be selected.
425     * @param isInnerCircle Whether the selection should be in the inner circle; will be ignored
426     * if there is no inner circle.
427     * @param forceToVisibleValue Even if the currently-showing circle allows for fine-grained
428     * selection (i.e. minutes), force the selection to one of the visibly-showing values.
429     * @param forceDrawDot The dot in the circle will generally only be shown when the selection
430     * is on non-visible values, but use this to force the dot to be shown.
431     * @return The value that was selected, i.e. 0-23 for hours, 0-59 for minutes.
432     */
433    private int reselectSelector(int degrees, boolean isInnerCircle,
434            boolean forceToVisibleValue, boolean forceDrawDot) {
435        if (degrees == -1) {
436            return -1;
437        }
438        int currentShowing = getCurrentItemShowing();
439
440        int stepSize;
441        boolean allowFineGrained = !forceToVisibleValue && (currentShowing == MINUTE_INDEX);
442        if (allowFineGrained) {
443            degrees = snapPrefer30s(degrees);
444        } else {
445            degrees = snapOnly30s(degrees, 0);
446        }
447
448        RadialSelectorView radialSelectorView;
449        if (currentShowing == HOUR_INDEX) {
450            radialSelectorView = mHourRadialSelectorView;
451            stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
452        } else {
453            radialSelectorView = mMinuteRadialSelectorView;
454            stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
455        }
456        radialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot);
457        radialSelectorView.invalidate();
458
459
460        if (currentShowing == HOUR_INDEX) {
461            if (mIs24HourMode) {
462                if (degrees == 0 && isInnerCircle) {
463                    degrees = 360;
464                } else if (degrees == 360 && !isInnerCircle) {
465                    degrees = 0;
466                }
467            } else if (degrees == 0) {
468                degrees = 360;
469            }
470        } else if (degrees == 360 && currentShowing == MINUTE_INDEX) {
471            degrees = 0;
472        }
473
474        int value = degrees / stepSize;
475        if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) {
476            value += 12;
477        }
478        return value;
479    }
480
481    /**
482     * Calculate the degrees within the circle that corresponds to the specified coordinates, if
483     * the coordinates are within the range that will trigger a selection.
484     * @param pointX The x coordinate.
485     * @param pointY The y coordinate.
486     * @param forceLegal Force the selection to be legal, regardless of how far the coordinates are
487     * from the actual numbers.
488     * @param isInnerCircle If the selection may be in the inner circle, pass in a size-1 boolean
489     * array here, inside which the value will be true if the selection is in the inner circle,
490     * and false if in the outer circle.
491     * @return Degrees from 0 to 360, if the selection was within the legal range. -1 if not.
492     */
493    private int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal,
494            final Boolean[] isInnerCircle) {
495        int currentItem = getCurrentItemShowing();
496        if (currentItem == HOUR_INDEX) {
497            return mHourRadialSelectorView.getDegreesFromCoords(
498                    pointX, pointY, forceLegal, isInnerCircle);
499        } else if (currentItem == MINUTE_INDEX) {
500            return mMinuteRadialSelectorView.getDegreesFromCoords(
501                    pointX, pointY, forceLegal, isInnerCircle);
502        } else {
503            return -1;
504        }
505    }
506
507    /**
508     * Get the item (hours or minutes) that is currently showing.
509     */
510    public int getCurrentItemShowing() {
511        if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX) {
512            Log.e(TAG, "Current item showing was unfortunately set to "+mCurrentItemShowing);
513            return -1;
514        }
515        return mCurrentItemShowing;
516    }
517
518    /**
519     * Set either minutes or hours as showing.
520     * @param animate True to animate the transition, false to show with no animation.
521     */
522    public void setCurrentItemShowing(int index, boolean animate) {
523        if (index != HOUR_INDEX && index != MINUTE_INDEX) {
524            Log.e(TAG, "TimePicker does not support view at index "+index);
525            return;
526        }
527
528        int lastIndex = getCurrentItemShowing();
529        mCurrentItemShowing = index;
530
531        if (animate && (index != lastIndex)) {
532            ObjectAnimator[] anims = new ObjectAnimator[4];
533            if (index == MINUTE_INDEX) {
534                anims[0] = mHourRadialTextsView.getDisappearAnimator();
535                anims[1] = mHourRadialSelectorView.getDisappearAnimator();
536                anims[2] = mMinuteRadialTextsView.getReappearAnimator();
537                anims[3] = mMinuteRadialSelectorView.getReappearAnimator();
538            } else if (index == HOUR_INDEX){
539                anims[0] = mHourRadialTextsView.getReappearAnimator();
540                anims[1] = mHourRadialSelectorView.getReappearAnimator();
541                anims[2] = mMinuteRadialTextsView.getDisappearAnimator();
542                anims[3] = mMinuteRadialSelectorView.getDisappearAnimator();
543            }
544
545            if (mTransition != null && mTransition.isRunning()) {
546                mTransition.end();
547            }
548            mTransition = new AnimatorSet();
549            mTransition.playTogether(anims);
550            mTransition.start();
551        } else {
552            int hourAlpha = (index == HOUR_INDEX) ? 255 : 0;
553            int minuteAlpha = (index == MINUTE_INDEX) ? 255 : 0;
554            mHourRadialTextsView.setAlpha(hourAlpha);
555            mHourRadialSelectorView.setAlpha(hourAlpha);
556            mMinuteRadialTextsView.setAlpha(minuteAlpha);
557            mMinuteRadialSelectorView.setAlpha(minuteAlpha);
558        }
559
560    }
561
562    @Override
563    public boolean onTouch(View v, MotionEvent event) {
564        final float eventX = event.getX();
565        final float eventY = event.getY();
566        int degrees;
567        int value;
568        final Boolean[] isInnerCircle = new Boolean[1];
569        isInnerCircle[0] = false;
570
571        switch(event.getAction()) {
572            case MotionEvent.ACTION_DOWN:
573                if (!mInputEnabled) {
574                    return true;
575                }
576
577                mDownX = eventX;
578                mDownY = eventY;
579
580                mLastValueSelected = -1;
581                mDoingMove = false;
582                mDoingTouch = true;
583                // If we're showing the AM/PM, check to see if the user is touching it.
584                if (!mHideAmPm) {
585                    mIsTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
586                } else {
587                    mIsTouchingAmOrPm = -1;
588                }
589                if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
590                    // If the touch is on AM or PM, set it as "touched" after the TAP_TIMEOUT
591                    // in case the user moves their finger quickly.
592                    mHapticFeedbackController.tryVibrate();
593                    mDownDegrees = -1;
594                    mHandler.postDelayed(new Runnable() {
595                        @Override
596                        public void run() {
597                            mAmPmCirclesView.setAmOrPmPressed(mIsTouchingAmOrPm);
598                            mAmPmCirclesView.invalidate();
599                        }
600                    }, TAP_TIMEOUT);
601                } else {
602                    // If we're in accessibility mode, force the touch to be legal. Otherwise,
603                    // it will only register within the given touch target zone.
604                    boolean forceLegal = mAccessibilityManager.isTouchExplorationEnabled();
605                    // Calculate the degrees that is currently being touched.
606                    mDownDegrees = getDegreesFromCoords(eventX, eventY, forceLegal, isInnerCircle);
607                    if (mDownDegrees != -1) {
608                        // If it's a legal touch, set that number as "selected" after the
609                        // TAP_TIMEOUT in case the user moves their finger quickly.
610                        mHapticFeedbackController.tryVibrate();
611                        mHandler.postDelayed(new Runnable() {
612                            @Override
613                            public void run() {
614                                mDoingMove = true;
615                                int value = reselectSelector(mDownDegrees, isInnerCircle[0],
616                                        false, true);
617                                mLastValueSelected = value;
618                                mListener.onValueSelected(getCurrentItemShowing(), value, false);
619                            }
620                        }, TAP_TIMEOUT);
621                    }
622                }
623                return true;
624            case MotionEvent.ACTION_MOVE:
625                if (!mInputEnabled) {
626                    // We shouldn't be in this state, because input is disabled.
627                    Log.e(TAG, "Input was disabled, but received ACTION_MOVE.");
628                    return true;
629                }
630
631                float dY = Math.abs(eventY - mDownY);
632                float dX = Math.abs(eventX - mDownX);
633
634                if (!mDoingMove && dX <= TOUCH_SLOP && dY <= TOUCH_SLOP) {
635                    // Hasn't registered down yet, just slight, accidental movement of finger.
636                    break;
637                }
638
639                // If we're in the middle of touching down on AM or PM, check if we still are.
640                // If so, no-op. If not, remove its pressed state. Either way, no need to check
641                // for touches on the other circle.
642                if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
643                    mHandler.removeCallbacksAndMessages(null);
644                    int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
645                    if (isTouchingAmOrPm != mIsTouchingAmOrPm) {
646                        mAmPmCirclesView.setAmOrPmPressed(-1);
647                        mAmPmCirclesView.invalidate();
648                        mIsTouchingAmOrPm = -1;
649                    }
650                    break;
651                }
652
653                if (mDownDegrees == -1) {
654                    // Original down was illegal, so no movement will register.
655                    break;
656                }
657
658                // We're doing a move along the circle, so move the selection as appropriate.
659                mDoingMove = true;
660                mHandler.removeCallbacksAndMessages(null);
661                degrees = getDegreesFromCoords(eventX, eventY, true, isInnerCircle);
662                if (degrees != -1) {
663                    value = reselectSelector(degrees, isInnerCircle[0], false, true);
664                    if (value != mLastValueSelected) {
665                        mHapticFeedbackController.tryVibrate();
666                        mLastValueSelected = value;
667                        mListener.onValueSelected(getCurrentItemShowing(), value, false);
668                    }
669                }
670                return true;
671            case MotionEvent.ACTION_UP:
672                if (!mInputEnabled) {
673                    // If our touch input was disabled, tell the listener to re-enable us.
674                    Log.d(TAG, "Input was disabled, but received ACTION_UP.");
675                    mListener.onValueSelected(ENABLE_PICKER_INDEX, 1, false);
676                    return true;
677                }
678
679                mHandler.removeCallbacksAndMessages(null);
680                mDoingTouch = false;
681
682                // If we're touching AM or PM, set it as selected, and tell the listener.
683                if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
684                    int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
685                    mAmPmCirclesView.setAmOrPmPressed(-1);
686                    mAmPmCirclesView.invalidate();
687
688                    if (isTouchingAmOrPm == mIsTouchingAmOrPm) {
689                        mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm);
690                        if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) {
691                            mListener.onValueSelected(AMPM_INDEX, mIsTouchingAmOrPm, false);
692                            setValueForItem(AMPM_INDEX, isTouchingAmOrPm);
693                        }
694                    }
695                    mIsTouchingAmOrPm = -1;
696                    break;
697                }
698
699                // If we have a legal degrees selected, set the value and tell the listener.
700                if (mDownDegrees != -1) {
701                    degrees = getDegreesFromCoords(eventX, eventY, mDoingMove, isInnerCircle);
702                    if (degrees != -1) {
703                        value = reselectSelector(degrees, isInnerCircle[0], !mDoingMove, false);
704                        if (getCurrentItemShowing() == HOUR_INDEX && !mIs24HourMode) {
705                            int amOrPm = getIsCurrentlyAmOrPm();
706                            if (amOrPm == AM && value == 12) {
707                                value = 0;
708                            } else if (amOrPm == PM && value != 12) {
709                                value += 12;
710                            }
711                        }
712                        setValueForItem(getCurrentItemShowing(), value);
713                        mListener.onValueSelected(getCurrentItemShowing(), value, true);
714                    }
715                }
716                mDoingMove = false;
717                return true;
718            default:
719                break;
720        }
721        return false;
722    }
723
724    /**
725     * Set touch input as enabled or disabled, for use with keyboard mode.
726     */
727    public boolean trySettingInputEnabled(boolean inputEnabled) {
728        if (mDoingTouch && !inputEnabled) {
729            // If we're trying to disable input, but we're in the middle of a touch event,
730            // we'll allow the touch event to continue before disabling input.
731            return false;
732        }
733        mInputEnabled = inputEnabled;
734        mGrayBox.setVisibility(inputEnabled? View.INVISIBLE : View.VISIBLE);
735        return true;
736    }
737
738    /**
739     * Necessary for accessibility, to ensure we support "scrolling" forward and backward
740     * in the circle.
741     */
742    @Override
743    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
744      super.onInitializeAccessibilityNodeInfo(info);
745      info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
746      info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
747    }
748
749    /**
750     * Announce the currently-selected time when launched.
751     */
752    @Override
753    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
754        if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
755            // Clear the event's current text so that only the current time will be spoken.
756            event.getText().clear();
757            Time time = new Time();
758            time.hour = getHours();
759            time.minute = getMinutes();
760            long millis = time.normalize(true);
761            int flags = DateUtils.FORMAT_SHOW_TIME;
762            if (mIs24HourMode) {
763                flags |= DateUtils.FORMAT_24HOUR;
764            }
765            String timeString = DateUtils.formatDateTime(getContext(), millis, flags);
766            event.getText().add(timeString);
767            return true;
768        }
769        return super.dispatchPopulateAccessibilityEvent(event);
770    }
771
772    /**
773     * When scroll forward/backward events are received, jump the time to the higher/lower
774     * discrete, visible value on the circle.
775     */
776    @SuppressLint("NewApi")
777    @Override
778    public boolean performAccessibilityAction(int action, Bundle arguments) {
779        if (super.performAccessibilityAction(action, arguments)) {
780            return true;
781        }
782
783        int changeMultiplier = 0;
784        if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
785            changeMultiplier = 1;
786        } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
787            changeMultiplier = -1;
788        }
789        if (changeMultiplier != 0) {
790            int value = getCurrentlyShowingValue();
791            int stepSize = 0;
792            int currentItemShowing = getCurrentItemShowing();
793            if (currentItemShowing == HOUR_INDEX) {
794                stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
795                value %= 12;
796            } else if (currentItemShowing == MINUTE_INDEX) {
797                stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
798            }
799
800            int degrees = value * stepSize;
801            degrees = snapOnly30s(degrees, changeMultiplier);
802            value = degrees / stepSize;
803            int maxValue = 0;
804            int minValue = 0;
805            if (currentItemShowing == HOUR_INDEX) {
806                if (mIs24HourMode) {
807                    maxValue = 23;
808                } else {
809                    maxValue = 12;
810                    minValue = 1;
811                }
812            } else {
813                maxValue = 55;
814            }
815            if (value > maxValue) {
816                // If we scrolled forward past the highest number, wrap around to the lowest.
817                value = minValue;
818            } else if (value < minValue) {
819                // If we scrolled backward past the lowest number, wrap around to the highest.
820                value = maxValue;
821            }
822            setItem(currentItemShowing, value);
823            mListener.onValueSelected(currentItemShowing, value, false);
824            return true;
825        }
826
827        return false;
828    }
829}
830