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