1/*
2 * Copyright (C) 2007 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.widget;
18
19import android.annotation.Widget;
20import android.content.Context;
21import android.content.res.Configuration;
22import android.content.res.TypedArray;
23import android.os.Parcel;
24import android.os.Parcelable;
25import android.text.format.DateUtils;
26import android.util.AttributeSet;
27import android.view.LayoutInflater;
28import android.view.View;
29import android.view.accessibility.AccessibilityEvent;
30import android.view.accessibility.AccessibilityNodeInfo;
31import android.view.inputmethod.EditorInfo;
32import android.view.inputmethod.InputMethodManager;
33import android.widget.NumberPicker.OnValueChangeListener;
34
35import com.android.internal.R;
36
37import java.text.DateFormatSymbols;
38import java.util.Calendar;
39import java.util.Locale;
40
41/**
42 * A view for selecting the time of day, in either 24 hour or AM/PM mode. The
43 * hour, each minute digit, and AM/PM (if applicable) can be conrolled by
44 * vertical spinners. The hour can be entered by keyboard input. Entering in two
45 * digit hours can be accomplished by hitting two digits within a timeout of
46 * about a second (e.g. '1' then '2' to select 12). The minutes can be entered
47 * by entering single digits. Under AM/PM mode, the user can hit 'a', 'A", 'p'
48 * or 'P' to pick. For a dialog using this view, see
49 * {@link android.app.TimePickerDialog}.
50 *<p>
51 * See the <a href="{@docRoot}guide/topics/ui/controls/pickers.html">Pickers</a>
52 * guide.
53 * </p>
54 */
55@Widget
56public class TimePicker extends FrameLayout {
57
58    private static final boolean DEFAULT_ENABLED_STATE = true;
59
60    private static final int HOURS_IN_HALF_DAY = 12;
61
62    /**
63     * A no-op callback used in the constructor to avoid null checks later in
64     * the code.
65     */
66    private static final OnTimeChangedListener NO_OP_CHANGE_LISTENER = new OnTimeChangedListener() {
67        public void onTimeChanged(TimePicker view, int hourOfDay, int minute) {
68        }
69    };
70
71    // state
72    private boolean mIs24HourView;
73
74    private boolean mIsAm;
75
76    // ui components
77    private final NumberPicker mHourSpinner;
78
79    private final NumberPicker mMinuteSpinner;
80
81    private final NumberPicker mAmPmSpinner;
82
83    private final EditText mHourSpinnerInput;
84
85    private final EditText mMinuteSpinnerInput;
86
87    private final EditText mAmPmSpinnerInput;
88
89    private final TextView mDivider;
90
91    // Note that the legacy implementation of the TimePicker is
92    // using a button for toggling between AM/PM while the new
93    // version uses a NumberPicker spinner. Therefore the code
94    // accommodates these two cases to be backwards compatible.
95    private final Button mAmPmButton;
96
97    private final String[] mAmPmStrings;
98
99    private boolean mIsEnabled = DEFAULT_ENABLED_STATE;
100
101    // callbacks
102    private OnTimeChangedListener mOnTimeChangedListener;
103
104    private Calendar mTempCalendar;
105
106    private Locale mCurrentLocale;
107
108    /**
109     * The callback interface used to indicate the time has been adjusted.
110     */
111    public interface OnTimeChangedListener {
112
113        /**
114         * @param view The view associated with this listener.
115         * @param hourOfDay The current hour.
116         * @param minute The current minute.
117         */
118        void onTimeChanged(TimePicker view, int hourOfDay, int minute);
119    }
120
121    public TimePicker(Context context) {
122        this(context, null);
123    }
124
125    public TimePicker(Context context, AttributeSet attrs) {
126        this(context, attrs, R.attr.timePickerStyle);
127    }
128
129    public TimePicker(Context context, AttributeSet attrs, int defStyle) {
130        super(context, attrs, defStyle);
131
132        // initialization based on locale
133        setCurrentLocale(Locale.getDefault());
134
135        // process style attributes
136        TypedArray attributesArray = context.obtainStyledAttributes(
137                attrs, R.styleable.TimePicker, defStyle, 0);
138        int layoutResourceId = attributesArray.getResourceId(
139                R.styleable.TimePicker_internalLayout, R.layout.time_picker);
140        attributesArray.recycle();
141
142        LayoutInflater inflater = (LayoutInflater) context.getSystemService(
143                Context.LAYOUT_INFLATER_SERVICE);
144        inflater.inflate(layoutResourceId, this, true);
145
146        // hour
147        mHourSpinner = (NumberPicker) findViewById(R.id.hour);
148        mHourSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
149            public void onValueChange(NumberPicker spinner, int oldVal, int newVal) {
150                updateInputState();
151                if (!is24HourView()) {
152                    if ((oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY)
153                            || (oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1)) {
154                        mIsAm = !mIsAm;
155                        updateAmPmControl();
156                    }
157                }
158                onTimeChanged();
159            }
160        });
161        mHourSpinnerInput = (EditText) mHourSpinner.findViewById(R.id.numberpicker_input);
162        mHourSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
163
164        // divider (only for the new widget style)
165        mDivider = (TextView) findViewById(R.id.divider);
166        if (mDivider != null) {
167            mDivider.setText(R.string.time_picker_separator);
168        }
169
170        // minute
171        mMinuteSpinner = (NumberPicker) findViewById(R.id.minute);
172        mMinuteSpinner.setMinValue(0);
173        mMinuteSpinner.setMaxValue(59);
174        mMinuteSpinner.setOnLongPressUpdateInterval(100);
175        mMinuteSpinner.setFormatter(NumberPicker.getTwoDigitFormatter());
176        mMinuteSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
177            public void onValueChange(NumberPicker spinner, int oldVal, int newVal) {
178                updateInputState();
179                int minValue = mMinuteSpinner.getMinValue();
180                int maxValue = mMinuteSpinner.getMaxValue();
181                if (oldVal == maxValue && newVal == minValue) {
182                    int newHour = mHourSpinner.getValue() + 1;
183                    if (!is24HourView() && newHour == HOURS_IN_HALF_DAY) {
184                        mIsAm = !mIsAm;
185                        updateAmPmControl();
186                    }
187                    mHourSpinner.setValue(newHour);
188                } else if (oldVal == minValue && newVal == maxValue) {
189                    int newHour = mHourSpinner.getValue() - 1;
190                    if (!is24HourView() && newHour == HOURS_IN_HALF_DAY - 1) {
191                        mIsAm = !mIsAm;
192                        updateAmPmControl();
193                    }
194                    mHourSpinner.setValue(newHour);
195                }
196                onTimeChanged();
197            }
198        });
199        mMinuteSpinnerInput = (EditText) mMinuteSpinner.findViewById(R.id.numberpicker_input);
200        mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
201
202        /* Get the localized am/pm strings and use them in the spinner */
203        mAmPmStrings = new DateFormatSymbols().getAmPmStrings();
204
205        // am/pm
206        View amPmView = findViewById(R.id.amPm);
207        if (amPmView instanceof Button) {
208            mAmPmSpinner = null;
209            mAmPmSpinnerInput = null;
210            mAmPmButton = (Button) amPmView;
211            mAmPmButton.setOnClickListener(new OnClickListener() {
212                public void onClick(View button) {
213                    button.requestFocus();
214                    mIsAm = !mIsAm;
215                    updateAmPmControl();
216                    onTimeChanged();
217                }
218            });
219        } else {
220            mAmPmButton = null;
221            mAmPmSpinner = (NumberPicker) amPmView;
222            mAmPmSpinner.setMinValue(0);
223            mAmPmSpinner.setMaxValue(1);
224            mAmPmSpinner.setDisplayedValues(mAmPmStrings);
225            mAmPmSpinner.setOnValueChangedListener(new OnValueChangeListener() {
226                public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
227                    updateInputState();
228                    picker.requestFocus();
229                    mIsAm = !mIsAm;
230                    updateAmPmControl();
231                    onTimeChanged();
232                }
233            });
234            mAmPmSpinnerInput = (EditText) mAmPmSpinner.findViewById(R.id.numberpicker_input);
235            mAmPmSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
236        }
237
238        // update controls to initial state
239        updateHourControl();
240        updateAmPmControl();
241
242        setOnTimeChangedListener(NO_OP_CHANGE_LISTENER);
243
244        // set to current time
245        setCurrentHour(mTempCalendar.get(Calendar.HOUR_OF_DAY));
246        setCurrentMinute(mTempCalendar.get(Calendar.MINUTE));
247
248        if (!isEnabled()) {
249            setEnabled(false);
250        }
251
252        // set the content descriptions
253        setContentDescriptions();
254
255        // If not explicitly specified this view is important for accessibility.
256        if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
257            setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
258        }
259    }
260
261    @Override
262    public void setEnabled(boolean enabled) {
263        if (mIsEnabled == enabled) {
264            return;
265        }
266        super.setEnabled(enabled);
267        mMinuteSpinner.setEnabled(enabled);
268        if (mDivider != null) {
269            mDivider.setEnabled(enabled);
270        }
271        mHourSpinner.setEnabled(enabled);
272        if (mAmPmSpinner != null) {
273            mAmPmSpinner.setEnabled(enabled);
274        } else {
275            mAmPmButton.setEnabled(enabled);
276        }
277        mIsEnabled = enabled;
278    }
279
280    @Override
281    public boolean isEnabled() {
282        return mIsEnabled;
283    }
284
285    @Override
286    protected void onConfigurationChanged(Configuration newConfig) {
287        super.onConfigurationChanged(newConfig);
288        setCurrentLocale(newConfig.locale);
289    }
290
291    /**
292     * Sets the current locale.
293     *
294     * @param locale The current locale.
295     */
296    private void setCurrentLocale(Locale locale) {
297        if (locale.equals(mCurrentLocale)) {
298            return;
299        }
300        mCurrentLocale = locale;
301        mTempCalendar = Calendar.getInstance(locale);
302    }
303
304    /**
305     * Used to save / restore state of time picker
306     */
307    private static class SavedState extends BaseSavedState {
308
309        private final int mHour;
310
311        private final int mMinute;
312
313        private SavedState(Parcelable superState, int hour, int minute) {
314            super(superState);
315            mHour = hour;
316            mMinute = minute;
317        }
318
319        private SavedState(Parcel in) {
320            super(in);
321            mHour = in.readInt();
322            mMinute = in.readInt();
323        }
324
325        public int getHour() {
326            return mHour;
327        }
328
329        public int getMinute() {
330            return mMinute;
331        }
332
333        @Override
334        public void writeToParcel(Parcel dest, int flags) {
335            super.writeToParcel(dest, flags);
336            dest.writeInt(mHour);
337            dest.writeInt(mMinute);
338        }
339
340        @SuppressWarnings({"unused", "hiding"})
341        public static final Parcelable.Creator<SavedState> CREATOR = new Creator<SavedState>() {
342            public SavedState createFromParcel(Parcel in) {
343                return new SavedState(in);
344            }
345
346            public SavedState[] newArray(int size) {
347                return new SavedState[size];
348            }
349        };
350    }
351
352    @Override
353    protected Parcelable onSaveInstanceState() {
354        Parcelable superState = super.onSaveInstanceState();
355        return new SavedState(superState, getCurrentHour(), getCurrentMinute());
356    }
357
358    @Override
359    protected void onRestoreInstanceState(Parcelable state) {
360        SavedState ss = (SavedState) state;
361        super.onRestoreInstanceState(ss.getSuperState());
362        setCurrentHour(ss.getHour());
363        setCurrentMinute(ss.getMinute());
364    }
365
366    /**
367     * Set the callback that indicates the time has been adjusted by the user.
368     *
369     * @param onTimeChangedListener the callback, should not be null.
370     */
371    public void setOnTimeChangedListener(OnTimeChangedListener onTimeChangedListener) {
372        mOnTimeChangedListener = onTimeChangedListener;
373    }
374
375    /**
376     * @return The current hour in the range (0-23).
377     */
378    public Integer getCurrentHour() {
379        int currentHour = mHourSpinner.getValue();
380        if (is24HourView()) {
381            return currentHour;
382        } else if (mIsAm) {
383            return currentHour % HOURS_IN_HALF_DAY;
384        } else {
385            return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY;
386        }
387    }
388
389    /**
390     * Set the current hour.
391     */
392    public void setCurrentHour(Integer currentHour) {
393        // why was Integer used in the first place?
394        if (currentHour == null || currentHour == getCurrentHour()) {
395            return;
396        }
397        if (!is24HourView()) {
398            // convert [0,23] ordinal to wall clock display
399            if (currentHour >= HOURS_IN_HALF_DAY) {
400                mIsAm = false;
401                if (currentHour > HOURS_IN_HALF_DAY) {
402                    currentHour = currentHour - HOURS_IN_HALF_DAY;
403                }
404            } else {
405                mIsAm = true;
406                if (currentHour == 0) {
407                    currentHour = HOURS_IN_HALF_DAY;
408                }
409            }
410            updateAmPmControl();
411        }
412        mHourSpinner.setValue(currentHour);
413        onTimeChanged();
414    }
415
416    /**
417     * Set whether in 24 hour or AM/PM mode.
418     *
419     * @param is24HourView True = 24 hour mode. False = AM/PM.
420     */
421    public void setIs24HourView(Boolean is24HourView) {
422        if (mIs24HourView == is24HourView) {
423            return;
424        }
425        mIs24HourView = is24HourView;
426        // cache the current hour since spinner range changes
427        int currentHour = getCurrentHour();
428        updateHourControl();
429        // set value after spinner range is updated
430        setCurrentHour(currentHour);
431        updateAmPmControl();
432    }
433
434    /**
435     * @return true if this is in 24 hour view else false.
436     */
437    public boolean is24HourView() {
438        return mIs24HourView;
439    }
440
441    /**
442     * @return The current minute.
443     */
444    public Integer getCurrentMinute() {
445        return mMinuteSpinner.getValue();
446    }
447
448    /**
449     * Set the current minute (0-59).
450     */
451    public void setCurrentMinute(Integer currentMinute) {
452        if (currentMinute == getCurrentMinute()) {
453            return;
454        }
455        mMinuteSpinner.setValue(currentMinute);
456        onTimeChanged();
457    }
458
459    @Override
460    public int getBaseline() {
461        return mHourSpinner.getBaseline();
462    }
463
464    @Override
465    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
466        onPopulateAccessibilityEvent(event);
467        return true;
468    }
469
470    @Override
471    public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
472        super.onPopulateAccessibilityEvent(event);
473
474        int flags = DateUtils.FORMAT_SHOW_TIME;
475        if (mIs24HourView) {
476            flags |= DateUtils.FORMAT_24HOUR;
477        } else {
478            flags |= DateUtils.FORMAT_12HOUR;
479        }
480        mTempCalendar.set(Calendar.HOUR_OF_DAY, getCurrentHour());
481        mTempCalendar.set(Calendar.MINUTE, getCurrentMinute());
482        String selectedDateUtterance = DateUtils.formatDateTime(mContext,
483                mTempCalendar.getTimeInMillis(), flags);
484        event.getText().add(selectedDateUtterance);
485    }
486
487    @Override
488    public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
489        super.onInitializeAccessibilityEvent(event);
490        event.setClassName(TimePicker.class.getName());
491    }
492
493    @Override
494    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
495        super.onInitializeAccessibilityNodeInfo(info);
496        info.setClassName(TimePicker.class.getName());
497    }
498
499    private void updateHourControl() {
500        if (is24HourView()) {
501            mHourSpinner.setMinValue(0);
502            mHourSpinner.setMaxValue(23);
503            mHourSpinner.setFormatter(NumberPicker.getTwoDigitFormatter());
504        } else {
505            mHourSpinner.setMinValue(1);
506            mHourSpinner.setMaxValue(12);
507            mHourSpinner.setFormatter(null);
508        }
509    }
510
511    private void updateAmPmControl() {
512        if (is24HourView()) {
513            if (mAmPmSpinner != null) {
514                mAmPmSpinner.setVisibility(View.GONE);
515            } else {
516                mAmPmButton.setVisibility(View.GONE);
517            }
518        } else {
519            int index = mIsAm ? Calendar.AM : Calendar.PM;
520            if (mAmPmSpinner != null) {
521                mAmPmSpinner.setValue(index);
522                mAmPmSpinner.setVisibility(View.VISIBLE);
523            } else {
524                mAmPmButton.setText(mAmPmStrings[index]);
525                mAmPmButton.setVisibility(View.VISIBLE);
526            }
527        }
528        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
529    }
530
531    private void onTimeChanged() {
532        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
533        if (mOnTimeChangedListener != null) {
534            mOnTimeChangedListener.onTimeChanged(this, getCurrentHour(), getCurrentMinute());
535        }
536    }
537
538    private void setContentDescriptions() {
539        // Minute
540        trySetContentDescription(mMinuteSpinner, R.id.increment,
541                R.string.time_picker_increment_minute_button);
542        trySetContentDescription(mMinuteSpinner, R.id.decrement,
543                R.string.time_picker_decrement_minute_button);
544        // Hour
545        trySetContentDescription(mHourSpinner, R.id.increment,
546                R.string.time_picker_increment_hour_button);
547        trySetContentDescription(mHourSpinner, R.id.decrement,
548                R.string.time_picker_decrement_hour_button);
549        // AM/PM
550        if (mAmPmSpinner != null) {
551            trySetContentDescription(mAmPmSpinner, R.id.increment,
552                    R.string.time_picker_increment_set_pm_button);
553            trySetContentDescription(mAmPmSpinner, R.id.decrement,
554                    R.string.time_picker_decrement_set_am_button);
555        }
556    }
557
558    private void trySetContentDescription(View root, int viewId, int contDescResId) {
559        View target = root.findViewById(viewId);
560        if (target != null) {
561            target.setContentDescription(mContext.getString(contDescResId));
562        }
563    }
564
565    private void updateInputState() {
566        // Make sure that if the user changes the value and the IME is active
567        // for one of the inputs if this widget, the IME is closed. If the user
568        // changed the value via the IME and there is a next input the IME will
569        // be shown, otherwise the user chose another means of changing the
570        // value and having the IME up makes no sense.
571        InputMethodManager inputMethodManager = InputMethodManager.peekInstance();
572        if (inputMethodManager != null) {
573            if (inputMethodManager.isActive(mHourSpinnerInput)) {
574                mHourSpinnerInput.clearFocus();
575                inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
576            } else if (inputMethodManager.isActive(mMinuteSpinnerInput)) {
577                mMinuteSpinnerInput.clearFocus();
578                inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
579            } else if (inputMethodManager.isActive(mAmPmSpinnerInput)) {
580                mAmPmSpinnerInput.clearFocus();
581                inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
582            }
583        }
584    }
585}
586