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. + *

*/ package com.reactcommunity.rndatetimepicker; +import static com.reactcommunity.rndatetimepicker.ReflectionHelper.findField; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; + import android.app.TimePickerDialog; import android.content.Context; import android.content.res.TypedArray; import android.os.Build; import android.util.AttributeSet; import android.widget.TimePicker; -import androidx.annotation.Nullable; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import static com.reactcommunity.rndatetimepicker.ReflectionHelper.findField; +import androidx.annotation.Nullable; /** *

- * 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. *

* *

- * See: Issue 34833 + * See: Issue 34833 *

*/ -public class RNDismissableTimePickerDialog extends TimePickerDialog { + +public class RNDismissableTimePickerDialog extends MinuteIntervalSnappableTimePickerDialog { public RNDismissableTimePickerDialog( - Context context, - @Nullable TimePickerDialog.OnTimeSetListener callback, - int hourOfDay, - int minute, - boolean is24HourView, - RNTimePickerDisplay display) { - super(context, callback, hourOfDay, minute, is24HourView); + Context context, + @Nullable TimePickerDialog.OnTimeSetListener callback, + int hourOfDay, + int minute, + int minuteInterval, + boolean is24HourView, + RNTimePickerDisplay display + ) { + super(context, callback, hourOfDay, minute, minuteInterval, is24HourView, display); fixSpinner(context, hourOfDay, minute, is24HourView, display); } public RNDismissableTimePickerDialog( - Context context, - int theme, - @Nullable TimePickerDialog.OnTimeSetListener callback, - int hourOfDay, - int minute, - boolean is24HourView, - RNTimePickerDisplay display) { - super(context, theme, callback, hourOfDay, minute, is24HourView); + Context context, + int theme, + @Nullable TimePickerDialog.OnTimeSetListener callback, + int hourOfDay, + int minute, + int minuteInterval, + boolean is24HourView, + RNTimePickerDisplay display + ) { + super(context, theme, callback, hourOfDay, minute, minuteInterval, is24HourView, display); fixSpinner(context, hourOfDay, minute, is24HourView, display); } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogFragment.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogFragment.java index d35a396a..0df49a20 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogFragment.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogFragment.java @@ -1,8 +1,9 @@ /** * 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. + *

*/ package com.reactcommunity.rndatetimepicker; @@ -16,9 +17,10 @@ import android.content.DialogInterface.OnClickListener; import android.os.Build; import android.os.Bundle; +import android.text.format.DateFormat; + import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; -import android.text.format.DateFormat; import java.util.Locale; @@ -55,6 +57,11 @@ static TimePickerDialog getDialog( final int minute = date.minute(); boolean is24hour = DateFormat.is24HourFormat(activityContext); + int minuteInterval = RNConstants.DEFAULT_TIME_PICKER_INTERVAL; + if (args != null && MinuteIntervalSnappableTimePickerDialog.isValidMinuteInterval(args.getInt(RNConstants.ARG_INTERVAL))) { + minuteInterval = args.getInt(RNConstants.ARG_INTERVAL); + } + RNTimePickerDisplay display = RNTimePickerDisplay.DEFAULT; if (args != null && args.getString(RNConstants.ARG_DISPLAY, null) != null) { display = RNTimePickerDisplay.valueOf(args.getString(RNConstants.ARG_DISPLAY).toUpperCase(Locale.US)); @@ -81,18 +88,20 @@ static TimePickerDialog getDialog( onTimeSetListener, hour, minute, + minuteInterval, is24hour, display ); } } return new RNDismissableTimePickerDialog( - activityContext, - onTimeSetListener, - hour, - minute, - is24hour, - display + activityContext, + onTimeSetListener, + hour, + minute, + minuteInterval, + is24hour, + display ); } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogModule.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogModule.java index 41ea573a..4f14c6f8 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogModule.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDialogModule.java @@ -1,8 +1,9 @@ /** * 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. + *

*/ package com.reactcommunity.rndatetimepicker; @@ -17,6 +18,7 @@ import android.content.DialogInterface.OnClickListener; import android.os.Bundle; import android.widget.TimePicker; + import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; @@ -87,8 +89,8 @@ public void open(@Nullable final ReadableMap options, Promise promise) { FragmentActivity activity = (FragmentActivity) getCurrentActivity(); if (activity == null) { promise.reject( - RNConstants.ERROR_NO_ACTIVITY, - "Tried to open a TimePicker dialog while not attached to an Activity"); + RNConstants.ERROR_NO_ACTIVITY, + "Tried to open a TimePicker dialog while not attached to an Activity"); return; } // We want to support both android.app.Activity and the pre-Honeycomb FragmentActivity @@ -134,6 +136,9 @@ private Bundle createFragmentArguments(ReadableMap options) { if (options.hasKey(RNConstants.ARG_NEUTRAL_BUTTON_LABEL) && !options.isNull(RNConstants.ARG_NEUTRAL_BUTTON_LABEL)) { args.putString(RNConstants.ARG_NEUTRAL_BUTTON_LABEL, options.getString(RNConstants.ARG_NEUTRAL_BUTTON_LABEL)); } + if (options.hasKey(RNConstants.ARG_INTERVAL) && !options.isNull(RNConstants.ARG_INTERVAL)) { + args.putInt(RNConstants.ARG_INTERVAL, options.getInt(RNConstants.ARG_INTERVAL)); + } return args; } } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDisplay.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDisplay.java index 32df4b88..eb9572c5 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDisplay.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNTimePickerDisplay.java @@ -1,8 +1,9 @@ /** * 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. + *

*/ package com.reactcommunity.rndatetimepicker; diff --git a/example/App.js b/example/App.js index 7abd59ad..970348c1 100644 --- a/example/App.js +++ b/example/App.js @@ -11,7 +11,7 @@ import { useColorScheme, } from 'react-native'; import DateTimePicker from '@react-native-community/datetimepicker'; -import {Header, Colors} from 'react-native/Libraries/NewAppScreen'; +import {Colors} from 'react-native/Libraries/NewAppScreen'; import React, {useState} from 'react'; import {Picker} from 'react-native-windows'; import moment from 'moment'; @@ -34,6 +34,7 @@ export const App = () => { const [show, setShow] = useState(false); const [color, setColor] = useState(); const [display, setDisplay] = useState('default'); + const [interval, setMinInterval] = useState(undefined); // Windows-specific const [maxDate, setMinDate] = useState(new Date('2021')); @@ -80,6 +81,18 @@ export const App = () => { setDisplay('spinner'); }; + const showTimepickerClockModeWithInterval = () => { + showMode('time'); + setMinInterval(5); + setDisplay('clock'); + }; + + const showTimepickerSpinnerWithInterval = () => { + showMode('time'); + setMinInterval(5); + setDisplay('spinner'); + }; + const isDarkMode = useColorScheme() === 'dark'; const backgroundStyle = { @@ -91,7 +104,6 @@ export const App = () => { -
{global.HermesInternal != null && ( @@ -150,10 +162,27 @@ export const App = () => { title="Show time picker spinner!" /> + +