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