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