1/*
2 * Copyright (C) 2017 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.text.Editable;
21import android.text.InputFilter;
22import android.text.TextWatcher;
23import android.util.AttributeSet;
24import android.util.MathUtils;
25import android.view.View;
26
27import com.android.internal.R;
28
29/**
30 * View to show text input based time picker with hour and minute fields and an optional AM/PM
31 * spinner.
32 *
33 * @hide
34 */
35public class TextInputTimePickerView extends RelativeLayout {
36    public static final int HOURS = 0;
37    public static final int MINUTES = 1;
38    public static final int AMPM = 2;
39
40    private static final int AM = 0;
41    private static final int PM = 1;
42
43    private final EditText mHourEditText;
44    private final EditText mMinuteEditText;
45    private final TextView mInputSeparatorView;
46    private final Spinner mAmPmSpinner;
47    private final TextView mErrorLabel;
48    private final TextView mHourLabel;
49    private final TextView mMinuteLabel;
50
51    private boolean mIs24Hour;
52    private boolean mHourFormatStartsAtZero;
53    private OnValueTypedListener mListener;
54
55    private boolean mErrorShowing;
56
57    interface OnValueTypedListener {
58        void onValueChanged(int inputType, int newValue);
59    }
60
61    public TextInputTimePickerView(Context context) {
62        this(context, null);
63    }
64
65    public TextInputTimePickerView(Context context, AttributeSet attrs) {
66        this(context, attrs, 0);
67    }
68
69    public TextInputTimePickerView(Context context, AttributeSet attrs, int defStyle) {
70        this(context, attrs, defStyle, 0);
71    }
72
73    public TextInputTimePickerView(Context context, AttributeSet attrs, int defStyle,
74            int defStyleRes) {
75        super(context, attrs, defStyle, defStyleRes);
76
77        inflate(context, R.layout.time_picker_text_input_material, this);
78
79        mHourEditText = findViewById(R.id.input_hour);
80        mMinuteEditText = findViewById(R.id.input_minute);
81        mInputSeparatorView = findViewById(R.id.input_separator);
82        mErrorLabel = findViewById(R.id.label_error);
83        mHourLabel = findViewById(R.id.label_hour);
84        mMinuteLabel = findViewById(R.id.label_minute);
85
86        mHourEditText.addTextChangedListener(new TextWatcher() {
87            @Override
88            public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
89
90            @Override
91            public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
92
93            @Override
94            public void afterTextChanged(Editable editable) {
95                parseAndSetHourInternal(editable.toString());
96            }
97        });
98
99        mMinuteEditText.addTextChangedListener(new TextWatcher() {
100            @Override
101            public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
102
103            @Override
104            public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
105
106            @Override
107            public void afterTextChanged(Editable editable) {
108                parseAndSetMinuteInternal(editable.toString());
109            }
110        });
111
112        mAmPmSpinner = findViewById(R.id.am_pm_spinner);
113        final String[] amPmStrings = TimePicker.getAmPmStrings(context);
114        ArrayAdapter<CharSequence> adapter =
115                new ArrayAdapter<CharSequence>(context, R.layout.simple_spinner_dropdown_item);
116        adapter.add(TimePickerClockDelegate.obtainVerbatim(amPmStrings[0]));
117        adapter.add(TimePickerClockDelegate.obtainVerbatim(amPmStrings[1]));
118        mAmPmSpinner.setAdapter(adapter);
119        mAmPmSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
120            @Override
121            public void onItemSelected(AdapterView<?> adapterView, View view, int position,
122                    long id) {
123                if (position == 0) {
124                    mListener.onValueChanged(AMPM, AM);
125                } else {
126                    mListener.onValueChanged(AMPM, PM);
127                }
128            }
129
130            @Override
131            public void onNothingSelected(AdapterView<?> adapterView) {}
132        });
133    }
134
135    void setListener(OnValueTypedListener listener) {
136        mListener = listener;
137    }
138
139    void setHourFormat(int maxCharLength) {
140        mHourEditText.setFilters(new InputFilter[] {
141                new InputFilter.LengthFilter(maxCharLength)});
142        mMinuteEditText.setFilters(new InputFilter[] {
143                new InputFilter.LengthFilter(maxCharLength)});
144    }
145
146    boolean validateInput() {
147        final boolean inputValid = parseAndSetHourInternal(mHourEditText.getText().toString())
148                && parseAndSetMinuteInternal(mMinuteEditText.getText().toString());
149        setError(!inputValid);
150        return inputValid;
151    }
152
153    void updateSeparator(String separatorText) {
154        mInputSeparatorView.setText(separatorText);
155    }
156
157    private void setError(boolean enabled) {
158        mErrorShowing = enabled;
159
160        mErrorLabel.setVisibility(enabled ? View.VISIBLE : View.INVISIBLE);
161        mHourLabel.setVisibility(enabled ? View.INVISIBLE : View.VISIBLE);
162        mMinuteLabel.setVisibility(enabled ? View.INVISIBLE : View.VISIBLE);
163    }
164
165    /**
166     * Computes the display value and updates the text of the view.
167     * <p>
168     * This method should be called whenever the current value or display
169     * properties (leading zeroes, max digits) change.
170     */
171    void updateTextInputValues(int localizedHour, int minute, int amOrPm, boolean is24Hour,
172            boolean hourFormatStartsAtZero) {
173        final String format = "%d";
174
175        mIs24Hour = is24Hour;
176        mHourFormatStartsAtZero = hourFormatStartsAtZero;
177
178        mAmPmSpinner.setVisibility(is24Hour ? View.INVISIBLE : View.VISIBLE);
179
180        if (amOrPm == AM) {
181            mAmPmSpinner.setSelection(0);
182        } else {
183            mAmPmSpinner.setSelection(1);
184        }
185
186        mHourEditText.setText(String.format(format, localizedHour));
187        mMinuteEditText.setText(String.format(format, minute));
188
189        if (mErrorShowing) {
190            validateInput();
191        }
192    }
193
194    private boolean parseAndSetHourInternal(String input) {
195        try {
196            final int hour = Integer.parseInt(input);
197            if (!isValidLocalizedHour(hour)) {
198                final int minHour = mHourFormatStartsAtZero ? 0 : 1;
199                final int maxHour = mIs24Hour ? 23 : 11 + minHour;
200                mListener.onValueChanged(HOURS, getHourOfDayFromLocalizedHour(
201                        MathUtils.constrain(hour, minHour, maxHour)));
202                return false;
203            }
204            mListener.onValueChanged(HOURS, getHourOfDayFromLocalizedHour(hour));
205            return true;
206        } catch (NumberFormatException e) {
207            // Do nothing since we cannot parse the input.
208            return false;
209        }
210    }
211
212    private boolean parseAndSetMinuteInternal(String input) {
213        try {
214            final int minutes = Integer.parseInt(input);
215            if (minutes < 0 || minutes > 59) {
216                mListener.onValueChanged(MINUTES, MathUtils.constrain(minutes, 0, 59));
217                return false;
218            }
219            mListener.onValueChanged(MINUTES, minutes);
220            return true;
221        } catch (NumberFormatException e) {
222            // Do nothing since we cannot parse the input.
223            return false;
224        }
225    }
226
227    private boolean isValidLocalizedHour(int localizedHour) {
228        final int minHour = mHourFormatStartsAtZero ? 0 : 1;
229        final int maxHour = (mIs24Hour ? 23 : 11) + minHour;
230        return localizedHour >= minHour && localizedHour <= maxHour;
231    }
232
233    private int getHourOfDayFromLocalizedHour(int localizedHour) {
234        int hourOfDay = localizedHour;
235        if (mIs24Hour) {
236            if (!mHourFormatStartsAtZero && localizedHour == 24) {
237                hourOfDay = 0;
238            }
239        } else {
240            if (!mHourFormatStartsAtZero && localizedHour == 12) {
241                hourOfDay = 0;
242            }
243            if (mAmPmSpinner.getSelectedItemPosition() == 1) {
244                hourOfDay += 12;
245            }
246        }
247        return hourOfDay;
248    }
249}
250