Skip to content

Commit c6c6738

Browse files
luancurtievgeniy-ferapontovBenderBRodrigezvonovak
authored
feat: add minute interval on Android (#177)
* minuteInterval types * minuteInterval android prop pass-through * implemented custom picker and minuteInterval interface * ignore intellij files * propper onTimeChanged * refactored android picker * fixed incorrect time value setting * force set spinner mode if setting interval more than 5 min because of incompatibility * get rid of reflection api * updated readme * added dependencies repos, upgraded gradle version * fixed incorrectly displayed minutes when interval is set * indentation and tests * updated readme * added docs + correct handling in textinput mode * fixed get resources in RNDismissableTimePickerDialog * separated CustomTimePickerDialog * fix detox prettier errors * make minute interval work with timepicker default * fix add text color again o ts types * remove note force timepicker mode spinner * fix detox tests * remove minute interval options mutation * merge minute interval android/ios type * run attached window custom code only minute interval is set * refactor onClick and onAttachedWindow * wait datepicker to be visible with timeout * remove/undo some small changes * reactor minuteinterval * fix time picker on older androids * e2e tests wip * add e2e tests * rename TimePickerDialog * add forgotten folder * make more space * fix minuteInterval assignment * minor polish * fix isValidMinuteInterval Co-authored-by: Evgeniy Ferapontov <[email protected]> Co-authored-by: BenderBRodrigez <[email protected]> Co-authored-by: Vojtech Novak <[email protected]>
1 parent 3536dec commit c6c6738

17 files changed

+500
-64
lines changed

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ DerivedData
2929
*.xcuserstate
3030
project.xcworkspace
3131

32+
#Detox
33+
#
34+
artifacts
3235

3336
# Android/IntelliJ
3437
#
@@ -37,6 +40,14 @@ build/
3740
.gradle
3841
local.properties
3942
*.iml
43+
/example/android/app/.settings
44+
/example/android/app/.project
45+
/example/android/app/.classpath
46+
/example/android/.settings
47+
/example/android/.project
48+
/android/.settings
49+
/android/.project
50+
/android/.classpath
4051

4152
# BUCK
4253
buck-out/

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ React Native date & time picker component for iOS and Android
4949
- [`locale` (`optional`, `iOS only`)](#locale-optional-ios-only)
5050
- [`is24Hour` (`optional`, `Android only`)](#is24hour-optional-android-only)
5151
- [`neutralButtonLabel` (`optional`, `Android only`)](#neutralbuttonlabel-optional-android-only)
52-
- [`minuteInterval` (`optional`, `iOS only`)](#minuteinterval-optional-ios-only)
52+
- [`minuteInterval` (`optional`)](#minuteinterval-optional-ios-only)
5353
- [`style` (`optional`, `iOS only`)](#style-optional-ios-only)
5454
- [Migration from the older components](#migration-from-the-older-components)
5555
- [DatePickerIOS](#datepickerios)
@@ -312,7 +312,7 @@ Pressing button can be observed in onChange handler as `event.type === 'neutralB
312312
<RNDateTimePicker neutralButtonLabel="clear" />
313313
```
314314

315-
#### `minuteInterval` (`optional`, `iOS only`)
315+
#### `minuteInterval` (`optional`)
316316

317317
The interval at which minutes can be selected.
318318
Possible values are: `1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30`
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
package com.reactcommunity.rndatetimepicker;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
6+
import android.annotation.SuppressLint;
7+
import android.app.TimePickerDialog;
8+
import android.content.DialogInterface;
9+
import android.content.Context;
10+
import android.os.Handler;
11+
import android.widget.TimePicker;
12+
import android.view.View;
13+
import android.widget.EditText;
14+
import android.widget.NumberPicker;
15+
16+
class MinuteIntervalSnappableTimePickerDialog extends TimePickerDialog {
17+
private TimePicker mTimePicker;
18+
private int mTimePickerInterval;
19+
private RNTimePickerDisplay mDisplay;
20+
private final OnTimeSetListener mTimeSetListener;
21+
private Handler handler = new Handler();
22+
private Runnable runnable;
23+
private Context mContext;
24+
25+
public MinuteIntervalSnappableTimePickerDialog(
26+
Context context,
27+
OnTimeSetListener listener,
28+
int hourOfDay,
29+
int minute,
30+
int minuteInterval,
31+
boolean is24HourView,
32+
RNTimePickerDisplay display
33+
) {
34+
super(context, listener, hourOfDay, minute, is24HourView);
35+
mTimePickerInterval = minuteInterval;
36+
mTimeSetListener = listener;
37+
mDisplay = display;
38+
mContext = context;
39+
}
40+
41+
public MinuteIntervalSnappableTimePickerDialog(
42+
Context context,
43+
int theme,
44+
OnTimeSetListener listener,
45+
int hourOfDay,
46+
int minute,
47+
int minuteInterval,
48+
boolean is24HourView,
49+
RNTimePickerDisplay display
50+
) {
51+
super(context, theme, listener, hourOfDay, minute, is24HourView);
52+
mTimePickerInterval = minuteInterval;
53+
mTimeSetListener = listener;
54+
mDisplay = display;
55+
mContext = context;
56+
}
57+
58+
public static boolean isValidMinuteInterval(int interval) {
59+
return interval >= 1 && interval <= 30 && 60 % interval == 0;
60+
}
61+
62+
private boolean timePickerHasCustomMinuteInterval() {
63+
return mTimePickerInterval != RNConstants.DEFAULT_TIME_PICKER_INTERVAL;
64+
}
65+
66+
private boolean isSpinner() {
67+
return mDisplay == RNTimePickerDisplay.SPINNER;
68+
}
69+
70+
/**
71+
* Converts values returned from picker to actual minutes
72+
*
73+
* @param minutesOrSpinnerIndex the internal value of what the user had selected
74+
* @return returns 'real' minutes (0-59)
75+
*/
76+
private int getRealMinutes(int minutesOrSpinnerIndex) {
77+
if (mDisplay == RNTimePickerDisplay.SPINNER) {
78+
return minutesOrSpinnerIndex * mTimePickerInterval;
79+
}
80+
81+
return minutesOrSpinnerIndex;
82+
}
83+
84+
private int getRealMinutes() {
85+
int minute = mTimePicker.getCurrentMinute();
86+
return getRealMinutes(minute);
87+
}
88+
89+
/**
90+
* 'Snaps' real minutes or spinner value index to nearest valid value
91+
* in spinner mode you need to make sure to transform the picked value (which is an index)
92+
* to a real value before passing!
93+
*
94+
* @param realMinutes 'real' minutes (0-59)
95+
* @return nearest valid real minute
96+
*/
97+
private int snapRealMinutesToInterval(int realMinutes) {
98+
float stepsInMinutes = (float) realMinutes / (float) mTimePickerInterval;
99+
100+
int rounded = Math.round(stepsInMinutes) * mTimePickerInterval;
101+
return rounded == 60 ? rounded - mTimePickerInterval : rounded;
102+
}
103+
104+
private void assertNotSpinner(String s) {
105+
if (isSpinner()) {
106+
throw new RuntimeException(s);
107+
}
108+
}
109+
110+
/**
111+
* Determines if picked real minutes are ok with the minuteInterval
112+
*
113+
* @param realMinutes 'real' minutes (0-59)
114+
*/
115+
private boolean minutesNeedCorrection(int realMinutes) {
116+
assertNotSpinner("minutesNeedCorrection is not intended to be used with spinner, spinner won't allow picking invalid values");
117+
118+
return timePickerHasCustomMinuteInterval() && realMinutes != snapRealMinutesToInterval(realMinutes);
119+
}
120+
121+
/**
122+
* Determines if the picker is in text input mode (keyboard icon in 'clock' mode)
123+
*/
124+
private boolean pickerIsInTextInputMode() {
125+
int textInputPickerId = mContext.getResources().getIdentifier("input_mode", "id", "android");
126+
final View textInputPicker = this.findViewById(textInputPickerId);
127+
128+
return textInputPicker != null && textInputPicker.hasFocus();
129+
}
130+
131+
/**
132+
* Corrects minute values if they don't align with minuteInterval
133+
* <p>
134+
* in text input mode, correction will be postponed slightly to let the user finish the input
135+
* in clock mode we also delay it to give user visual cue about the correction
136+
* <p>
137+
*
138+
* @param view the picker's view
139+
* @param hourOfDay the picker's selected hours
140+
* @param correctedMinutes 'real' minutes (0-59) aligned to minute interval
141+
*/
142+
private void correctEnteredMinutes(final TimePicker view, final int hourOfDay, final int correctedMinutes) {
143+
assertNotSpinner("spinner never needs to be corrected because wrong values are not offered to user (both in scrolling and textInput mode)!");
144+
final EditText textInput = (EditText) view.findFocus();
145+
146+
// 'correction' callback
147+
runnable = new Runnable() {
148+
@Override
149+
public void run() {
150+
if (pickerIsInTextInputMode()) {
151+
// set valid minutes && move caret to the end of input
152+
view.setCurrentHour(hourOfDay);
153+
view.setCurrentMinute(correctedMinutes);
154+
textInput.setSelection(textInput.getText().length());
155+
} else {
156+
view.setCurrentHour(hourOfDay);
157+
// we need to set minutes to 0 for this to work on older android devices
158+
view.setCurrentMinute(0);
159+
view.setCurrentMinute(correctedMinutes);
160+
}
161+
}
162+
};
163+
164+
handler.postDelayed(runnable, 500);
165+
}
166+
167+
@Override
168+
public void onTimeChanged(final TimePicker view, final int hourOfDay, final int minute) {
169+
final int realMinutes = getRealMinutes(minute);
170+
// *always* remove pending 'validation' callbacks, otherwise a valid value might be rewritten
171+
handler.removeCallbacks(runnable);
172+
173+
if (!isSpinner() && minutesNeedCorrection(realMinutes)) {
174+
int correctedMinutes = snapRealMinutesToInterval(realMinutes);
175+
176+
// will fire another onTimeChanged
177+
correctEnteredMinutes(view, hourOfDay, correctedMinutes);
178+
} else {
179+
super.onTimeChanged(view, hourOfDay, minute);
180+
}
181+
}
182+
183+
@Override
184+
public void onClick(DialogInterface dialog, int which) {
185+
if (mTimePicker != null && which == BUTTON_POSITIVE && timePickerHasCustomMinuteInterval()) {
186+
final int hours = mTimePicker.getCurrentHour();
187+
188+
final int realMinutes = getRealMinutes();
189+
int validMinutes = isSpinner() ? realMinutes : snapRealMinutesToInterval(realMinutes);
190+
191+
if (mTimeSetListener != null) {
192+
mTimeSetListener.onTimeSet(mTimePicker, hours, validMinutes);
193+
}
194+
} else {
195+
super.onClick(dialog, which);
196+
}
197+
}
198+
199+
@Override
200+
public void updateTime(int hourOfDay, int minuteOfHour) {
201+
if (timePickerHasCustomMinuteInterval()) {
202+
if (isSpinner()) {
203+
final int realMinutes = getRealMinutes();
204+
int selectedIndex = snapRealMinutesToInterval(realMinutes) / mTimePickerInterval;
205+
super.updateTime(hourOfDay, selectedIndex);
206+
} else {
207+
super.updateTime(hourOfDay, snapRealMinutesToInterval(minuteOfHour));
208+
}
209+
} else {
210+
super.updateTime(hourOfDay, minuteOfHour);
211+
}
212+
}
213+
214+
/**
215+
* Apply visual style in 'spinner' mode
216+
* Adjust minutes to correspond selected interval
217+
*/
218+
@Override
219+
public void onAttachedToWindow() {
220+
super.onAttachedToWindow();
221+
222+
if (timePickerHasCustomMinuteInterval()) {
223+
setupPickerDialog();
224+
}
225+
}
226+
227+
private void setupPickerDialog() {
228+
int timePickerId = mContext.getResources().getIdentifier("timePicker", "id", "android");
229+
mTimePicker = this.findViewById(timePickerId);
230+
231+
int realMinuteBackup = mTimePicker.getCurrentMinute();
232+
233+
if (isSpinner()) {
234+
setSpinnerDisplayedValues();
235+
int selectedIndex = snapRealMinutesToInterval(realMinuteBackup) / mTimePickerInterval;
236+
mTimePicker.setCurrentMinute(selectedIndex);
237+
} else {
238+
int snappedRealMinute = snapRealMinutesToInterval(realMinuteBackup);
239+
mTimePicker.setCurrentMinute(snappedRealMinute);
240+
}
241+
}
242+
243+
@SuppressLint("DefaultLocale")
244+
private void setSpinnerDisplayedValues() {
245+
int minutePickerId = mContext.getResources().getIdentifier("minute", "id", "android");
246+
NumberPicker minutePicker = this.findViewById(minutePickerId);
247+
248+
minutePicker.setMinValue(0);
249+
minutePicker.setMaxValue((60 / mTimePickerInterval) - 1);
250+
251+
List<String> displayedValues = new ArrayList<>(60 / mTimePickerInterval);
252+
for (int displayedMinute = 0; displayedMinute < 60; displayedMinute += mTimePickerInterval) {
253+
displayedValues.add(String.format("%02d", displayedMinute));
254+
}
255+
256+
minutePicker.setDisplayedValues(displayedValues.toArray(new String[0]));
257+
}
258+
259+
@Override
260+
public void onDetachedFromWindow() {
261+
handler.removeCallbacks(runnable);
262+
super.onDetachedFromWindow();
263+
}
264+
}

android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ public final class RNConstants {
55
public static final String ARG_VALUE = "value";
66
public static final String ARG_MINDATE = "minimumDate";
77
public static final String ARG_MAXDATE = "maximumDate";
8+
public static final String ARG_INTERVAL = "minuteInterval";
89
public static final String ARG_IS24HOUR = "is24Hour";
910
public static final String ARG_DISPLAY = "display";
1011
public static final String ARG_NEUTRAL_BUTTON_LABEL = "neutralButtonLabel";
@@ -17,4 +18,9 @@ public final class RNConstants {
1718
* Minimum date supported by {@link DatePicker}, 01 Jan 1900
1819
*/
1920
public static final long DEFAULT_MIN_DATE = -2208988800001l;
21+
22+
/**
23+
* Minimum and default time picker minute interval
24+
*/
25+
public static final int DEFAULT_TIME_PICKER_INTERVAL = 1;
2026
}

0 commit comments

Comments
 (0)