diff --git a/README.md b/README.md index 19f7d7c9..9a6542cc 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ React Native date & time picker component for iOS, Android and Windows (please n

+ +

+

+ Windows

@@ -77,6 +81,10 @@ React Native date & time picker component for iOS, Android and Windows (please n - [Props / params](#component-props--params-of-the-android-imperative-api) - [`mode` (`optional`)](#mode-optional) - [`display` (`optional`)](#display-optional) + - [`design` (`optional`, `Android only`)](#design-optional) + - [`initialInputMode` (`optional`, `Android only`)](#initialinputmode-optional-android-only) + - [`title` (`optional`, `Android only`)](#title-optional-android-only) + - [`fullscreen` (`optional`, `Android only`)](#fullscreen-optional-android-only) - [`onChange` (`optional`)](#onchange-optional) - [`value` (`required`)](#value-required) - [`maximumDate` (`optional`)](#maximumdate-optional) @@ -287,6 +295,8 @@ The reason we recommend the imperative API is: on Android, the date/time picker ### Android styling +If you'd like to use the Material pickers, your app theme will need to inherit from `Theme.Material3.DayNight.NoActionBar` in `styles.xml`. + Styling of the dialogs on Android can be easily customized by using the provided config plugin, provided that you use a [Expo development build](https://docs.expo.dev/develop/development-builds/introduction/). The plugin allows you to configure color properties that cannot be set at runtime and requires building a new app binary to take effect. Refer to this documentation for more information: [android-styling.md](/docs/android-styling.md). @@ -334,6 +344,19 @@ List of possible values for iOS (maps to [preferredDatePickerStyle](https://deve ``` +#### `design` (`optional`, `Android only`) + +Defines if the picker should use Material 3 components or the default picker. The default value is `"default"`. + +List of possible values + +- `"default"` +- `"material"` + +```js + +``` + #### `onChange` (`optional`) Date change handler. @@ -482,6 +505,35 @@ Allows changing of the time picker to a 24-hour format. By default, this value i ``` +#### `initialInputMode` (`optional`, `Android only`) + +:warning: Has effect only when `design` is "material". Allows setting the initial input mode of the picker. + +List of possible values: + +- `"default"` - Recommended. Date pickers will show the calendar view by default, and time pickers will show the clock view by default. +- `"keyboard"` - Both pickers will show an input where the user can type the date or time. + +```js + +``` + +#### `title` (`optional`, `Android only`) + +:warning: Has effect only when `design` is "material". Allows setting the title of the dialog for the pickers. + +```js + +``` + +#### `fullscreen` (`optional`, `Android only`) + +:warning: Has effect only when `design` is "material". Allows setting the date picker dialog to be fullscreen. + +```js + +``` + #### `positiveButton` (`optional`, `Android only`) Set the positive button label and text color. diff --git a/android/build.gradle b/android/build.gradle index 20cc6954..af810e8e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -18,6 +18,7 @@ def isNewArchitectureEnabled() { } apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" } @@ -53,6 +54,9 @@ android { } } } + kotlinOptions { + jvmTarget = '17' + } } repositories { @@ -64,4 +68,6 @@ repositories { dependencies { //noinspection GradleDynamicVersion implementation 'com.facebook.react:react-native:+' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.core:core-ktx:1.13.1' } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java index 0bfc4c21..ca6b2e06 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java @@ -17,6 +17,7 @@ import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; +import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.util.RNLog; @@ -205,6 +206,66 @@ public static Bundle createFragmentArguments(ReadableMap options) { if (options.hasKey(RNConstants.ARG_TZ_NAME) && !options.isNull(RNConstants.ARG_TZ_NAME)) { args.putString(RNConstants.ARG_TZ_NAME, options.getString(RNConstants.ARG_TZ_NAME)); } + if (options.hasKey(RNConstants.ARG_TITLE) && !options.isNull(RNConstants.ARG_TITLE)) { + args.putString(RNConstants.ARG_TITLE, options.getString(RNConstants.ARG_TITLE)); + } + if (options.hasKey(RNConstants.ARG_INITIAL_INPUT_MODE) && !options.isNull(RNConstants.ARG_INITIAL_INPUT_MODE)) { + args.putString(RNConstants.ARG_INITIAL_INPUT_MODE, options.getString(RNConstants.ARG_INITIAL_INPUT_MODE)); + } + + return args; + } + + public static Bundle createDatePickerArguments(ReadableMap options) { + final Bundle args = Common.createFragmentArguments(options); + + if (options.hasKey(RNConstants.ARG_MINDATE) && !options.isNull(RNConstants.ARG_MINDATE)) { + args.putLong(RNConstants.ARG_MINDATE, (long) options.getDouble(RNConstants.ARG_MINDATE)); + } + if (options.hasKey(RNConstants.ARG_MAXDATE) && !options.isNull(RNConstants.ARG_MAXDATE)) { + args.putLong(RNConstants.ARG_MAXDATE, (long) options.getDouble(RNConstants.ARG_MAXDATE)); + } + if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) { + args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY)); + } + if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) { + args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS))); + } + if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) { + args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS)); + } + if (options.hasKey(RNConstants.ARG_TESTID) && !options.isNull(RNConstants.ARG_TESTID)) { + args.putString(RNConstants.ARG_TESTID, options.getString(RNConstants.ARG_TESTID)); + } + if (options.hasKey(RNConstants.ARG_FULLSCREEN) && !options.isNull(RNConstants.ARG_FULLSCREEN)) { + args.putBoolean(RNConstants.ARG_FULLSCREEN, options.getBoolean(RNConstants.ARG_FULLSCREEN)); + } + if (options.hasKey(RNConstants.FIRST_DAY_OF_WEEK) && !options.isNull(RNConstants.FIRST_DAY_OF_WEEK)) { + // FIRST_DAY_OF_WEEK is 0-indexed, since it uses the same constants DAY_OF_WEEK used in the Windows implementation + // Android DatePicker uses 1-indexed values, SUNDAY being 1 and SATURDAY being 7, so the +1 is necessary in this case + args.putInt(RNConstants.FIRST_DAY_OF_WEEK, options.getInt(RNConstants.FIRST_DAY_OF_WEEK)+1); + } + return args; + } + + public static Bundle createTimePickerArguments(ReadableMap options) { + final Bundle args = Common.createFragmentArguments(options); + + if (options.hasKey(RNConstants.ARG_IS24HOUR) && !options.isNull(RNConstants.ARG_IS24HOUR)) { + args.putBoolean(RNConstants.ARG_IS24HOUR, options.getBoolean(RNConstants.ARG_IS24HOUR)); + } + if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) { + args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY)); + } + if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) { + args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS))); + } + if (options.hasKey(RNConstants.ARG_INTERVAL) && !options.isNull(RNConstants.ARG_INTERVAL)) { + args.putInt(RNConstants.ARG_INTERVAL, options.getInt(RNConstants.ARG_INTERVAL)); + } + if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) { + args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS)); + } return args; } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/DatePickerModule.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/DatePickerModule.java index 6eec370b..d043e4f9 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/DatePickerModule.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/DatePickerModule.java @@ -21,6 +21,7 @@ import com.facebook.react.common.annotations.VisibleForTesting; import com.facebook.react.module.annotations.ReactModule; +import static com.reactcommunity.rndatetimepicker.Common.createDatePickerArguments; import static com.reactcommunity.rndatetimepicker.Common.dismissDialog; import java.util.Calendar; @@ -144,7 +145,7 @@ public void open(final ReadableMap options, final Promise promise) { RNDatePickerDialogFragment oldFragment = (RNDatePickerDialogFragment) fragmentManager.findFragmentByTag(NAME); - Bundle arguments = createFragmentArguments(options); + Bundle arguments = createDatePickerArguments(options); if (oldFragment != null) { oldFragment.update(arguments); @@ -162,33 +163,4 @@ public void open(final ReadableMap options, final Promise promise) { fragment.show(fragmentManager, NAME); }); } - - private Bundle createFragmentArguments(ReadableMap options) { - final Bundle args = Common.createFragmentArguments(options); - - if (options.hasKey(RNConstants.ARG_MINDATE) && !options.isNull(RNConstants.ARG_MINDATE)) { - args.putLong(RNConstants.ARG_MINDATE, (long) options.getDouble(RNConstants.ARG_MINDATE)); - } - if (options.hasKey(RNConstants.ARG_MAXDATE) && !options.isNull(RNConstants.ARG_MAXDATE)) { - args.putLong(RNConstants.ARG_MAXDATE, (long) options.getDouble(RNConstants.ARG_MAXDATE)); - } - if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) { - args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY)); - } - if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) { - args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS))); - } - if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) { - args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS)); - } - if (options.hasKey(RNConstants.ARG_TESTID) && !options.isNull(RNConstants.ARG_TESTID)) { - args.putString(RNConstants.ARG_TESTID, options.getString(RNConstants.ARG_TESTID)); - } - if (options.hasKey(RNConstants.FIRST_DAY_OF_WEEK) && !options.isNull(RNConstants.FIRST_DAY_OF_WEEK)) { - // FIRST_DAY_OF_WEEK is 0-indexed, since it uses the same constants DAY_OF_WEEK used in the Windows implementation - // Android DatePicker uses 1-indexed values, SUNDAY being 1 and SATURDAY being 7, so the +1 is necessary in this case - args.putInt(RNConstants.FIRST_DAY_OF_WEEK, options.getInt(RNConstants.FIRST_DAY_OF_WEEK)+1); - } - return args; - } } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialDatePickerModule.kt b/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialDatePickerModule.kt new file mode 100644 index 00000000..2cb28ade --- /dev/null +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialDatePickerModule.kt @@ -0,0 +1,44 @@ +package com.reactcommunity.rndatetimepicker + +import androidx.fragment.app.FragmentActivity +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.UiThreadUtil +import com.reactcommunity.rndatetimepicker.Common.createDatePickerArguments +import com.reactcommunity.rndatetimepicker.Common.dismissDialog + +class MaterialDatePickerModule(reactContext: ReactApplicationContext): NativeModuleMaterialDatePickerSpec(reactContext) { + companion object { + const val NAME = "RNCMaterialDatePicker" + } + + override fun getName(): String { + return NAME + } + + override fun dismiss(promise: Promise?) { + val activity = currentActivity as FragmentActivity? + dismissDialog(activity, NAME, promise) + } + + override fun open(params: ReadableMap, promise: Promise) { + val activity = currentActivity as FragmentActivity? + if (activity == null) { + promise.reject( + RNConstants.ERROR_NO_ACTIVITY, + "Tried to open a MaterialDatePicker dialog while not attached to an Activity" + ) + return + } + + val fragmentManager = activity.supportFragmentManager + + UiThreadUtil.runOnUiThread { + val arguments = createDatePickerArguments(params) + val datePicker = + RNMaterialDatePicker(arguments, promise, fragmentManager, reactApplicationContext) + datePicker.open() + } + } +} diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialTimePickerModule.kt b/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialTimePickerModule.kt new file mode 100644 index 00000000..b3e75669 --- /dev/null +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialTimePickerModule.kt @@ -0,0 +1,49 @@ +package com.reactcommunity.rndatetimepicker + +import androidx.fragment.app.FragmentActivity +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.UiThreadUtil +import com.reactcommunity.rndatetimepicker.Common.createTimePickerArguments +import com.reactcommunity.rndatetimepicker.Common.dismissDialog + +class MaterialTimePickerModule(reactContext: ReactApplicationContext) : + NativeModuleMaterialTimePickerSpec(reactContext) { + companion object { + const val NAME = "RNCMaterialTimePicker" + } + + override fun getName(): String { + return NAME + } + + override fun dismiss(promise: Promise?) { + val activity = currentActivity as FragmentActivity? + dismissDialog(activity, NAME, promise) + } + + override fun open(params: ReadableMap, promise: Promise) { + val activity = currentActivity as FragmentActivity? + if (activity == null) { + promise.reject( + RNConstants.ERROR_NO_ACTIVITY, + "Tried to open a MaterialTimePicker dialog while not attached to an Activity" + ) + } + + val fragmentManager = activity!!.supportFragmentManager + + UiThreadUtil.runOnUiThread { + val arguments = + createTimePickerArguments(params) + val materialPicker = RNMaterialTimePicker( + arguments, + promise, + fragmentManager, + reactApplicationContext + ) + materialPicker.open() + } + } +} diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java index acb56cb9..07220b79 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java @@ -14,6 +14,9 @@ public final class RNConstants { public static final String ARG_TZOFFSET_MINS = "timeZoneOffsetInMinutes"; public static final String ARG_TZ_NAME = "timeZoneName"; public static final String ARG_TESTID = "testID"; + public static final String ARG_TITLE = "title"; + public static final String ARG_INITIAL_INPUT_MODE = "initialInputMode"; + public static final String ARG_FULLSCREEN = "fullscreen"; public static final String ACTION_DATE_SET = "dateSetAction"; public static final String ACTION_TIME_SET = "timeSetAction"; public static final String ACTION_DISMISSED = "dismissedAction"; diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDate.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDate.java index 137869a9..f5dc0f64 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDate.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDate.java @@ -23,4 +23,5 @@ public RNDate(Bundle args) { public int day() { return now.get(Calendar.DAY_OF_MONTH); } public int hour() { return now.get(Calendar.HOUR_OF_DAY); } public int minute() { return now.get(Calendar.MINUTE); } + public Long timestamp() { return now.getTimeInMillis(); } } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java index ceafa6ba..417183e4 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java @@ -20,6 +20,10 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) return new DatePickerModule(reactContext); } else if (name.equals(TimePickerModule.NAME)) { return new TimePickerModule(reactContext); + } else if (name.equals(MaterialDatePickerModule.NAME)) { + return new MaterialDatePickerModule(reactContext); + } else if (name.equals(MaterialTimePickerModule.NAME)) { + return new MaterialTimePickerModule(reactContext); } else { return null; } @@ -52,6 +56,28 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule isTurboModule // isTurboModule )); + moduleInfos.put( + MaterialDatePickerModule.NAME, + new ReactModuleInfo( + MaterialDatePickerModule.NAME, + MaterialDatePickerModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); + moduleInfos.put( + MaterialTimePickerModule.NAME, + new ReactModuleInfo( + MaterialTimePickerModule.NAME, + MaterialTimePickerModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); return moduleInfos; }; } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialDatePicker.kt b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialDatePicker.kt new file mode 100644 index 00000000..3e0d88b7 --- /dev/null +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialDatePicker.kt @@ -0,0 +1,179 @@ +package com.reactcommunity.rndatetimepicker + +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.FragmentManager +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableNativeMap +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.CalendarConstraints.DateValidator +import com.google.android.material.datepicker.CompositeDateValidator +import com.google.android.material.datepicker.DateValidatorPointBackward +import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener +import java.util.Calendar + +class RNMaterialDatePicker( + private val args: Bundle, + private val promise: Promise, + private val fragmentManager: FragmentManager, + private val reactContext: ReactApplicationContext +) { + private var promiseResolved = false + private var datePicker: MaterialDatePicker? = null + private var builder = MaterialDatePicker.Builder.datePicker() + + fun open() { + createDatePicker() + addListeners() + show() + } + + private fun createDatePicker() { + setInitialDate() + setTitle() + setInputMode() + setButtons() + setConstraints() + setFullscreen() + + datePicker = builder.build() + } + + private fun setInitialDate() { + val initialDate = RNDate(args) + builder.setSelection(initialDate.timestamp()) + } + + private fun setTitle() { + val title = args.getString(RNConstants.ARG_TITLE) + if (!title.isNullOrEmpty()) { + builder.setTitleText(args.getString(RNConstants.ARG_TITLE)) + } + } + + private fun setInputMode() { + if (args.getString(RNConstants.ARG_INITIAL_INPUT_MODE).isNullOrEmpty()) { + builder.setInputMode(MaterialDatePicker.INPUT_MODE_CALENDAR) + return + } + + val inputMode = + RNMaterialInputMode.valueOf( + args.getString(RNConstants.ARG_INITIAL_INPUT_MODE)!!.uppercase() + ) + + if (inputMode == RNMaterialInputMode.KEYBOARD) { + builder.setInputMode(MaterialDatePicker.INPUT_MODE_TEXT) + } else { + builder.setInputMode(MaterialDatePicker.INPUT_MODE_CALENDAR) + } + } + + private fun setConstraints() { + val constraintsBuilder = CalendarConstraints.Builder() + + if (args.containsKey(RNConstants.FIRST_DAY_OF_WEEK)) { + constraintsBuilder.setFirstDayOfWeek(args.getInt(RNConstants.FIRST_DAY_OF_WEEK)) + } + + val validators = mutableListOf() + + if (args.containsKey(RNConstants.ARG_MINDATE)) { + val minDate = Common.minDateWithTimeZone(args) + validators.add(DateValidatorPointForward.from(minDate)) + } + + if (args.containsKey(RNConstants.ARG_MAXDATE)) { + val maxDate = Common.maxDateWithTimeZone(args) + validators.add(DateValidatorPointBackward.before(maxDate)) + } + + constraintsBuilder.setValidator(CompositeDateValidator.allOf(validators)) + builder.setCalendarConstraints(constraintsBuilder.build()) + } + + private fun setFullscreen() { + val isFullscreen = args.getBoolean(RNConstants.ARG_FULLSCREEN) + + if (isFullscreen) { + builder.setTheme(com.google.android.material.R.style.ThemeOverlay_Material3_MaterialCalendar_Fullscreen) + } else { + builder.setTheme(com.google.android.material.R.style.ThemeOverlay_Material3_MaterialCalendar) + } + } + + private fun addListeners() { + val listeners = Listeners() + datePicker!!.addOnPositiveButtonClickListener(listeners) + datePicker!!.addOnDismissListener(listeners) + } + + private fun show() { + datePicker!!.show(fragmentManager, MaterialDatePickerModule.NAME) + } + + private fun setButtons() { + val buttons = args.getBundle(RNConstants.ARG_DIALOG_BUTTONS) ?: return + + val negativeButton = buttons.getBundle(Common.NEGATIVE) + val positiveButton = buttons.getBundle(Common.POSITIVE) + + if (negativeButton != null) { + builder.setNegativeButtonText(negativeButton.getString(Common.LABEL)) + } + + if (positiveButton != null) { + builder.setPositiveButtonText(positiveButton.getString(Common.LABEL)) + } + } + + private inner class Listeners : MaterialPickerOnPositiveButtonClickListener, + DialogInterface.OnDismissListener { + override fun onDismiss(dialog: DialogInterface) { + if (promiseResolved || !reactContext.hasActiveReactInstance()) return + + val result = WritableNativeMap() + result.putString("action", RNConstants.ACTION_DISMISSED) + promise.resolve(result) + promiseResolved = true + } + + override fun onPositiveButtonClick(selection: Long) { + if (promiseResolved || !reactContext.hasActiveReactInstance()) return + + val newCalendar = createNewCalendar(selection) + val result = WritableNativeMap() + + result.putString("action", RNConstants.ACTION_DATE_SET) + result.putDouble("timestamp", newCalendar.timeInMillis.toDouble()) + result.putDouble( + "utcOffset", + newCalendar.timeZone.getOffset(newCalendar.timeInMillis).toDouble() / 1000 / 60 + ) + + promise.resolve(result) + promiseResolved = true + } + + private fun createNewCalendar(selection: Long): Calendar { + val initialDate = RNDate(args) + val newCalendar = Calendar.getInstance( + Common.getTimeZone( + args + ) + ) + + newCalendar.timeInMillis = selection + + newCalendar[Calendar.HOUR_OF_DAY] = initialDate.hour() + newCalendar[Calendar.MINUTE] = initialDate.minute() + newCalendar[Calendar.SECOND] = 0 + newCalendar[Calendar.MILLISECOND] = 0 + + return newCalendar + } + } +} diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialInputMode.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialInputMode.java new file mode 100644 index 00000000..5952b0a2 --- /dev/null +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialInputMode.java @@ -0,0 +1,6 @@ +package com.reactcommunity.rndatetimepicker; + +public enum RNMaterialInputMode { + DEFAULT, + KEYBOARD +} diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialTimePicker.kt b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialTimePicker.kt new file mode 100644 index 00000000..9ff384bf --- /dev/null +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialTimePicker.kt @@ -0,0 +1,155 @@ +package com.reactcommunity.rndatetimepicker + +import android.content.DialogInterface +import android.os.Bundle +import android.text.format.DateFormat +import android.view.View +import androidx.fragment.app.FragmentManager +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import java.util.Calendar + +class RNMaterialTimePicker( + private val args: Bundle, + private val promise: Promise, + private val fragmentManager: FragmentManager, + private val reactContext: ReactApplicationContext +) { + private var promiseResolved = false + private var timePicker: MaterialTimePicker? = null + private var builder = MaterialTimePicker.Builder() + + fun open() { + createTimePicker() + addListeners() + show() + } + + private fun createTimePicker() { + setInitialDate() + setTitle() + setInputMode() + setButtons() + setTimeFormat() + + timePicker = builder.build() + } + + private fun setInitialDate() { + val initialDate = RNDate(args) + + builder.setHour(initialDate.hour()) + .setMinute(initialDate.minute()) + } + + private fun setTitle() { + val title = args.getString(RNConstants.ARG_TITLE) + if (!title.isNullOrEmpty()) { + builder.setTitleText(args.getString(RNConstants.ARG_TITLE)) + } + } + + private fun setInputMode() { + if (args.getString(RNConstants.ARG_INITIAL_INPUT_MODE).isNullOrEmpty()) { + builder.setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK) + return + } + + val inputMode = + RNMaterialInputMode.valueOf( + args.getString(RNConstants.ARG_INITIAL_INPUT_MODE)!!.uppercase() + ) + + if (inputMode == RNMaterialInputMode.KEYBOARD) { + builder.setInputMode(MaterialTimePicker.INPUT_MODE_KEYBOARD) + } else { + builder.setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK) + } + } + + private fun setButtons() { + val buttons = args.getBundle(RNConstants.ARG_DIALOG_BUTTONS) ?: return + + val negativeButton = buttons.getBundle(Common.NEGATIVE) + val positiveButton = buttons.getBundle(Common.POSITIVE) + + if (negativeButton != null) { + builder.setNegativeButtonText(negativeButton.getString(Common.LABEL)) + } + + if (positiveButton != null) { + builder.setPositiveButtonText(positiveButton.getString(Common.LABEL)) + } + } + + private fun setTimeFormat() { + if (args.getBoolean(RNConstants.ARG_IS24HOUR)) { + builder.setTimeFormat(TimeFormat.CLOCK_24H) + return + } + + if (DateFormat.is24HourFormat(reactContext)) { + builder.setTimeFormat(TimeFormat.CLOCK_24H) + } else { + builder.setTimeFormat(TimeFormat.CLOCK_12H) + } + } + + private fun addListeners() { + val listeners = Listeners() + timePicker!!.addOnPositiveButtonClickListener(listeners) + timePicker!!.addOnDismissListener(listeners) + } + + private fun show() { + timePicker!!.show(fragmentManager, MaterialTimePickerModule.NAME) + } + + private inner class Listeners : View.OnClickListener, DialogInterface.OnDismissListener { + override fun onDismiss(dialog: DialogInterface) { + if (promiseResolved || !reactContext.hasActiveReactInstance()) return + + val result: WritableMap = WritableNativeMap() + result.putString("action", RNConstants.ACTION_DISMISSED) + promise.resolve(result) + promiseResolved = true + } + + override fun onClick(v: View) { + if (promiseResolved || !reactContext.hasActiveReactInstance()) return + + val newCalendar = createNewCalendar() + val result = WritableNativeMap() + + result.putString("action", RNConstants.ACTION_DATE_SET) + result.putDouble("timestamp", newCalendar.timeInMillis.toDouble()) + result.putDouble( + "utcOffset", + newCalendar.timeZone.getOffset(newCalendar.timeInMillis).toDouble() / 1000 / 60 + ) + + promise.resolve(result) + promiseResolved = true + } + + private fun createNewCalendar(): Calendar { + val initialDate = RNDate(args) + val calendar = Calendar.getInstance( + Common.getTimeZone( + args + ) + ) + + calendar[initialDate.year(), initialDate.month(), initialDate.day(), timePicker!!.hour, timePicker!!.minute] = + 0 + + calendar[Calendar.MILLISECOND] = 0 + + return calendar + } + } +} diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/TimePickerModule.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/TimePickerModule.java index 877b37b1..46dd5e41 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/TimePickerModule.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/TimePickerModule.java @@ -23,6 +23,7 @@ import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; +import static com.reactcommunity.rndatetimepicker.Common.createTimePickerArguments; import static com.reactcommunity.rndatetimepicker.Common.dismissDialog; import java.util.Calendar; @@ -119,7 +120,7 @@ public void open(final ReadableMap options, final Promise promise) { RNTimePickerDialogFragment oldFragment = (RNTimePickerDialogFragment) fragmentManager.findFragmentByTag(NAME); - Bundle arguments = createFragmentArguments(options); + Bundle arguments = createTimePickerArguments(options); if (oldFragment != null) { oldFragment.update(arguments); @@ -137,25 +138,4 @@ public void open(final ReadableMap options, final Promise promise) { fragment.show(fragmentManager, NAME); }); } - - private Bundle createFragmentArguments(ReadableMap options) { - final Bundle args = Common.createFragmentArguments(options); - - if (options.hasKey(RNConstants.ARG_IS24HOUR) && !options.isNull(RNConstants.ARG_IS24HOUR)) { - args.putBoolean(RNConstants.ARG_IS24HOUR, options.getBoolean(RNConstants.ARG_IS24HOUR)); - } - if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) { - args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY)); - } - if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) { - args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS))); - } - if (options.hasKey(RNConstants.ARG_INTERVAL) && !options.isNull(RNConstants.ARG_INTERVAL)) { - args.putInt(RNConstants.ARG_INTERVAL, options.getInt(RNConstants.ARG_INTERVAL)); - } - if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) { - args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS)); - } - return args; - } } diff --git a/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialDatePickerSpec.java b/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialDatePickerSpec.java new file mode 100644 index 00000000..4db46bff --- /dev/null +++ b/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialDatePickerSpec.java @@ -0,0 +1,36 @@ + +/** + * This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). + * + * Then it was commited. It is here to support the old architecture. + * If you use the new architecture, this file won't be included and instead will be generated by the codegen. + * + * @generated by codegen project: GenerateModuleJavaSpec.js + * + * @nolint + */ + +package com.reactcommunity.rndatetimepicker; + +import com.facebook.proguard.annotations.DoNotStrip; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReactModuleWithSpec; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.turbomodule.core.interfaces.TurboModule; + +public abstract class NativeModuleMaterialDatePickerSpec extends ReactContextBaseJavaModule implements ReactModuleWithSpec, TurboModule { + public NativeModuleMaterialDatePickerSpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @ReactMethod + @DoNotStrip + public abstract void dismiss(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void open(ReadableMap params, Promise promise); +} diff --git a/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialTimePickerSpec.java b/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialTimePickerSpec.java new file mode 100644 index 00000000..a59a8cda --- /dev/null +++ b/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialTimePickerSpec.java @@ -0,0 +1,36 @@ + +/** + * This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). + * + * Then it was commited. It is here to support the old architecture. + * If you use the new architecture, this file won't be included and instead will be generated by the codegen. + * + * @generated by codegen project: GenerateModuleJavaSpec.js + * + * @nolint + */ + +package com.reactcommunity.rndatetimepicker; + +import com.facebook.proguard.annotations.DoNotStrip; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReactModuleWithSpec; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.turbomodule.core.interfaces.TurboModule; + +public abstract class NativeModuleMaterialTimePickerSpec extends ReactContextBaseJavaModule implements ReactModuleWithSpec, TurboModule { + public NativeModuleMaterialTimePickerSpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @ReactMethod + @DoNotStrip + public abstract void dismiss(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void open(ReadableMap params, Promise promise); +} diff --git a/docs/images/android_material_date.jpg b/docs/images/android_material_date.jpg new file mode 100644 index 00000000..8930cb10 Binary files /dev/null and b/docs/images/android_material_date.jpg differ diff --git a/docs/images/android_material_time.jpg b/docs/images/android_material_time.jpg new file mode 100644 index 00000000..b1f3aaca Binary files /dev/null and b/docs/images/android_material_time.jpg differ diff --git a/example/App.js b/example/App.js index 7e00c069..ae542c80 100644 --- a/example/App.js +++ b/example/App.js @@ -87,6 +87,8 @@ const DISPLAY_VALUES = Platform.select({ windows: [], }); const MINUTE_INTERVALS = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30]; +const DESIGNS = ['default', 'material']; +const INPUT_MODES = ['default', 'keyboard']; export const App = () => { // Sat, 13 Nov 2021 10:00:00 GMT (local: Saturday, November 13, 2021 11:00:00 AM GMT+01:00) @@ -103,8 +105,14 @@ export const App = () => { const [interval, setMinInterval] = useState(1); const [neutralButtonLabel, setNeutralButtonLabel] = useState(undefined); const [disabled, setDisabled] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); const [minimumDate, setMinimumDate] = useState(); const [maximumDate, setMaximumDate] = useState(); + const [design, setDesign] = useState(DESIGNS[0]); + const [inputMode, setInputMode] = useState(INPUT_MODES[0]); + const [title, setTitle] = useState(''); + + const isMaterialDesign = design === 'material'; // Windows-specific const [time, setTime] = useState(undefined); @@ -312,6 +320,32 @@ export const App = () => { ); }} /> + Design (android only): + { + setDesign(DESIGNS[event.nativeEvent.selectedSegmentIndex]); + }} + /> + Input mode (android only): + { + setInputMode(INPUT_MODES[event.nativeEvent.selectedSegmentIndex]); + }} + /> + + + title (android only) + + + text color (iOS only) @@ -344,6 +378,14 @@ export const App = () => { + + + fullscreen (android only) + + + + + neutralButtonLabel (android only) @@ -455,6 +497,10 @@ export const App = () => { negativeButton={{label: 'Cancel', textColor: 'red'}} disabled={disabled} firstDayOfWeek={firstDayOfWeek} + title={isMaterialDesign ? title : undefined} + initialInputMode={isMaterialDesign ? inputMode : undefined} + design={design} + fullscreen={isMaterialDesign ? isFullscreen : undefined} /> )} diff --git a/example/e2e/detoxTest.spec.js b/example/e2e/detoxTest.spec.js index 3ea6dbf2..8961720a 100644 --- a/example/e2e/detoxTest.spec.js +++ b/example/e2e/detoxTest.spec.js @@ -168,6 +168,7 @@ describe('e2e tests', () => { await expect(elementById('overriddenTzName')).toHaveText('Europe/Prague'); let timeZone = 'America/Vancouver'; + await elementById('DateTimePickerScrollView').scrollTo('bottom'); await waitFor(elementById('timezone')).toBeVisible().withTimeout(1000); await userSwipesTimezoneListUntilDesiredIsVisible(timeZone); @@ -192,6 +193,7 @@ describe('e2e tests', () => { it('daylight saving should work properly', async () => { let timeZone = 'America/Vancouver'; + await elementById('DateTimePickerScrollView').scrollTo('bottom'); await waitFor(elementById('timezone')).toBeVisible().withTimeout(1000); await userSwipesTimezoneListUntilDesiredIsVisible(timeZone); @@ -373,6 +375,7 @@ describe('e2e tests', () => { }); it(':android: when component unmounts, dialog is dismissed', async () => { + await elementById('DateTimePickerScrollView').scrollTo('bottom'); await elementById('showAndDismissPickerButton').tap(); await waitFor(getDatePickerAndroid()).toExist().withTimeout(4000); await wait(6000); diff --git a/example/e2e/utils/actions.js b/example/e2e/utils/actions.js index 4a70cbdd..6e18b360 100644 --- a/example/e2e/utils/actions.js +++ b/example/e2e/utils/actions.js @@ -35,7 +35,7 @@ async function userOpensPicker({ await elementById('DateTimePickerScrollView').scrollTo('top'); await element(by.text(mode)).tap(); - await element(by.text(display)).tap(); + await element(by.text(display)).atIndex(0).tap(); if (interval) { await element(by.text(String(interval))).tap(); } @@ -47,6 +47,7 @@ async function userOpensPicker({ if (firstDayOfWeek) { await element(by.id(firstDayOfWeek)).tap(); } + await elementById('DateTimePickerScrollView').scrollTo('bottom'); await element(by.id('showPickerButton')).tap(); } diff --git a/package.json b/package.json index eccb0a73..9802aba7 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "access": "public" }, "scripts": { - "start": "react-native start", + "start": "patch-package && react-native start", "start:android": "react-native run-android", "start:ios": "react-native run-ios", "start:windows": "react-native run-windows --sln example/windows/date-time-picker-example.sln", @@ -91,6 +91,8 @@ "jest": "^29.5.0", "moment": "^2.24.0", "moment-timezone": "^0.5.41", + "patch-package": "^8.0.0", + "postinstall-postinstall": "^2.1.0", "prettier": "^2.8.8", "react": "18.3.1", "react-native": "^0.75.4", diff --git a/patches/react-native-test-app+4.0.7.patch b/patches/react-native-test-app+4.0.7.patch new file mode 100644 index 00000000..eff131ce --- /dev/null +++ b/patches/react-native-test-app+4.0.7.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-test-app/android/app/src/main/res/values/styles.xml b/node_modules/react-native-test-app/android/app/src/main/res/values/styles.xml +index e58cbf3..ba878cb 100644 +--- a/node_modules/react-native-test-app/android/app/src/main/res/values/styles.xml ++++ b/node_modules/react-native-test-app/android/app/src/main/res/values/styles.xml +@@ -1,7 +1,7 @@ + + + +-