diff --git a/.gitignore b/.gitignore
index 777dec08..8d5bc0b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,9 @@ DerivedData
*.xcuserstate
project.xcworkspace
+#Detox
+#
+artifacts
# Android/IntelliJ
#
@@ -37,6 +40,14 @@ build/
.gradle
local.properties
*.iml
+/example/android/app/.settings
+/example/android/app/.project
+/example/android/app/.classpath
+/example/android/.settings
+/example/android/.project
+/android/.settings
+/android/.project
+/android/.classpath
# BUCK
buck-out/
diff --git a/README.md b/README.md
index ed24d0e9..08ae0151 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@ React Native date & time picker component for iOS and Android
- [`locale` (`optional`, `iOS only`)](#locale-optional-ios-only)
- [`is24Hour` (`optional`, `Android only`)](#is24hour-optional-android-only)
- [`neutralButtonLabel` (`optional`, `Android only`)](#neutralbuttonlabel-optional-android-only)
- - [`minuteInterval` (`optional`, `iOS only`)](#minuteinterval-optional-ios-only)
+ - [`minuteInterval` (`optional`)](#minuteinterval-optional-ios-only)
- [`style` (`optional`, `iOS only`)](#style-optional-ios-only)
- [Migration from the older components](#migration-from-the-older-components)
- [DatePickerIOS](#datepickerios)
@@ -312,7 +312,7 @@ Pressing button can be observed in onChange handler as `event.type === 'neutralB
```
-#### `minuteInterval` (`optional`, `iOS only`)
+#### `minuteInterval` (`optional`)
The interval at which minutes can be selected.
Possible values are: `1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30`
diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/MinuteIntervalSnappableTimePickerDialog.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/MinuteIntervalSnappableTimePickerDialog.java
new file mode 100644
index 00000000..7357339d
--- /dev/null
+++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/MinuteIntervalSnappableTimePickerDialog.java
@@ -0,0 +1,264 @@
+package com.reactcommunity.rndatetimepicker;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import android.annotation.SuppressLint;
+import android.app.TimePickerDialog;
+import android.content.DialogInterface;
+import android.content.Context;
+import android.os.Handler;
+import android.widget.TimePicker;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.NumberPicker;
+
+class MinuteIntervalSnappableTimePickerDialog extends TimePickerDialog {
+ private TimePicker mTimePicker;
+ private int mTimePickerInterval;
+ private RNTimePickerDisplay mDisplay;
+ private final OnTimeSetListener mTimeSetListener;
+ private Handler handler = new Handler();
+ private Runnable runnable;
+ private Context mContext;
+
+ public MinuteIntervalSnappableTimePickerDialog(
+ Context context,
+ OnTimeSetListener listener,
+ int hourOfDay,
+ int minute,
+ int minuteInterval,
+ boolean is24HourView,
+ RNTimePickerDisplay display
+ ) {
+ super(context, listener, hourOfDay, minute, is24HourView);
+ mTimePickerInterval = minuteInterval;
+ mTimeSetListener = listener;
+ mDisplay = display;
+ mContext = context;
+ }
+
+ public MinuteIntervalSnappableTimePickerDialog(
+ Context context,
+ int theme,
+ OnTimeSetListener listener,
+ int hourOfDay,
+ int minute,
+ int minuteInterval,
+ boolean is24HourView,
+ RNTimePickerDisplay display
+ ) {
+ super(context, theme, listener, hourOfDay, minute, is24HourView);
+ mTimePickerInterval = minuteInterval;
+ mTimeSetListener = listener;
+ mDisplay = display;
+ mContext = context;
+ }
+
+ public static boolean isValidMinuteInterval(int interval) {
+ return interval >= 1 && interval <= 30 && 60 % interval == 0;
+ }
+
+ private boolean timePickerHasCustomMinuteInterval() {
+ return mTimePickerInterval != RNConstants.DEFAULT_TIME_PICKER_INTERVAL;
+ }
+
+ private boolean isSpinner() {
+ return mDisplay == RNTimePickerDisplay.SPINNER;
+ }
+
+ /**
+ * Converts values returned from picker to actual minutes
+ *
+ * @param minutesOrSpinnerIndex the internal value of what the user had selected
+ * @return returns 'real' minutes (0-59)
+ */
+ private int getRealMinutes(int minutesOrSpinnerIndex) {
+ if (mDisplay == RNTimePickerDisplay.SPINNER) {
+ return minutesOrSpinnerIndex * mTimePickerInterval;
+ }
+
+ return minutesOrSpinnerIndex;
+ }
+
+ private int getRealMinutes() {
+ int minute = mTimePicker.getCurrentMinute();
+ return getRealMinutes(minute);
+ }
+
+ /**
+ * 'Snaps' real minutes or spinner value index to nearest valid value
+ * in spinner mode you need to make sure to transform the picked value (which is an index)
+ * to a real value before passing!
+ *
+ * @param realMinutes 'real' minutes (0-59)
+ * @return nearest valid real minute
+ */
+ private int snapRealMinutesToInterval(int realMinutes) {
+ float stepsInMinutes = (float) realMinutes / (float) mTimePickerInterval;
+
+ int rounded = Math.round(stepsInMinutes) * mTimePickerInterval;
+ return rounded == 60 ? rounded - mTimePickerInterval : rounded;
+ }
+
+ private void assertNotSpinner(String s) {
+ if (isSpinner()) {
+ throw new RuntimeException(s);
+ }
+ }
+
+ /**
+ * Determines if picked real minutes are ok with the minuteInterval
+ *
+ * @param realMinutes 'real' minutes (0-59)
+ */
+ private boolean minutesNeedCorrection(int realMinutes) {
+ assertNotSpinner("minutesNeedCorrection is not intended to be used with spinner, spinner won't allow picking invalid values");
+
+ return timePickerHasCustomMinuteInterval() && realMinutes != snapRealMinutesToInterval(realMinutes);
+ }
+
+ /**
+ * Determines if the picker is in text input mode (keyboard icon in 'clock' mode)
+ */
+ private boolean pickerIsInTextInputMode() {
+ int textInputPickerId = mContext.getResources().getIdentifier("input_mode", "id", "android");
+ final View textInputPicker = this.findViewById(textInputPickerId);
+
+ return textInputPicker != null && textInputPicker.hasFocus();
+ }
+
+ /**
+ * Corrects minute values if they don't align with minuteInterval
+ *
+ * in text input mode, correction will be postponed slightly to let the user finish the input
+ * in clock mode we also delay it to give user visual cue about the correction
+ *
+ *
+ * @param view the picker's view
+ * @param hourOfDay the picker's selected hours
+ * @param correctedMinutes 'real' minutes (0-59) aligned to minute interval
+ */
+ private void correctEnteredMinutes(final TimePicker view, final int hourOfDay, final int correctedMinutes) {
+ assertNotSpinner("spinner never needs to be corrected because wrong values are not offered to user (both in scrolling and textInput mode)!");
+ final EditText textInput = (EditText) view.findFocus();
+
+ // 'correction' callback
+ runnable = new Runnable() {
+ @Override
+ public void run() {
+ if (pickerIsInTextInputMode()) {
+ // set valid minutes && move caret to the end of input
+ view.setCurrentHour(hourOfDay);
+ view.setCurrentMinute(correctedMinutes);
+ textInput.setSelection(textInput.getText().length());
+ } else {
+ view.setCurrentHour(hourOfDay);
+ // we need to set minutes to 0 for this to work on older android devices
+ view.setCurrentMinute(0);
+ view.setCurrentMinute(correctedMinutes);
+ }
+ }
+ };
+
+ handler.postDelayed(runnable, 500);
+ }
+
+ @Override
+ public void onTimeChanged(final TimePicker view, final int hourOfDay, final int minute) {
+ final int realMinutes = getRealMinutes(minute);
+ // *always* remove pending 'validation' callbacks, otherwise a valid value might be rewritten
+ handler.removeCallbacks(runnable);
+
+ if (!isSpinner() && minutesNeedCorrection(realMinutes)) {
+ int correctedMinutes = snapRealMinutesToInterval(realMinutes);
+
+ // will fire another onTimeChanged
+ correctEnteredMinutes(view, hourOfDay, correctedMinutes);
+ } else {
+ super.onTimeChanged(view, hourOfDay, minute);
+ }
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (mTimePicker != null && which == BUTTON_POSITIVE && timePickerHasCustomMinuteInterval()) {
+ final int hours = mTimePicker.getCurrentHour();
+
+ final int realMinutes = getRealMinutes();
+ int validMinutes = isSpinner() ? realMinutes : snapRealMinutesToInterval(realMinutes);
+
+ if (mTimeSetListener != null) {
+ mTimeSetListener.onTimeSet(mTimePicker, hours, validMinutes);
+ }
+ } else {
+ super.onClick(dialog, which);
+ }
+ }
+
+ @Override
+ public void updateTime(int hourOfDay, int minuteOfHour) {
+ if (timePickerHasCustomMinuteInterval()) {
+ if (isSpinner()) {
+ final int realMinutes = getRealMinutes();
+ int selectedIndex = snapRealMinutesToInterval(realMinutes) / mTimePickerInterval;
+ super.updateTime(hourOfDay, selectedIndex);
+ } else {
+ super.updateTime(hourOfDay, snapRealMinutesToInterval(minuteOfHour));
+ }
+ } else {
+ super.updateTime(hourOfDay, minuteOfHour);
+ }
+ }
+
+ /**
+ * Apply visual style in 'spinner' mode
+ * Adjust minutes to correspond selected interval
+ */
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (timePickerHasCustomMinuteInterval()) {
+ setupPickerDialog();
+ }
+ }
+
+ private void setupPickerDialog() {
+ int timePickerId = mContext.getResources().getIdentifier("timePicker", "id", "android");
+ mTimePicker = this.findViewById(timePickerId);
+
+ int realMinuteBackup = mTimePicker.getCurrentMinute();
+
+ if (isSpinner()) {
+ setSpinnerDisplayedValues();
+ int selectedIndex = snapRealMinutesToInterval(realMinuteBackup) / mTimePickerInterval;
+ mTimePicker.setCurrentMinute(selectedIndex);
+ } else {
+ int snappedRealMinute = snapRealMinutesToInterval(realMinuteBackup);
+ mTimePicker.setCurrentMinute(snappedRealMinute);
+ }
+ }
+
+ @SuppressLint("DefaultLocale")
+ private void setSpinnerDisplayedValues() {
+ int minutePickerId = mContext.getResources().getIdentifier("minute", "id", "android");
+ NumberPicker minutePicker = this.findViewById(minutePickerId);
+
+ minutePicker.setMinValue(0);
+ minutePicker.setMaxValue((60 / mTimePickerInterval) - 1);
+
+ List displayedValues = new ArrayList<>(60 / mTimePickerInterval);
+ for (int displayedMinute = 0; displayedMinute < 60; displayedMinute += mTimePickerInterval) {
+ displayedValues.add(String.format("%02d", displayedMinute));
+ }
+
+ minutePicker.setDisplayedValues(displayedValues.toArray(new String[0]));
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ handler.removeCallbacks(runnable);
+ super.onDetachedFromWindow();
+ }
+}
diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java
index b14cd462..ee090fcc 100644
--- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java
+++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java
@@ -5,6 +5,7 @@ public final class RNConstants {
public static final String ARG_VALUE = "value";
public static final String ARG_MINDATE = "minimumDate";
public static final String ARG_MAXDATE = "maximumDate";
+ public static final String ARG_INTERVAL = "minuteInterval";
public static final String ARG_IS24HOUR = "is24Hour";
public static final String ARG_DISPLAY = "display";
public static final String ARG_NEUTRAL_BUTTON_LABEL = "neutralButtonLabel";
@@ -17,4 +18,9 @@ public final class RNConstants {
* Minimum date supported by {@link DatePicker}, 01 Jan 1900
*/
public static final long DEFAULT_MIN_DATE = -2208988800001l;
+
+ /**
+ * Minimum and default time picker minute interval
+ */
+ public static final int DEFAULT_TIME_PICKER_INTERVAL = 1;
}
diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDismissableTimePickerDialog.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDismissableTimePickerDialog.java
index f77deb0f..073bd8d1 100644
--- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDismissableTimePickerDialog.java
+++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDismissableTimePickerDialog.java
@@ -1,58 +1,65 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
- *
+ *
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
+ *
- * Certain versions of Android (Jellybean-KitKat) have a bug where when dismissed, the
- * {@link TimePickerDialog} still calls the OnTimeSetListener. This class works around that issue
- * by *not* calling super.onStop on KitKat on lower, as that would erroneously call the
- * OnTimeSetListener when the dialog is dismissed, or call it twice when "OK" is pressed.
+ * Certain versions of Android (Jellybean-KitKat) have a bug where when dismissed, the
+ * {@link TimePickerDialog} still calls the OnTimeSetListener. This class works around that issue
+ * by *not* calling super.onStop on KitKat on lower, as that would erroneously call the
+ * OnTimeSetListener when the dialog is dismissed, or call it twice when "OK" is pressed.
*