package com.simplicityapks.reminderdatepicker.lib; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Resources; import android.content.res.TypedArray; import android.content.res.XmlResourceParser; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.v4.app.FragmentActivity; import android.support.v4.app.FragmentManager; import android.text.format.DateUtils; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.widget.AdapterView; import android.widget.Toast; import com.fourmob.datetimepicker.date.CalendarDay; import com.fourmob.datetimepicker.date.DatePickerDialog; import java.text.DateFormatSymbols; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.List; /** * The left PickerSpinner in the Google Keep app, to select a date. */ public class DateSpinner extends PickerSpinner implements AdapterView.OnItemSelectedListener { // TODO remove when setMinDate(null) works private static final CalendarDay MINIMUM_POSSIBLE_DATE = new CalendarDay(1902, 1, 1); public static final String XML_TAG_DATEITEM = "DateItem"; public static final String XML_ATTR_ABSDAYOFYEAR = "absDayOfYear"; public static final String XML_ATTR_ABSDAYOFMONTH = "absDayOfMonth"; public static final String XML_ATTR_ABSMONTH = "absMonth"; public static final String XML_ATTR_ABSYEAR = "absYear"; public static final String XML_ATTR_RELDAY = "relDay"; public static final String XML_ATTR_RELMONTH = "relMonth"; public static final String XML_ATTR_RELYEAR = "relYear"; // These listeners don't have to be implemented, if null just ignore private OnDateSelectedListener dateListener = null; private OnClickListener customDatePicker = null; // The default DatePicker dialog to show if customDatePicker has not been set private final DatePickerDialog datePickerDialog; private FragmentManager fragmentManager; private boolean showPastItems = false; private boolean showMonthItem = false; private boolean showWeekdayNames = false; private boolean showNumbersInView = false; private String[] weekDays = null; // To catch twice selecting the same date: private Calendar lastSelectedDate = null; // Min and mix date to be shown (are currently not restored during rotation as they are mostly set in the onCreate() anyway): private Calendar minDate = null; private Calendar maxDate = null; // The custom DateFormat used to convert Calendars into displayable Strings: private java.text.DateFormat customDateFormat = null; private java.text.DateFormat secondaryDateFormat = null; /** * Construct a new DateSpinner with the given context's theme. * @param context The Context the view is running in, through which it can access the current theme, resources, etc. */ public DateSpinner(Context context){ this(context, null, 0); } /** * Construct a new DateSpinner with the given context's theme and the supplied attribute set. * @param context The Context the view is running in, through which it can access the current theme, resources, etc. * @param attrs The attributes of the XML tag that is inflating the view. May contain a flags attribute. */ public DateSpinner(Context context, AttributeSet attrs){ this(context, attrs, 0); } /** * Construct a new TimeSpinner with the given context's theme, the supplied attribute set, and default style. * @param context The Context the view is running in, through which it can access the current theme, resources, etc. * @param attrs The attributes of the XML tag that is inflating the view. May contain a flags attribute. * @param defStyle The default style to apply to this view. If 0, no style will be applied (beyond * what is included in the theme). This may either be an attribute resource, whose * value will be retrieved from the current theme, or an explicit style resource. */ public DateSpinner(Context context, AttributeSet attrs, int defStyle){ super(context, attrs, defStyle); // check if the parent activity has our dateSelectedListener, automatically enable it: if(context instanceof OnDateSelectedListener) setOnDateSelectedListener((OnDateSelectedListener) context); setOnItemSelectedListener(this); final Calendar calendar = Calendar.getInstance(); // create the dialog: datePickerDialog = DatePickerDialog.newInstance( new DatePickerDialog.OnDateSetListener() { @Override public void onDateSet(DatePickerDialog datePickerDialog, int year, int month, int day) { setSelectedDate(new GregorianCalendar(year, month, day)); } }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH), hasVibratePermission(context)); // the default min date is today: setMinDate(calendar); // get the FragmentManager: try{ fragmentManager = ((FragmentActivity) context).getSupportFragmentManager(); } catch (ClassCastException e) { Log.d(getClass().getSimpleName(), "Can't get fragment manager from context"); } if(attrs != null) { // get our flags from xml, if set: TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ReminderDatePicker); int flags = a.getInt(R.styleable.ReminderDatePicker_flags, ReminderDatePicker.MODE_GOOGLE); setFlags(flags); a.recycle(); } } private boolean hasVibratePermission(Context context) { final String permission = "android.permission.VIBRATE"; final int res = context.checkCallingOrSelfPermission(permission); return (res == PackageManager.PERMISSION_GRANTED); } @Override public List<TwinTextItem> getSpinnerItems() { try { return getItemsFromXml(R.xml.date_items); } catch (Exception e) { Log.d("DateSpinner", "Error parsing date items from xml"); e.printStackTrace(); } return null; } @Override protected @Nullable TwinTextItem parseItemFromXmlTag(@NonNull XmlResourceParser parser) { if(!parser.getName().equals(XML_TAG_DATEITEM)) { Log.d("DateSpinner", "Unknown xml tag name: " + parser.getName()); return null; } // parse the DateItem, possible values are String text = null; @StringRes int textResource = NO_ID, id = NO_ID; Calendar date = Calendar.getInstance(); for(int i=parser.getAttributeCount()-1; i>=0; i--) { String attrName = parser.getAttributeName(i); switch (attrName) { case XML_ATTR_ID: id = parser.getIdAttributeResourceValue(NO_ID); break; case XML_ATTR_TEXT: text = parser.getAttributeValue(i); // try to get a resource value, the string is retrieved below if(text != null && text.startsWith("@")) textResource = parser.getAttributeResourceValue(i, NO_ID); break; case XML_ATTR_ABSDAYOFYEAR: final int absDayOfYear = parser.getAttributeIntValue(i, -1); if(absDayOfYear > 0) date.set(Calendar.DAY_OF_YEAR, absDayOfYear); break; case XML_ATTR_ABSDAYOFMONTH: final int absDayOfMonth = parser.getAttributeIntValue(i, -1); if(absDayOfMonth > 0) date.set(Calendar.DAY_OF_MONTH, absDayOfMonth); break; case XML_ATTR_ABSMONTH: final int absMonth = parser.getAttributeIntValue(i, -1); if(absMonth >= 0) date.set(Calendar.MONTH, absMonth); break; case XML_ATTR_ABSYEAR: final int absYear = parser.getAttributeIntValue(i, -1); if(absYear >= 0) date.set(Calendar.YEAR, absYear); break; case XML_ATTR_RELDAY: final int relDay = parser.getAttributeIntValue(i, 0); date.add(Calendar.DAY_OF_YEAR, relDay); break; case XML_ATTR_RELMONTH: final int relMonth = parser.getAttributeIntValue(i, 0); date.add(Calendar.MONTH, relMonth); break; case XML_ATTR_RELYEAR: final int relYear = parser.getAttributeIntValue(i, 0); date.add(Calendar.YEAR, relYear); break; default: Log.d("DateSpinner", "Skipping unknown attribute tag parsing xml resource: " + attrName + ", maybe a typo?"); } }// end for attr // now construct the date item from the attributes // check if we got a textResource earlier and parse that string together with the weekday if(textResource != NO_ID) text = getWeekDay(date.get(Calendar.DAY_OF_WEEK), textResource); // when no text is given, format the date to have at least something to show if(text == null || text.equals("")) text = formatDate(date); return new DateItem(text, date, id); } private String getWeekDay(int weekDay, @StringRes int stringRes) { if(weekDays == null) weekDays = new DateFormatSymbols().getWeekdays(); // use a separate string for Saturday and Sunday because of gender variation in Portuguese if(weekDay==7 || weekDay==1) { if(stringRes == R.string.date_next_weekday) stringRes = R.string.date_next_weekday_weekend; else if(stringRes == R.string.date_last_weekday) stringRes = R.string.date_last_weekday_weekend; } String result = getResources().getString(stringRes, weekDays[weekDay]); // in some translations (French for instance), the weekday is the first word but is not capitalized, so we'll do that return Character.toUpperCase(result.charAt(0)) + result.substring(1); } /** * Gets the currently selected date (that the Spinner is showing) * @return The selected date as Calendar, or null if there is none. */ public Calendar getSelectedDate() { final Object selectedItem = getSelectedItem(); if(!(selectedItem instanceof DateItem)) return null; return ((DateItem) selectedItem).getDate(); } /** * Sets the Spinner's selection as date. If the date was not in the possible selections, a temporary * item is created and passed to selectTemporary(). * @param date The date to be selected. */ public void setSelectedDate(@NonNull Calendar date) { final int count = getAdapter().getCount() - 1; int itemPosition = -1; for(int i=0; i<count; i++) { if(getAdapter().getItem(i).equals(date)) { // because DateItem deeply compares to calendar itemPosition = i; break; } } if(itemPosition >= 0) setSelection(itemPosition); else if(showWeekdayNames) { final long MILLIS_IN_DAY = 1000*60*60*24; final long dateDifference = (date.getTimeInMillis()/MILLIS_IN_DAY) - (Calendar.getInstance().getTimeInMillis()/MILLIS_IN_DAY); if(dateDifference>0 && dateDifference<7) { // if the date is within the next week: // construct a temporary DateItem to select: final int day = date.get(Calendar.DAY_OF_WEEK); // Because these items are always temporarily selected, we can safely assume that // they will never appear in the spinner dropdown. When a FLAG_NUMBERS is set, we // want these items to have the date as secondary text in a short format. selectTemporary(new DateItem(getWeekDay(day, R.string.date_only_weekday), formatSecondaryDate(date), date, NO_ID)); } else { // show the date as a full text, using the current DateFormat: selectTemporary(new DateItem(formatDate(date), date, NO_ID)); } } else { // show the date as a full text, using the current DateFormat: selectTemporary(new DateItem(formatDate(date), date, NO_ID)); } } private String formatDate(@NonNull Calendar date) { if(customDateFormat == null) return DateUtils.formatDateTime(getContext(), date.getTimeInMillis(), DateUtils.FORMAT_SHOW_DATE); else return customDateFormat.format(date.getTime()); } // only to be used when FLAG_NUMBERS and FLAG_WEEKDAY_NAMES have been set private String formatSecondaryDate(@NonNull Calendar date) { if(secondaryDateFormat == null) return DateUtils.formatDateTime(getContext(), date.getTimeInMillis(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NUMERIC_DATE); else return secondaryDateFormat.format(date.getTime()); } /** * Gets the custom DateFormat currently used to format Calendar strings. * If {@link #setDateFormat(java.text.DateFormat)} has not been called yet, it will return null. * @return The date format, or null if the Spinner is using the default date format. */ public java.text.DateFormat getCustomDateFormat() { return customDateFormat; } /** * Sets the custom date format to use for formatting Calendar objects to displayable strings. * @param dateFormat The new DateFormat, or null to use the default format. */ public void setDateFormat(java.text.DateFormat dateFormat) { setDateFormat(dateFormat, null); } /** * Sets the custom date format to use for formatting Calendar objects to displayable strings. * @param dateFormat The new DateFormat, or null to use the default format. * @param numbersDateFormat The DateFormat for formatting the secondary date when both FLAG_NUMBERS * and FLAG_WEEKDAY_NAMES are set, or null to use the default format. */ public void setDateFormat(java.text.DateFormat dateFormat, java.text.DateFormat numbersDateFormat) { this.customDateFormat = dateFormat; this.secondaryDateFormat = numbersDateFormat; // update the spinner with the new date format: // the only spinner item that will be affected is the month item, so just toggle the flag twice // instead of rebuilding the whole adapter if(showMonthItem) { int monthPosition = getAdapterItemPosition(4); boolean reselectMonthItem = getSelectedItemPosition() == monthPosition; setShowMonthItem(false); setShowMonthItem(true); if(reselectMonthItem) setSelection(monthPosition); } // if we have a temporary date item selected, update that as well if(getSelectedItemPosition() == getAdapter().getCount()) setSelectedDate(getSelectedDate()); } /** * Sets the minimum allowed date. * Spinner items and dates in the date picker before the given date will get disabled. * @param minDate The minimum date, or null to clear the previous min date. */ public void setMinDate(@Nullable Calendar minDate) { this.minDate = minDate; // update the date picker (even if it is not used right now) if(minDate == null) datePickerDialog.setMinDate(MINIMUM_POSSIBLE_DATE); else if(maxDate != null && compareCalendarDates(minDate, maxDate) > 0) throw new IllegalArgumentException("Minimum date must be before maximum date!"); else datePickerDialog.setMinDate(new CalendarDay(minDate)); updateEnabledItems(); } /** * Gets the current minimum allowed date. * @return The minimum date, or null if there is none. */ public @Nullable Calendar getMinDate() { return minDate; } /** * Sets the maximum allowed date. * Spinner items and dates in the date picker after the given date will get disabled. * @param maxDate The maximum date, or null to clear the previous max date. */ public void setMaxDate(@Nullable Calendar maxDate) { this.maxDate = maxDate; // update the date picker (even if it is not used right now) if(maxDate == null) datePickerDialog.setMaxDate(null); else if(minDate != null && compareCalendarDates(minDate, maxDate) > 0) throw new IllegalArgumentException("Maximum date must be after minimum date!"); else datePickerDialog.setMaxDate(new CalendarDay(maxDate)); updateEnabledItems(); } /** * Gets the current maximum allowed date. * @return The maximum date, or null if there is none. */ public @Nullable Calendar getMaxDate() { return maxDate; } /** * Loops through the Spinner items and disables all that are not within the min/max date range. */ private void updateEnabledItems() { PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); // if the current item is out of range, we have no choice but to reset it if(!isInDateRange(getSelectedDate())) { final Calendar today = Calendar.getInstance(); if(isInDateRange(today)) setSelectedDate(today); else // if today itself is not a valid date, we will just use the minimum date (which is always set here) setSelectedDate(minDate); } for(int position = getLastItemPosition(); position >= 0; position--) { DateItem item = (DateItem) adapter.getItem(position); if(isInDateRange(item.getDate())) item.setEnabled(true); else item.setEnabled(false); } } private boolean isInDateRange(@NonNull Calendar date) { return (minDate == null || compareCalendarDates(minDate, date) <= 0) // later than minDate && (maxDate == null || compareCalendarDates(maxDate, date) >= 0); // before maxDate } /** * Compares the two given Calendar objects, only counting the date, not time. * @return -1 if first comes before second, 0 if both are the same day, 1 if second is before first. */ static int compareCalendarDates(@NonNull Calendar first, @NonNull Calendar second) { final int firstYear = first.get(Calendar.YEAR); final int secondYear = second.get(Calendar.YEAR); final int firstDay = first.get(Calendar.DAY_OF_YEAR); final int secondDay = second.get(Calendar.DAY_OF_YEAR); if(firstYear == secondYear) { if(firstDay == secondDay) return 0; else if(firstDay < secondDay) return -1; else return 1; } else if(firstYear < secondYear) return -1; else return 1; } /** * Implement this interface if you want to be notified whenever the selected date changes. */ public void setOnDateSelectedListener(OnDateSelectedListener listener) { this.dateListener = listener; } /** * Gets the default {@link DatePickerDialog} that is shown when the footer is clicked. * @return The dialog, or null if a custom date picker has been set and the default one is thus unused. */ public @Nullable DatePickerDialog getDatePickerDialog() { if(customDatePicker != null) return null; return datePickerDialog; } /** * Sets a custom listener whose onClick method will be called to create and handle the custom date picker. * You should call {@link #setSelectedDate} when the custom picker is finished. * @param launchPicker An {@link android.view.View.OnClickListener} whose onClick method will be * called to show the custom date picker, or null to use the default picker. */ public void setCustomDatePicker(@Nullable OnClickListener launchPicker) { this.customDatePicker = launchPicker; } /** * Toggles showing the past items. Past mode shows the yesterday and last weekday item. * @param enable True to enable, false to disable past mode. */ public void setShowPastItems(boolean enable) { if(enable && !showPastItems) { // first reset the minimum date if necessary: if(getMinDate() != null && compareCalendarDates(getMinDate(), Calendar.getInstance()) == 0) setMinDate(null); // create the yesterday and last Monday item: final Resources res = getResources(); final Calendar date = Calendar.getInstance(); // yesterday: date.add(Calendar.DAY_OF_YEAR, -1); insertAdapterItem(new DateItem(res.getString(R.string.date_yesterday), date, R.id.date_yesterday), 0); // last weekday item: date.add(Calendar.DAY_OF_YEAR, -6); int weekday = date.get(Calendar.DAY_OF_WEEK); insertAdapterItem(new DateItem(getWeekDay(weekday, R.string.date_last_weekday), date, R.id.date_last_week), 0); } else if(!enable && showPastItems) { // delete the yesterday and last weekday items: removeAdapterItemById(R.id.date_last_week); removeAdapterItemById(R.id.date_yesterday); // we set the minimum date to today as we don't allow past items setMinDate(Calendar.getInstance()); } showPastItems = enable; } /** * Toggles showing the month item. Month mode an item in exactly one month from now. * @param enable True to enable, false to disable month mode. */ public void setShowMonthItem(boolean enable) { if(enable && !showMonthItem) { // create the in 1 month item final Calendar date = Calendar.getInstance(); date.add(Calendar.MONTH, 1); addAdapterItem(new DateItem(formatDate(date), date, R.id.date_month)); } else if(!enable && showMonthItem) { removeAdapterItemById(R.id.date_month); } showMonthItem = enable; } /** * Toggles showing the weekday names instead of dates for the next week. Turning this on will * display e.g. "Sunday" for the day after tomorrow, otherwise it'll be January 1. * @param enable True to enable, false to disable weekday names. */ public void setShowWeekdayNames(boolean enable) { if(showWeekdayNames != enable) { showWeekdayNames = enable; // if FLAG_NUMBERS has been set, toggle the secondary text in the adapter if(showNumbersInView) setShowNumbersInViewInt(enable); // reselect the current item so it will use the new setting: setSelectedDate(getSelectedDate()); } } /** * Toggles showing numeric dates for the weekday items in the spinner view. This will only apply * when a day within the next week is selected and FLAG_WEEKDAY_NAMES has been set, not in the dropdown. * @param enable True to enable, false to disable numeric mode. */ public void setShowNumbersInView(boolean enable) { showNumbersInView = enable; // only enable the adapter when FLAG_WEEKDAY_NAMES has been set as well if(!enable || showWeekdayNames) setShowNumbersInViewInt(enable); } private void setShowNumbersInViewInt(boolean enable) { PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); // workaround for now. See GitHub issue #2 if (enable != adapter.isShowingSecondaryTextInView() && adapter.getCount() == getSelectedItemPosition()) setSelection(0); adapter.setShowSecondaryTextInView(enable); } /** * Set the flags to use for this date spinner. * @param modeOrFlags A mode of ReminderDatePicker.MODE_... or multiple ReminderDatePicker.FLAG_... * combined with the | operator. */ public void setFlags(int modeOrFlags) { setShowPastItems((modeOrFlags & ReminderDatePicker.FLAG_PAST) != 0); setShowMonthItem((modeOrFlags & ReminderDatePicker.FLAG_MONTH) != 0); setShowWeekdayNames((modeOrFlags & ReminderDatePicker.FLAG_WEEKDAY_NAMES) != 0); setShowNumbersInView((modeOrFlags & ReminderDatePicker.FLAG_NUMBERS) != 0); } /** * {@inheritDoc} */ @Override public void removeAdapterItemAt(int index) { if(index == getSelectedItemPosition()) { Calendar date = getSelectedDate(); selectTemporary(new DateItem(formatDate(date), date, NO_ID)); } super.removeAdapterItemAt(index); } @Override public CharSequence getFooter() { return getResources().getString(R.string.spinner_date_footer); } @Override public void onFooterClick() { if (customDatePicker == null) { // update the selected date in the dialog final Calendar date = getSelectedDate(); datePickerDialog.onDateSelected( date.get(Calendar.YEAR), date.get(Calendar.MONTH), date.get(Calendar.DAY_OF_MONTH)); datePickerDialog.show(fragmentManager, "DatePickerDialog"); } else { customDatePicker.onClick(this); } } @Override protected void restoreTemporarySelection(String codeString) { selectTemporary(DateItem.fromString(codeString)); } @Override public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { if(dateListener != null) { // catch selecting same date twice Calendar date = getSelectedDate(); if(date != null && !date.equals(lastSelectedDate)) { dateListener.onDateSelected(date); lastSelectedDate = date; } } } // unused @Override public void onNothingSelected(AdapterView<?> parent) { } }