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 android.widget;
18
19import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_AUTO;
20import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES;
21
22import android.annotation.TestApi;
23import android.content.Context;
24import android.content.res.TypedArray;
25import android.os.Parcelable;
26import android.text.format.DateFormat;
27import android.text.format.DateUtils;
28import android.util.AttributeSet;
29import android.view.LayoutInflater;
30import android.view.View;
31import android.view.ViewGroup;
32import android.view.accessibility.AccessibilityEvent;
33import android.view.inputmethod.EditorInfo;
34import android.view.inputmethod.InputMethodManager;
35
36import com.android.internal.R;
37
38import libcore.icu.LocaleData;
39
40import java.util.Calendar;
41
42/**
43 * A delegate implementing the basic spinner-based TimePicker.
44 */
45class TimePickerSpinnerDelegate extends TimePicker.AbstractTimePickerDelegate {
46    private static final boolean DEFAULT_ENABLED_STATE = true;
47    private static final int HOURS_IN_HALF_DAY = 12;
48
49    private final NumberPicker mHourSpinner;
50    private final NumberPicker mMinuteSpinner;
51    private final NumberPicker mAmPmSpinner;
52    private final EditText mHourSpinnerInput;
53    private final EditText mMinuteSpinnerInput;
54    private final EditText mAmPmSpinnerInput;
55    private final TextView mDivider;
56
57    // Note that the legacy implementation of the TimePicker is
58    // using a button for toggling between AM/PM while the new
59    // version uses a NumberPicker spinner. Therefore the code
60    // accommodates these two cases to be backwards compatible.
61    private final Button mAmPmButton;
62
63    private final String[] mAmPmStrings;
64
65    private final Calendar mTempCalendar;
66
67    private boolean mIsEnabled = DEFAULT_ENABLED_STATE;
68    private boolean mHourWithTwoDigit;
69    private char mHourFormat;
70
71    private boolean mIs24HourView;
72    private boolean mIsAm;
73
74    public TimePickerSpinnerDelegate(TimePicker delegator, Context context, AttributeSet attrs,
75            int defStyleAttr, int defStyleRes) {
76        super(delegator, context);
77
78        // process style attributes
79        final TypedArray a = mContext.obtainStyledAttributes(
80                attrs, R.styleable.TimePicker, defStyleAttr, defStyleRes);
81        final int layoutResourceId = a.getResourceId(
82                R.styleable.TimePicker_legacyLayout, R.layout.time_picker_legacy);
83        a.recycle();
84
85        final LayoutInflater inflater = LayoutInflater.from(mContext);
86        final View view = inflater.inflate(layoutResourceId, mDelegator, true);
87        view.setSaveFromParentEnabled(false);
88
89        // hour
90        mHourSpinner = delegator.findViewById(R.id.hour);
91        mHourSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
92            public void onValueChange(NumberPicker spinner, int oldVal, int newVal) {
93                updateInputState();
94                if (!is24Hour()) {
95                    if ((oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY) ||
96                            (oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1)) {
97                        mIsAm = !mIsAm;
98                        updateAmPmControl();
99                    }
100                }
101                onTimeChanged();
102            }
103        });
104        mHourSpinnerInput = mHourSpinner.findViewById(R.id.numberpicker_input);
105        mHourSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
106
107        // divider (only for the new widget style)
108        mDivider = mDelegator.findViewById(R.id.divider);
109        if (mDivider != null) {
110            setDividerText();
111        }
112
113        // minute
114        mMinuteSpinner = mDelegator.findViewById(R.id.minute);
115        mMinuteSpinner.setMinValue(0);
116        mMinuteSpinner.setMaxValue(59);
117        mMinuteSpinner.setOnLongPressUpdateInterval(100);
118        mMinuteSpinner.setFormatter(NumberPicker.getTwoDigitFormatter());
119        mMinuteSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
120            public void onValueChange(NumberPicker spinner, int oldVal, int newVal) {
121                updateInputState();
122                int minValue = mMinuteSpinner.getMinValue();
123                int maxValue = mMinuteSpinner.getMaxValue();
124                if (oldVal == maxValue && newVal == minValue) {
125                    int newHour = mHourSpinner.getValue() + 1;
126                    if (!is24Hour() && newHour == HOURS_IN_HALF_DAY) {
127                        mIsAm = !mIsAm;
128                        updateAmPmControl();
129                    }
130                    mHourSpinner.setValue(newHour);
131                } else if (oldVal == minValue && newVal == maxValue) {
132                    int newHour = mHourSpinner.getValue() - 1;
133                    if (!is24Hour() && newHour == HOURS_IN_HALF_DAY - 1) {
134                        mIsAm = !mIsAm;
135                        updateAmPmControl();
136                    }
137                    mHourSpinner.setValue(newHour);
138                }
139                onTimeChanged();
140            }
141        });
142        mMinuteSpinnerInput = mMinuteSpinner.findViewById(R.id.numberpicker_input);
143        mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
144
145        // Get the localized am/pm strings and use them in the spinner.
146        mAmPmStrings = getAmPmStrings(context);
147
148        // am/pm
149        final View amPmView = mDelegator.findViewById(R.id.amPm);
150        if (amPmView instanceof Button) {
151            mAmPmSpinner = null;
152            mAmPmSpinnerInput = null;
153            mAmPmButton = (Button) amPmView;
154            mAmPmButton.setOnClickListener(new View.OnClickListener() {
155                public void onClick(View button) {
156                    button.requestFocus();
157                    mIsAm = !mIsAm;
158                    updateAmPmControl();
159                    onTimeChanged();
160                }
161            });
162        } else {
163            mAmPmButton = null;
164            mAmPmSpinner = (NumberPicker) amPmView;
165            mAmPmSpinner.setMinValue(0);
166            mAmPmSpinner.setMaxValue(1);
167            mAmPmSpinner.setDisplayedValues(mAmPmStrings);
168            mAmPmSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
169                public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
170                    updateInputState();
171                    picker.requestFocus();
172                    mIsAm = !mIsAm;
173                    updateAmPmControl();
174                    onTimeChanged();
175                }
176            });
177            mAmPmSpinnerInput = mAmPmSpinner.findViewById(R.id.numberpicker_input);
178            mAmPmSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
179        }
180
181        if (isAmPmAtStart()) {
182            // Move the am/pm view to the beginning
183            ViewGroup amPmParent = delegator.findViewById(R.id.timePickerLayout);
184            amPmParent.removeView(amPmView);
185            amPmParent.addView(amPmView, 0);
186            // Swap layout margins if needed. They may be not symmetrical (Old Standard Theme
187            // for example and not for Holo Theme)
188            ViewGroup.MarginLayoutParams lp =
189                    (ViewGroup.MarginLayoutParams) amPmView.getLayoutParams();
190            final int startMargin = lp.getMarginStart();
191            final int endMargin = lp.getMarginEnd();
192            if (startMargin != endMargin) {
193                lp.setMarginStart(endMargin);
194                lp.setMarginEnd(startMargin);
195            }
196        }
197
198        getHourFormatData();
199
200        // update controls to initial state
201        updateHourControl();
202        updateMinuteControl();
203        updateAmPmControl();
204
205        // set to current time
206        mTempCalendar = Calendar.getInstance(mLocale);
207        setHour(mTempCalendar.get(Calendar.HOUR_OF_DAY));
208        setMinute(mTempCalendar.get(Calendar.MINUTE));
209
210        if (!isEnabled()) {
211            setEnabled(false);
212        }
213
214        // set the content descriptions
215        setContentDescriptions();
216
217        // If not explicitly specified this view is important for accessibility.
218        if (mDelegator.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
219            mDelegator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
220        }
221    }
222
223    @Override
224    public boolean validateInput() {
225        return true;
226    }
227
228    private void getHourFormatData() {
229        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale,
230                (mIs24HourView) ? "Hm" : "hm");
231        final int lengthPattern = bestDateTimePattern.length();
232        mHourWithTwoDigit = false;
233        char hourFormat = '\0';
234        // Check if the returned pattern is single or double 'H', 'h', 'K', 'k'. We also save
235        // the hour format that we found.
236        for (int i = 0; i < lengthPattern; i++) {
237            final char c = bestDateTimePattern.charAt(i);
238            if (c == 'H' || c == 'h' || c == 'K' || c == 'k') {
239                mHourFormat = c;
240                if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) {
241                    mHourWithTwoDigit = true;
242                }
243                break;
244            }
245        }
246    }
247
248    private boolean isAmPmAtStart() {
249        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale,
250                "hm" /* skeleton */);
251
252        return bestDateTimePattern.startsWith("a");
253    }
254
255    /**
256     * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":".
257     *
258     * See http://unicode.org/cldr/trac/browser/trunk/common/main
259     *
260     * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the
261     * separator as the character which is just after the hour marker in the returned pattern.
262     */
263    private void setDividerText() {
264        final String skeleton = (mIs24HourView) ? "Hm" : "hm";
265        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale,
266                skeleton);
267        final String separatorText;
268        int hourIndex = bestDateTimePattern.lastIndexOf('H');
269        if (hourIndex == -1) {
270            hourIndex = bestDateTimePattern.lastIndexOf('h');
271        }
272        if (hourIndex == -1) {
273            // Default case
274            separatorText = ":";
275        } else {
276            int minuteIndex = bestDateTimePattern.indexOf('m', hourIndex + 1);
277            if  (minuteIndex == -1) {
278                separatorText = Character.toString(bestDateTimePattern.charAt(hourIndex + 1));
279            } else {
280                separatorText = bestDateTimePattern.substring(hourIndex + 1, minuteIndex);
281            }
282        }
283        mDivider.setText(separatorText);
284    }
285
286    @Override
287    public void setDate(int hour, int minute) {
288        setCurrentHour(hour, false);
289        setCurrentMinute(minute, false);
290
291        onTimeChanged();
292    }
293
294    @Override
295    public void setHour(int hour) {
296        setCurrentHour(hour, true);
297    }
298
299    private void setCurrentHour(int currentHour, boolean notifyTimeChanged) {
300        // why was Integer used in the first place?
301        if (currentHour == getHour()) {
302            return;
303        }
304        resetAutofilledValue();
305        if (!is24Hour()) {
306            // convert [0,23] ordinal to wall clock display
307            if (currentHour >= HOURS_IN_HALF_DAY) {
308                mIsAm = false;
309                if (currentHour > HOURS_IN_HALF_DAY) {
310                    currentHour = currentHour - HOURS_IN_HALF_DAY;
311                }
312            } else {
313                mIsAm = true;
314                if (currentHour == 0) {
315                    currentHour = HOURS_IN_HALF_DAY;
316                }
317            }
318            updateAmPmControl();
319        }
320        mHourSpinner.setValue(currentHour);
321        if (notifyTimeChanged) {
322            onTimeChanged();
323        }
324    }
325
326    @Override
327    public int getHour() {
328        int currentHour = mHourSpinner.getValue();
329        if (is24Hour()) {
330            return currentHour;
331        } else if (mIsAm) {
332            return currentHour % HOURS_IN_HALF_DAY;
333        } else {
334            return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY;
335        }
336    }
337
338    @Override
339    public void setMinute(int minute) {
340        setCurrentMinute(minute, true);
341    }
342
343    private void setCurrentMinute(int minute, boolean notifyTimeChanged) {
344        if (minute == getMinute()) {
345            return;
346        }
347        resetAutofilledValue();
348        mMinuteSpinner.setValue(minute);
349        if (notifyTimeChanged) {
350            onTimeChanged();
351        }
352    }
353
354    @Override
355    public int getMinute() {
356        return mMinuteSpinner.getValue();
357    }
358
359    public void setIs24Hour(boolean is24Hour) {
360        if (mIs24HourView == is24Hour) {
361            return;
362        }
363        // cache the current hour since spinner range changes and BEFORE changing mIs24HourView!!
364        int currentHour = getHour();
365        // Order is important here.
366        mIs24HourView = is24Hour;
367        getHourFormatData();
368        updateHourControl();
369        // set value after spinner range is updated
370        setCurrentHour(currentHour, false);
371        updateMinuteControl();
372        updateAmPmControl();
373    }
374
375    @Override
376    public boolean is24Hour() {
377        return mIs24HourView;
378    }
379
380    @Override
381    public void setEnabled(boolean enabled) {
382        mMinuteSpinner.setEnabled(enabled);
383        if (mDivider != null) {
384            mDivider.setEnabled(enabled);
385        }
386        mHourSpinner.setEnabled(enabled);
387        if (mAmPmSpinner != null) {
388            mAmPmSpinner.setEnabled(enabled);
389        } else {
390            mAmPmButton.setEnabled(enabled);
391        }
392        mIsEnabled = enabled;
393    }
394
395    @Override
396    public boolean isEnabled() {
397        return mIsEnabled;
398    }
399
400    @Override
401    public int getBaseline() {
402        return mHourSpinner.getBaseline();
403    }
404
405    @Override
406    public Parcelable onSaveInstanceState(Parcelable superState) {
407        return new SavedState(superState, getHour(), getMinute(), is24Hour());
408    }
409
410    @Override
411    public void onRestoreInstanceState(Parcelable state) {
412        if (state instanceof SavedState) {
413            final SavedState ss = (SavedState) state;
414            setHour(ss.getHour());
415            setMinute(ss.getMinute());
416        }
417    }
418
419    @Override
420    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
421        onPopulateAccessibilityEvent(event);
422        return true;
423    }
424
425    @Override
426    public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
427        int flags = DateUtils.FORMAT_SHOW_TIME;
428        if (mIs24HourView) {
429            flags |= DateUtils.FORMAT_24HOUR;
430        } else {
431            flags |= DateUtils.FORMAT_12HOUR;
432        }
433        mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour());
434        mTempCalendar.set(Calendar.MINUTE, getMinute());
435        String selectedDateUtterance = DateUtils.formatDateTime(mContext,
436                mTempCalendar.getTimeInMillis(), flags);
437        event.getText().add(selectedDateUtterance);
438    }
439
440    /** @hide */
441    @Override
442    @TestApi
443    public View getHourView() {
444        return mHourSpinnerInput;
445    }
446
447    /** @hide */
448    @Override
449    @TestApi
450    public View getMinuteView() {
451        return mMinuteSpinnerInput;
452    }
453
454    /** @hide */
455    @Override
456    @TestApi
457    public View getAmView() {
458        return mAmPmSpinnerInput;
459    }
460
461    /** @hide */
462    @Override
463    @TestApi
464    public View getPmView() {
465        return mAmPmSpinnerInput;
466    }
467
468    private void updateInputState() {
469        // Make sure that if the user changes the value and the IME is active
470        // for one of the inputs if this widget, the IME is closed. If the user
471        // changed the value via the IME and there is a next input the IME will
472        // be shown, otherwise the user chose another means of changing the
473        // value and having the IME up makes no sense.
474        InputMethodManager inputMethodManager = InputMethodManager.peekInstance();
475        if (inputMethodManager != null) {
476            if (inputMethodManager.isActive(mHourSpinnerInput)) {
477                mHourSpinnerInput.clearFocus();
478                inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0);
479            } else if (inputMethodManager.isActive(mMinuteSpinnerInput)) {
480                mMinuteSpinnerInput.clearFocus();
481                inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0);
482            } else if (inputMethodManager.isActive(mAmPmSpinnerInput)) {
483                mAmPmSpinnerInput.clearFocus();
484                inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0);
485            }
486        }
487    }
488
489    private void updateAmPmControl() {
490        if (is24Hour()) {
491            if (mAmPmSpinner != null) {
492                mAmPmSpinner.setVisibility(View.GONE);
493            } else {
494                mAmPmButton.setVisibility(View.GONE);
495            }
496        } else {
497            int index = mIsAm ? Calendar.AM : Calendar.PM;
498            if (mAmPmSpinner != null) {
499                mAmPmSpinner.setValue(index);
500                mAmPmSpinner.setVisibility(View.VISIBLE);
501            } else {
502                mAmPmButton.setText(mAmPmStrings[index]);
503                mAmPmButton.setVisibility(View.VISIBLE);
504            }
505        }
506        mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
507    }
508
509    private void onTimeChanged() {
510        mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
511        if (mOnTimeChangedListener != null) {
512            mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(),
513                    getMinute());
514        }
515        if (mAutoFillChangeListener != null) {
516            mAutoFillChangeListener.onTimeChanged(mDelegator, getHour(), getMinute());
517        }
518    }
519
520    private void updateHourControl() {
521        if (is24Hour()) {
522            // 'k' means 1-24 hour
523            if (mHourFormat == 'k') {
524                mHourSpinner.setMinValue(1);
525                mHourSpinner.setMaxValue(24);
526            } else {
527                mHourSpinner.setMinValue(0);
528                mHourSpinner.setMaxValue(23);
529            }
530        } else {
531            // 'K' means 0-11 hour
532            if (mHourFormat == 'K') {
533                mHourSpinner.setMinValue(0);
534                mHourSpinner.setMaxValue(11);
535            } else {
536                mHourSpinner.setMinValue(1);
537                mHourSpinner.setMaxValue(12);
538            }
539        }
540        mHourSpinner.setFormatter(mHourWithTwoDigit ? NumberPicker.getTwoDigitFormatter() : null);
541    }
542
543    private void updateMinuteControl() {
544        if (is24Hour()) {
545            mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
546        } else {
547            mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
548        }
549    }
550
551    private void setContentDescriptions() {
552        // Minute
553        trySetContentDescription(mMinuteSpinner, R.id.increment,
554                R.string.time_picker_increment_minute_button);
555        trySetContentDescription(mMinuteSpinner, R.id.decrement,
556                R.string.time_picker_decrement_minute_button);
557        // Hour
558        trySetContentDescription(mHourSpinner, R.id.increment,
559                R.string.time_picker_increment_hour_button);
560        trySetContentDescription(mHourSpinner, R.id.decrement,
561                R.string.time_picker_decrement_hour_button);
562        // AM/PM
563        if (mAmPmSpinner != null) {
564            trySetContentDescription(mAmPmSpinner, R.id.increment,
565                    R.string.time_picker_increment_set_pm_button);
566            trySetContentDescription(mAmPmSpinner, R.id.decrement,
567                    R.string.time_picker_decrement_set_am_button);
568        }
569    }
570
571    private void trySetContentDescription(View root, int viewId, int contDescResId) {
572        View target = root.findViewById(viewId);
573        if (target != null) {
574            target.setContentDescription(mContext.getString(contDescResId));
575        }
576    }
577
578    public static String[] getAmPmStrings(Context context) {
579        String[] result = new String[2];
580        LocaleData d = LocaleData.get(context.getResources().getConfiguration().locale);
581        result[0] = d.amPm[0].length() > 4 ? d.narrowAm : d.amPm[0];
582        result[1] = d.amPm[1].length() > 4 ? d.narrowPm : d.amPm[1];
583        return result;
584    }
585}
586