/*
* Copyright (C) 2016 Phillip Hsu
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.philliphsu.bottomsheetpickers.time.numberpad;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.ColorStateList;
import android.os.Build;
import android.support.annotation.IntDef;
import android.support.annotation.Nullable;
import android.support.design.widget.FloatingActionButton;
import android.support.v4.content.ContextCompat;
import android.text.format.DateFormat;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.DecelerateInterpolator;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.TextView;
import com.philliphsu.bottomsheetpickers.R;
import com.philliphsu.bottomsheetpickers.Utils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.text.DateFormatSymbols;
class NumberPadTimePicker extends GridLayoutNumberPad {
private static final int MAX_DIGITS = 4;
// Formatted time string has a maximum of 8 characters
// in the 12-hour clock, e.g 12:59 AM. Although the 24-hour
// clock should be capped at 5 characters, the difference
// is not significant enough to deal with the separate cases.
private static final int MAX_CHARS = 8;
// Constant for converting text digits to numeric digits in base-10.
private static final int BASE_10 = 10;
// AmPmStates
static final int UNSPECIFIED = -1;
static final int AM = 0;
static final int PM = 1;
static final int HRS_24 = 2;
@IntDef({ UNSPECIFIED, AM, PM, HRS_24 }) // Specifies the accepted constants
@Retention(RetentionPolicy.SOURCE) // Usages do not need to be recorded in .class files
private @interface AmPmState {}
@AmPmState
private int mAmPmState = UNSPECIFIED;
private final StringBuilder mFormattedInput = new StringBuilder(MAX_CHARS);
private final Button[] mAltButtons = new Button[2];
private final FloatingActionButton mFab;
private final ImageButton mBackspace;
private boolean mThemeDark;
private final int mFabDisabledColorDark;
private final int mFabDisabledColorLight;
@Nullable
private final ObjectAnimator mElevationAnimator;
private boolean mIs24HourMode;
/**
* Provides additional APIs to configure clients' display output.
*/
public interface OnInputChangeListener extends GridLayoutNumberPad.OnInputChangeListener {
/**
* Called when this numpad's buttons are all disabled, indicating no further
* digits can be inserted.
*/
void onInputDisabled();
}
public NumberPadTimePicker(Context context) {
this(context, null);
}
public NumberPadTimePicker(Context context, AttributeSet attrs) {
super(context, attrs);
mAltButtons[0] = (Button) findViewById(R.id.bsp_leftAlt);
mAltButtons[1] = (Button) findViewById(R.id.bsp_rightAlt);
mFab = (FloatingActionButton) findViewById(R.id.bsp_fab);
mBackspace = (ImageButton) findViewById(R.id.bsp_backspace);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
mElevationAnimator = ObjectAnimator.ofFloat(mFab, "elevation",
getResources().getDimension(R.dimen.bsp_fab_elevation))
.setDuration(200);
mElevationAnimator.setInterpolator(new DecelerateInterpolator());
} else {
// Only animate the elevation for 21+ because changing elevation on pre-21
// shifts the FAB slightly up/down. For that reason, pre-21 has elevation
// permanently set to 0 (in XML).
mElevationAnimator = null;
}
mFabDisabledColorDark = ContextCompat.getColor(context, R.color.bsp_fab_disabled_dark);
mFabDisabledColorLight = ContextCompat.getColor(context, R.color.bsp_fab_disabled_light);
setIs24HourMode(DateFormat.is24HourFormat(context));
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
for (Button b : mAltButtons) {
b.setOnClickListener(mAltButtonClickListener);
}
mBackspace.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
delete();
}
});
mBackspace.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
return clear();
}
});
}
@Override
void setTheme(Context context, boolean themeDark) {
super.setTheme(context, themeDark);
mThemeDark = themeDark;
// this.getContext() ==> default teal accent color
// application context ==> white
// The Context that was passed in is NumberPadTimePickerDialog.getContext() which
// is probably the host Activity. I have no idea what this.getContext() returns,
// but its probably some internal type that isn't tied to any of our application
// components.
// So, we kept the 0-9 buttons as TextViews, but here we kept
// the alt buttons as actual Buttons...
for (Button b : mAltButtons) {
setTextColor(b);
Utils.setColorControlHighlight(b, mAccentColor);
}
Utils.setColorControlHighlight(mBackspace, mAccentColor);
ColorStateList colorBackspace = ContextCompat.getColorStateList(context,
themeDark? R.color.bsp_icon_color_dark : R.color.bsp_icon_color);
Utils.setTintList(mBackspace, mBackspace.getDrawable(), colorBackspace);
ColorStateList colorIcon = ContextCompat.getColorStateList(context,
themeDark? R.color.bsp_icon_color_dark : R.color.bsp_fab_icon_color);
Utils.setTintList(mFab, mFab.getDrawable(), colorIcon);
// Make sure the dark theme disabled color shows up initially
updateFabState();
}
void setIs24HourMode(boolean is24HourMode) {
mIs24HourMode = is24HourMode;
if (is24HourMode) {
mAltButtons[0].setText(R.string.bsp_left_alt_24hr);
mAltButtons[1].setText(R.string.bsp_right_alt_24hr);
} else {
String[] amPm = new DateFormatSymbols().getAmPmStrings();
mAltButtons[0].setText(amPm[0].length() > 2 ? "AM" : amPm[0]);
mAltButtons[1].setText(amPm[1].length() > 2 ? "PM" : amPm[1]);
}
updateNumpadStates();
}
@Override
public int capacity() {
return MAX_DIGITS;
}
@Override
protected int contentLayout() {
return R.layout.bsp_content_numpad_time_picker;
}
@Override
protected void enable(int lowerLimitInclusive, int upperLimitExclusive) {
super.enable(lowerLimitInclusive, upperLimitExclusive);
if (lowerLimitInclusive == 0 && upperLimitExclusive == 0) {
// For 12-hour clock, alt buttons need to be disabled as well before firing onInputDisabled()
if (!is24HourFormat() && (mAltButtons[0].isEnabled() || mAltButtons[1].isEnabled())) {
return;
}
((OnInputChangeListener) getOnInputChangeListener()).onInputDisabled();
}
}
@Override
protected void onDigitInserted(String newDigit) {
// Append the new digit(s) to the formatter
updateFormattedInputOnDigitInserted(newDigit);
super.onDigitInserted(mFormattedInput.toString());
updateNumpadStates();
}
@Override
protected void onDigitDeleted(String newStr) {
updateFormattedInputOnDigitDeleted();
super.onDigitDeleted(mFormattedInput.toString());
updateNumpadStates();
}
@Override
protected void onDigitsCleared() {
mFormattedInput.delete(0, mFormattedInput.length());
mAmPmState = UNSPECIFIED;
updateNumpadStates(); // TOneverDO: before resetting mAmPmState to UNSPECIFIED
super.onDigitsCleared();
}
@Override
public void delete() {
int len = mFormattedInput.length();
if (!is24HourFormat() && mAmPmState != UNSPECIFIED) {
mAmPmState = UNSPECIFIED;
// Delete starting from index of space to end
mFormattedInput.delete(mFormattedInput.indexOf(" "), len);
// No digit was actually deleted, but we have to notify the
// listener to update its output.
super/*TOneverDO: remove super*/.onDigitDeleted(mFormattedInput.toString());
// We also have to manually update the numpad.
updateNumpadStates();
} else {
super.delete();
}
}
/** Returns the hour of day (0-23) regardless of clock system */
public int getHour() {
if (!checkTimeValid())
throw new IllegalStateException("Cannot call hourOfDay() until legal time inputted");
int hours = count() < 4 ? valueAt(0) : valueAt(0) * 10 + valueAt(1);
if (hours == 12) {
switch (mAmPmState) {
case AM:
return 0;
case PM:
case HRS_24:
return 12;
default:
break;
}
}
// AM/PM clock needs value offset
return hours + (mAmPmState == PM ? 12 : 0);
}
public int getMinute() {
if (!checkTimeValid())
throw new IllegalStateException("Cannot call minute() until legal time inputted");
return count() < 4 ? valueAt(1) * 10 + valueAt(2) : valueAt(2) * 10 + valueAt(3);
}
/**
* Checks if the input stored so far qualifies as a valid time.
* For this to return {@code true}, the hours, minutes AND AM/PM
* state must be set.
*/
public boolean checkTimeValid() {
// While the test looks bare, it is actually comprehensive.
// mAmPmState will remain UNSPECIFIED until a legal
// sequence of digits is inputted, no matter the clock system in use.
// TODO: So if that's the case, do we actually need 'count() < 3' here? Or better yet,
// can we simplify the code to just 'return mAmPmState != UNSPECIFIED'?
if (mAmPmState == UNSPECIFIED || mAmPmState == HRS_24 && count() < 3) {
return false;
}
// AM or PM can only be set if the time was already valid previously, so we don't need
// to check for them.
return true;
}
public void setTime(int hours, int minutes) {
if (hours < 0 || hours > 23)
throw new IllegalArgumentException("Illegal hours: " + hours);
if (minutes < 0 || minutes > 59)
throw new IllegalArgumentException("Illegal minutes: " + minutes);
// Internal representation of the time has been checked for legality.
// Now we need to format it depending on the user's clock system.
// If 12-hour clock, can't set mAmPmState yet or else this interferes
// with the button state update mechanism. Instead, cache the state
// the hour would resolve to in a local variable and set it after
// all digits are inputted.
int amPmState;
if (!is24HourFormat()) {
// Convert 24-hour times into 12-hour compatible times.
if (hours == 0) {
hours = 12;
amPmState = AM;
} else if (hours == 12) {
amPmState = PM;
} else if (hours > 12) {
hours -= 12;
amPmState = PM;
} else {
amPmState = AM;
}
} else {
amPmState = HRS_24;
}
/*
// Convert the hour and minutes into text form, so that
// we can read each digit individually.
// Only if on 24-hour clock, zero-pad single digit hours.
// Zero cannot be the first digit of any time in the 12-hour clock.
String textDigits = is24HourFormat()
? String.format("%02d", hours)
: String.valueOf(hours);
textDigits += String.format("%02d", minutes);
int[] digits = new int[textDigits.length()];
for (int i = 0; i < textDigits.length(); i++) {
digits[i] = Character.digit(textDigits.charAt(i), BASE_10);
}
insertDigits(digits);
*/
if (is24HourFormat() || hours > 9) {
insertDigits(hours / 10, hours % 10, minutes / 10, minutes % 10);
} else {
insertDigits(hours, minutes / 10, minutes % 10);
}
mAmPmState = amPmState;
if (mAmPmState != HRS_24) {
mAltButtonClickListener.onClick(mAmPmState == AM ? mAltButtons[0] : mAltButtons[1]);
}
}
public String getTime() {
return mFormattedInput.toString();
}
@AmPmState
int getAmPmState() {
return mAmPmState;
}
// Because the annotation and its associated enum constants are marked private, the only real
// use for this method is to restore state across rotation after saving the value from
// #getAmPmState(). We can't directly pass in one of those accepted constants.
void setAmPmState(@AmPmState int amPmState) {
// mAmPmState = amPmState;
switch (amPmState) {
case AM:
case PM:
// mAmPmState is set for us
mAltButtonClickListener.onClick(mAltButtons[amPmState]);
break;
case HRS_24:
// Restoring the digits, if they make a valid time, should have already
// restored the mAmPmState to this value for us. If they don't make a
// valid time, then we refrain from setting it.
break;
case UNSPECIFIED:
// We should already be set to this value initially, but it can't hurt?
mAmPmState = amPmState;
break;
}
}
private boolean is24HourFormat() {
return mIs24HourMode;
}
private void updateFormattedInputOnDigitInserted(String newDigits) {
mFormattedInput.append(newDigits);
// Add colon if necessary, depending on how many digits entered so far
if (count() == 3) {
// Insert a colon
int digits = getInput();
if (digits >= 60 && digits < 100 || digits >= 160 && digits < 200) {
// From 060-099 (really only to 095, but might as well go up to 100)
// From 160-199 (really only to 195, but might as well go up to 200),
// time does not exist if colon goes at pos. 1
mFormattedInput.insert(2, ':');
// These times only apply to the 24-hour clock, and if we're here,
// the time is not legal yet. So we can't set mAmPmState here for
// either clock.
// The 12-hour clock can only have mAmPmState set when AM/PM are clicked.
} else {
// A valid time exists if colon is at pos. 1
mFormattedInput.insert(1, ':');
// We can set mAmPmState here (and not in the above case) because
// the time here is legal in 24-hour clock
if (is24HourFormat()) {
mAmPmState = HRS_24;
}
}
} else if (count() == MAX_DIGITS) {
int colonAt = mFormattedInput.indexOf(":");
// Since we now batch update the formatted input whenever
// digits are inserted, the colon may legitimately not be
// present in the formatted input when this is initialized.
if (colonAt != -1) {
// Colon needs to move, so remove the colon previously added
mFormattedInput.deleteCharAt(colonAt);
}
mFormattedInput.insert(2, ':');
// Time is legal in 24-hour clock
if (is24HourFormat()) {
mAmPmState = HRS_24;
}
}
}
private void updateFormattedInputOnDigitDeleted() {
int len = mFormattedInput.length();
mFormattedInput.delete(len - 1, len);
if (count() == 3) {
int value = getInput();
// Move the colon from its 4-digit position to its 3-digit position,
// unless doing so gives an invalid time.
// e.g. 17:55 becomes 1:75, which is invalid.
// All 3-digit times in the 12-hour clock at this point should be
// valid. The limits <=155 and (>=200 && <=235) are really only
// imposed on the 24-hour clock, and were chosen because 4-digit times
// in the 24-hour clock can only go up to 15:5[0-9] or be within the range
// [20:00, 23:59] if they are to remain valid when they become three digits.
// The is24HourFormat() check is therefore unnecessary.
if (value <= 155 || value >= 200 && value <= 235) {
mFormattedInput.deleteCharAt(mFormattedInput.indexOf(":"));
mFormattedInput.insert(1, ":");
} else {
// previously [16:00, 19:59]
mAmPmState = UNSPECIFIED;
}
} else if (count() == 2) {
// Remove the colon
mFormattedInput.deleteCharAt(mFormattedInput.indexOf(":"));
// No time can be valid with only 2 digits in either system.
// I don't think we actually need this, but it can't hurt?
mAmPmState = UNSPECIFIED;
}
}
private void updateNumpadStates() {
// TOneverDO: after updateNumberKeysStates(), esp. if clock is 12-hour,
// because it calls enable(0, 0), which checks if the alt buttons have been
// disabled as well before firing the onInputDisabled().
updateAltButtonStates();
updateBackspaceState();
updateNumberKeysStates();
updateFabState();
}
private void updateFabState() {
final boolean lastEnabled = mFab.isEnabled();
mFab.setEnabled(checkTimeValid());
// If the fab was last enabled and we rotate, this check will prevent us from
// restoring the color; it will instead show up opaque white with an eclipse.
// Why isn't the FAB initialized to enabled == false when it is recreated?
// The FAB class probably saves its own state.
// if (lastEnabled == mFab.isEnabled())
// return;
// Workaround for mFab.setBackgroundTintList() because I don't know how to reference the
// correct accent color in XML. Also because I don't want to programmatically create a
// ColorStateList.
int color;
if (mFab.isEnabled()) {
color = mAccentColor;
// If FAB was last enabled, then don't run the anim again.
if (mElevationAnimator != null && !lastEnabled) {
mElevationAnimator.start();
}
} else {
color = mThemeDark? mFabDisabledColorDark : mFabDisabledColorLight;
if (lastEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (mElevationAnimator != null && mElevationAnimator.isRunning()) {
// Otherwise, eclipse will show.
mElevationAnimator.end();
}
// No animation, otherwise we'll see eclipsing.
mFab.setElevation(0);
}
}
// TODO: How can we animate the background color? There is a ObjectAnimator.ofArgb()
// method, but that uses color ints as values. What we'd really need is something like
// ColorStateLists as values. There is an ObjectAnimator.ofObject(), but I don't know
// how that works. There is also a ValueAnimator.ofInt(), which doesn't need a
// target object.
mFab.setBackgroundTintList(ColorStateList.valueOf(color));
}
private void updateBackspaceState() {
mBackspace.setEnabled(count() > 0);
}
private void updateAltButtonStates() {
if (count() == 0) {
// No input, no access!
mAltButtons[0].setEnabled(false);
mAltButtons[1].setEnabled(false);
} else if (count() == 1) {
// Any of 0-9 inputted, always have access in either clock.
mAltButtons[0].setEnabled(true);
mAltButtons[1].setEnabled(true);
} else if (count() == 2) {
// Any 2 digits that make a valid hour for either clock are eligible for access
int time = getInput();
boolean validTwoDigitHour = is24HourFormat() ? time <= 23 : time >= 10 && time <= 12;
mAltButtons[0].setEnabled(validTwoDigitHour);
mAltButtons[1].setEnabled(validTwoDigitHour);
} else if (count() == 3) {
if (is24HourFormat()) {
// For the 24-hour clock, no access at all because
// two more digits (00 or 30) cannot be added to 3 digits.
mAltButtons[0].setEnabled(false);
mAltButtons[1].setEnabled(false);
} else {
// True for any 3 digits, if AM/PM not already entered
boolean enabled = mAmPmState == UNSPECIFIED;
mAltButtons[0].setEnabled(enabled);
mAltButtons[1].setEnabled(enabled);
}
} else if (count() == MAX_DIGITS) {
// If all 4 digits are filled in, the 24-hour clock has absolutely
// no need for the alt buttons. However, The 12-hour clock has
// complete need of them, if not already used.
boolean enabled = !is24HourFormat() && mAmPmState == UNSPECIFIED;
mAltButtons[0].setEnabled(enabled);
mAltButtons[1].setEnabled(enabled);
}
}
private void updateNumberKeysStates() {
int cap = 10; // number of buttons
boolean is24hours = is24HourFormat();
if (count() == 0) {
enable(is24hours ? 0 : 1, cap);
return;
} else if (count() == MAX_DIGITS) {
enable(0, 0);
return;
}
int time = getInput();
if (is24hours) {
if (count() == 1) {
enable(0, time < 2 ? cap : 6);
} else if (count() == 2) {
enable(0, time % 10 >= 0 && time % 10 <= 5 ? cap : 6);
} else if (count() == 3) {
if (time >= 236) {
enable(0, 0);
} else {
enable(0, time % 10 >= 0 && time % 10 <= 5 ? cap : 0);
}
}
} else {
if (count() == 1) {
if (time == 0) {
throw new IllegalStateException("12-hr format, zeroth digit = 0?");
} else {
enable(0, 6);
}
} else if (count() == 2 || count() == 3) {
if (time >= 126) {
enable(0, 0);
} else {
if (time >= 100 && time <= 125 && mAmPmState != UNSPECIFIED) {
// Could legally input fourth digit, if not for the am/pm state already set
enable(0, 0);
} else {
enable(0, time % 10 >= 0 && time % 10 <= 5 ? cap : 0);
}
}
}
}
}
private final View.OnClickListener mAltButtonClickListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
final TextView altBtn = (TextView) view;
// Manually insert special characters for 12-hour clock
if (!is24HourFormat()) {
if (count() <= 2) {
// The colon is inserted for you
insertDigits(0, 0);
}
// text is AM or PM, so include space before
String ampm = altBtn.getText().toString();
mFormattedInput.append(' ').append(ampm);
String am = new DateFormatSymbols().getAmPmStrings()[0];
mAmPmState = ampm.equals(am) ? AM : PM;
// Digits will be shown for you on insert, but not AM/PM
NumberPadTimePicker.super/*TOneverDO: remove super*/.onDigitInserted(mFormattedInput.toString());
} else {
CharSequence text = altBtn.getText();
int[] digits = new int[text.length() - 1];
// charAt(0) is the colon, so skip i = 0.
// We are only interested in storing the digits.
for (int i = 1; i < text.length(); i++) {
// The array and the text do not have the same lengths,
// so the iterator value does not correspond to the
// array index directly
digits[i - 1] = Character.digit(text.charAt(i), BASE_10);
}
// Colon is added for you
insertDigits(digits);
mAmPmState = HRS_24;
}
updateNumpadStates();
}
};
}