diff --git a/Examples/UIExplorer/AlertExample.js b/Examples/UIExplorer/AlertExample.js new file mode 100644 index 000000000..702c49a6c --- /dev/null +++ b/Examples/UIExplorer/AlertExample.js @@ -0,0 +1,137 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +'use strict'; + +var React = require('react-native'); +var { + Alert, + Platform, + StyleSheet, + Text, + TouchableHighlight, + View, +} = React; + +var UIExplorerBlock = require('./UIExplorerBlock'); + +// corporate ipsum > lorem ipsum +var alertMessage = 'Credibly reintermediate next-generation potentialities after goal-oriented ' + + 'catalysts for change. Dynamically revolutionize.'; + +/** + * Simple alert examples. + */ +var SimpleAlertExampleBlock = React.createClass({ + + render: function() { + return ( + + Alert.alert( + 'Alert Title', + alertMessage, + )}> + + Alert with message and default button + + + Alert.alert( + 'Alert Title', + alertMessage, + [ + {text: 'OK', onPress: () => console.log('OK Pressed!')}, + ] + )}> + + Alert with one button + + + Alert.alert( + 'Alert Title', + alertMessage, + [ + {text: 'Cancel', onPress: () => console.log('Cancel Pressed!')}, + {text: 'OK', onPress: () => console.log('OK Pressed!')}, + ] + )}> + + Alert with two buttons + + + Alert.alert( + 'Alert Title', + null, + [ + {text: 'Foo', onPress: () => console.log('Foo Pressed!')}, + {text: 'Bar', onPress: () => console.log('Bar Pressed!')}, + {text: 'Baz', onPress: () => console.log('Baz Pressed!')}, + ] + )}> + + Alert with three buttons + + + Alert.alert( + 'Foo Title', + alertMessage, + '..............'.split('').map((dot, index) => ({ + text: 'Button ' + index, + onPress: () => console.log('Pressed ' + index) + })) + )}> + + Alert with too many buttons + + + + ); + }, +}); + +var AlertExample = React.createClass({ + statics: { + title: 'Alert', + description: 'Alerts display a concise and informative message ' + + 'and prompt the user to make a decision.', + }, + render: function() { + return ( + + + + ); + } +}); + +var styles = StyleSheet.create({ + wrapper: { + borderRadius: 5, + marginBottom: 5, + }, + button: { + backgroundColor: '#eeeeee', + padding: 10, + }, +}); + +module.exports = { + AlertExample, + SimpleAlertExampleBlock, +} diff --git a/Examples/UIExplorer/AlertIOSExample.js b/Examples/UIExplorer/AlertIOSExample.js index 8666d5ba9..7532e4151 100644 --- a/Examples/UIExplorer/AlertIOSExample.js +++ b/Examples/UIExplorer/AlertIOSExample.js @@ -24,77 +24,15 @@ var { AlertIOS, } = React; +var { SimpleAlertExampleBlock } = require('./AlertExample'); + exports.framework = 'React'; exports.title = 'AlertIOS'; exports.description = 'iOS alerts and action sheets'; exports.examples = [{ title: 'Alerts', render() { - return ( - - AlertIOS.alert( - 'Foo Title', - 'My Alert Msg' - )}> - - Alert with message and default button - - - AlertIOS.alert( - 'Foo Title', - null, - [ - {text: 'Button', onPress: () => console.log('Button Pressed!')}, - ] - )}> - - Alert with only one button - - - AlertIOS.alert( - 'Foo Title', - 'My Alert Msg', - [ - {text: 'Foo', onPress: () => console.log('Foo Pressed!')}, - {text: 'Bar', onPress: () => console.log('Bar Pressed!')}, - ] - )}> - - Alert with two buttons - - - AlertIOS.alert( - 'Foo Title', - null, - [ - {text: 'Foo', onPress: () => console.log('Foo Pressed!')}, - {text: 'Bar', onPress: () => console.log('Bar Pressed!')}, - {text: 'Baz', onPress: () => console.log('Baz Pressed!')}, - ] - )}> - - Alert with 3 buttons - - - AlertIOS.alert( - 'Foo Title', - 'My Alert Msg', - '..............'.split('').map((dot, index) => ({ - text: 'Button ' + index, - onPress: () => console.log('Pressed ' + index) - })) - )}> - - Alert with too many buttons - - - - ); + return ; } }, { diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index 36746c558..e3d05a2b1 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -38,6 +38,7 @@ var COMPONENTS = [ var APIS = [ require('./AccessibilityAndroidExample.android'), + require('./AlertExample').AlertExample, require('./BorderExample'), require('./ClipboardExample'), require('./GeolocationExample'), diff --git a/Libraries/Utilities/Alert.js b/Libraries/Utilities/Alert.js new file mode 100644 index 000000000..de434c987 --- /dev/null +++ b/Libraries/Utilities/Alert.js @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule Alert + * @flow + */ +'use strict'; + +var AlertIOS = require('AlertIOS'); +var Platform = require('Platform'); +var DialogModuleAndroid = require('NativeModules').DialogManagerAndroid; + +import type { AlertType, AlertButtonStyle } from 'AlertIOS'; + +type Buttons = Array<{ + text?: string; + onPress?: ?Function; + style?: AlertButtonStyle; +}>; + +/** + * Launches an alert dialog with the specified title and message. + * + * Optionally provide a list of buttons. Tapping any button will fire the + * respective onPress callback and dismiss the alert. By default, the only + * button will be an 'OK' button. + * + * The last button in the list will be considered the 'Primary' button. + * + * ## iOS + * + * On iOS you can specify any number of buttons. Each button can optionally + * specify a style and you can also specify type of the alert. + * Refer to `AlertIOS` for details. + * + * ## Android + * + * On Android at most three buttons can be specified. Android has a concept + * of a 'neutral', 'negative' and a 'positive' button: + * + * - If you specify one button, it will be the 'positive' one (such as 'OK') + * - Two buttons mean 'negative', 'positive' (such as 'Cancel', 'OK') + * - Three buttons mean 'neutral', 'negative', 'positive' (such as 'Later', 'Cancel', 'OK') + * + * ``` + * Alert.alert( + * 'Alert Title', + * 'My Alert Msg', + * [ + * {text: 'Ask me later', onPress: () => console.log('Ask me later pressed')}, + * {text: 'Cancel', onPress: () => console.log('Cancel Pressed')}, + * {text: 'OK', onPress: () => console.log('OK Pressed')}, + * ] + * ) + * ``` + */ +class Alert { + + static alert( + title: ?string, + message?: ?string, + buttons?: Buttons, + type?: AlertType + ): void { + if (Platform.OS === 'ios') { + AlertIOS.alert(title, message, buttons, type); + } else if (Platform.OS === 'android') { + AlertAndroid.alert(title, message, buttons); + } + } +} + +/** + * Wrapper around the Android native module. + */ +class AlertAndroid { + + static alert( + title: ?string, + message?: ?string, + buttons?: Buttons, + ): void { + var config = { + title: title || '', + message: message || '', + }; + // At most three buttons (neutral, negative, positive). Ignore rest. + // The text 'OK' should be probably localized. iOS Alert does that in native. + var validButtons: Buttons = buttons ? buttons.slice(0, 3) : [{text: 'OK'}]; + var buttonPositive = validButtons.pop(); + var buttonNegative = validButtons.pop(); + var buttonNeutral = validButtons.pop(); + if (buttonNeutral) { + config = {...config, buttonNeutral: buttonNeutral.text || '' } + } + if (buttonNegative) { + config = {...config, buttonNegative: buttonNegative.text || '' } + } + if (buttonPositive) { + config = {...config, buttonPositive: buttonPositive.text || '' } + } + DialogModuleAndroid.showAlert( + config, + (errorMessage) => console.warn(message), + (action, buttonKey) => { + if (action !== DialogModuleAndroid.buttonClicked) { + return; + } + if (buttonKey === DialogModuleAndroid.buttonNeutral) { + buttonNeutral.onPress && buttonNeutral.onPress(); + } else if (buttonKey === DialogModuleAndroid.buttonNegative) { + buttonNegative.onPress && buttonNegative.onPress(); + } else if (buttonKey === DialogModuleAndroid.buttonPositive) { + buttonPositive.onPress && buttonPositive.onPress(); + } + } + ); + } +} + +module.exports = Alert; diff --git a/Libraries/Utilities/AlertIOS.js b/Libraries/Utilities/AlertIOS.js index 2bd1a3223..38fd0f88c 100644 --- a/Libraries/Utilities/AlertIOS.js +++ b/Libraries/Utilities/AlertIOS.js @@ -14,14 +14,14 @@ var RCTAlertManager = require('NativeModules').AlertManager; var invariant = require('invariant'); -type AlertType = $Enum<{ +export type AlertType = $Enum<{ 'default': string; 'plain-text': string; 'secure-text': string; 'login-password': string; }>; -type AlertButtonStyle = $Enum<{ +export type AlertButtonStyle = $Enum<{ 'default': string; 'cancel': string; 'destructive': string; @@ -32,20 +32,19 @@ type AlertButtonStyle = $Enum<{ * * Optionally provide a list of buttons. Tapping any button will fire the * respective onPress callback and dismiss the alert. By default, the only - * button will be an 'OK' button + * button will be an 'OK' button. * * ``` * AlertIOS.alert( * 'Foo Title', * 'My Alert Msg', * [ - * {text: 'OK', onPress: () => console.log('OK Pressed!')}, - * {text: 'Cancel', onPress: () => console.log('Cancel Pressed!'), style: 'cancel'}, + * {text: 'OK', onPress: () => console.log('OK Pressed')}, + * {text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel'}, * ] * ) * ``` */ - class AlertIOS { static alert( title: ?string, diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js index be3dfd26a..130c4439a 100644 --- a/Libraries/react-native/react-native.js +++ b/Libraries/react-native/react-native.js @@ -51,6 +51,7 @@ var ReactNative = { // APIs get ActionSheetIOS() { return require('ActionSheetIOS'); }, get AdSupportIOS() { return require('AdSupportIOS'); }, + get Alert() { return require('Alert'); }, get AlertIOS() { return require('AlertIOS'); }, get Animated() { return require('Animated'); }, get AppRegistry() { return require('AppRegistry'); }, diff --git a/ReactAndroid/build.gradle b/ReactAndroid/build.gradle index 464a2ad18..850d2f41f 100644 --- a/ReactAndroid/build.gradle +++ b/ReactAndroid/build.gradle @@ -257,7 +257,7 @@ dependencies { testCompile "org.powermock:powermock-classloading-xstream:${POWERMOCK_VERSION}" testCompile "org.mockito:mockito-core:${MOCKITO_CORE_VERSION}" testCompile "org.easytesting:fest-assert-core:${FEST_ASSERT_CORE_VERSION}" - testCompile("org.robolectric:robolectric:${ROBOLECTRIC_VERSION}") + testCompile "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" } apply from: 'release.gradle' diff --git a/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java b/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java index e8793d241..17fb538b8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java @@ -186,7 +186,7 @@ public abstract class ReactInstanceManager { /** * Path to the JS bundle file to be loaded from the file system. * - * Example: {@code "assets://index.android.js" or "/sdcard/main.jsbundle} + * Example: {@code "assets://index.android.js" or "/sdcard/main.jsbundle"} */ public Builder setJSBundleFile(String jsBundleFile) { mJSBundleFile = jsBundleFile; diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/AlertFragment.java b/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/AlertFragment.java new file mode 100644 index 000000000..04d6d421a --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/AlertFragment.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.modules.dialog; + +import javax.annotation.Nullable; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.DialogInterface; +import android.content.Context; +import android.os.Bundle; + +/** + * A fragment used to display the dialog. + */ +/* package */ class AlertFragment extends DialogFragment implements DialogInterface.OnClickListener { + + /* package */ static final String ARG_TITLE = "title"; + /* package */ static final String ARG_MESSAGE = "message"; + /* package */ static final String ARG_BUTTON_POSITIVE = "button_positive"; + /* package */ static final String ARG_BUTTON_NEGATIVE = "button_negative"; + /* package */ static final String ARG_BUTTON_NEUTRAL = "button_neutral"; + + private final @Nullable DialogModule.AlertFragmentListener mListener; + + public AlertFragment(@Nullable DialogModule.AlertFragmentListener listener, Bundle arguments) { + mListener = listener; + setArguments(arguments); + } + + public static Dialog createDialog( + Context activityContext, Bundle arguments, DialogInterface.OnClickListener fragment) { + AlertDialog.Builder builder = new AlertDialog.Builder(activityContext) + .setTitle(arguments.getString(ARG_TITLE)) + .setMessage(arguments.getString(ARG_MESSAGE)); + + if (arguments.containsKey(ARG_BUTTON_POSITIVE)) { + builder.setPositiveButton(arguments.getString(ARG_BUTTON_POSITIVE), fragment); + } + if (arguments.containsKey(ARG_BUTTON_NEGATIVE)) { + builder.setNegativeButton(arguments.getString(ARG_BUTTON_NEGATIVE), fragment); + } + if (arguments.containsKey(ARG_BUTTON_NEUTRAL)) { + builder.setNeutralButton(arguments.getString(ARG_BUTTON_NEUTRAL), fragment); + } + + return builder.create(); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return createDialog(getActivity(), getArguments(), this); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (mListener != null) { + mListener.onClick(dialog, which); + } + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + if (mListener != null) { + mListener.onDismiss(dialog); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/DialogModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/DialogModule.java new file mode 100644 index 000000000..10dfbc8d4 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/DialogModule.java @@ -0,0 +1,255 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.modules.dialog; + +import javax.annotation.Nullable; + +import java.util.Map; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.DialogInterface.OnDismissListener; +import android.os.Bundle; +import android.support.v4.app.FragmentActivity; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.common.MapBuilder; + +public class DialogModule extends ReactContextBaseJavaModule implements LifecycleEventListener { + + /* package */ static final String FRAGMENT_TAG = + "com.facebook.catalyst.react.dialog.DialogModule"; + + /* package */ static final String NAME = "DialogManagerAndroid"; + + /* package */ static final String ACTION_BUTTON_CLICKED = "buttonClicked"; + /* package */ static final String ACTION_DISMISSED = "dismissed"; + /* package */ static final String KEY_TITLE = "title"; + /* package */ static final String KEY_MESSAGE = "message"; + /* package */ static final String KEY_BUTTON_POSITIVE = "buttonPositive"; + /* package */ static final String KEY_BUTTON_NEGATIVE = "buttonNegative"; + /* package */ static final String KEY_BUTTON_NEUTRAL = "buttonNeutral"; + + /* package */ static final Map CONSTANTS = MapBuilder.of( + ACTION_BUTTON_CLICKED, ACTION_BUTTON_CLICKED, + ACTION_DISMISSED, ACTION_DISMISSED, + KEY_BUTTON_POSITIVE, DialogInterface.BUTTON_POSITIVE, + KEY_BUTTON_NEGATIVE, DialogInterface.BUTTON_NEGATIVE, + KEY_BUTTON_NEUTRAL, DialogInterface.BUTTON_NEUTRAL); + + private boolean mIsInForeground; + + public DialogModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return NAME; + } + + /** + * Helper to allow this module to work with both the standard FragmentManager + * and the Support FragmentManager (for apps that need to use it for legacy reasons). + * Since the two APIs don't share a common interface there's unfortunately some + * code duplication. + */ + private class FragmentManagerHelper { + + // Exactly one of the two is null + private final @Nullable android.app.FragmentManager mFragmentManager; + private final @Nullable android.support.v4.app.FragmentManager mSupportFragmentManager; + + private @Nullable Object mFragmentToShow; + + private boolean isUsingSupportLibrary() { + return mSupportFragmentManager != null; + } + + public FragmentManagerHelper(android.support.v4.app.FragmentManager supportFragmentManager) { + mFragmentManager = null; + mSupportFragmentManager = supportFragmentManager; + } + public FragmentManagerHelper(android.app.FragmentManager fragmentManager) { + mFragmentManager = fragmentManager; + mSupportFragmentManager = null; + } + + public void showPendingAlert() { + if (mFragmentToShow == null) { + return; + } + if (isUsingSupportLibrary()) { + ((SupportAlertFragment) mFragmentToShow).show(mSupportFragmentManager, FRAGMENT_TAG); + } else { + ((AlertFragment) mFragmentToShow).show(mFragmentManager, FRAGMENT_TAG); + } + mFragmentToShow = null; + } + + private void dismissExisting() { + if (isUsingSupportLibrary()) { + SupportAlertFragment oldFragment = + (SupportAlertFragment) mSupportFragmentManager.findFragmentByTag(FRAGMENT_TAG); + if (oldFragment != null) { + oldFragment.dismiss(); + } + } else { + AlertFragment oldFragment = + (AlertFragment) mFragmentManager.findFragmentByTag(FRAGMENT_TAG); + if (oldFragment != null) { + oldFragment.dismiss(); + } + } + } + + public void showNewAlert(boolean isInForeground, Bundle arguments, Callback actionCallback) { + dismissExisting(); + + AlertFragmentListener actionListener = + actionCallback != null ? new AlertFragmentListener(actionCallback) : null; + + if (isUsingSupportLibrary()) { + SupportAlertFragment alertFragment = new SupportAlertFragment(actionListener, arguments); + if (isInForeground) { + alertFragment.show(mSupportFragmentManager, FRAGMENT_TAG); + } else { + mFragmentToShow = alertFragment; + } + } else { + AlertFragment alertFragment = new AlertFragment(actionListener, arguments); + if (isInForeground) { + alertFragment.show(mFragmentManager, FRAGMENT_TAG); + } else { + mFragmentToShow = alertFragment; + } + } + } + } + + /* package */ class AlertFragmentListener implements OnClickListener, OnDismissListener { + + private final Callback mCallback; + private boolean mCallbackConsumed = false; + + public AlertFragmentListener(Callback callback) { + mCallback = callback; + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (!mCallbackConsumed) { + if (getReactApplicationContext().hasActiveCatalystInstance()) { + mCallback.invoke(ACTION_BUTTON_CLICKED, which); + mCallbackConsumed = true; + } + } + } + + @Override + public void onDismiss(DialogInterface dialog) { + if (!mCallbackConsumed) { + if (getReactApplicationContext().hasActiveCatalystInstance()) { + mCallback.invoke(ACTION_DISMISSED); + mCallbackConsumed = true; + } + } + } + } + + @Override + public Map getConstants() { + return CONSTANTS; + } + + @Override + public void initialize() { + getReactApplicationContext().addLifecycleEventListener(this); + } + + @Override + public void onHostPause() { + // Don't show the dialog if the host is paused. + mIsInForeground = false; + } + + @Override + public void onHostDestroy() { + } + + @Override + public void onHostResume() { + mIsInForeground = true; + // Check if a dialog has been created while the host was paused, so that we can show it now. + FragmentManagerHelper fragmentManagerHelper = getFragmentManagerHelper(); + Assertions.assertNotNull( + fragmentManagerHelper, + "Attached DialogModule to host with pending alert but no FragmentManager " + + "(not attached to an Activity)."); + fragmentManagerHelper.showPendingAlert(); + } + + @ReactMethod + public void showAlert( + ReadableMap options, + Callback errorCallback, + Callback actionCallback) { + FragmentManagerHelper fragmentManagerHelper = getFragmentManagerHelper(); + if (fragmentManagerHelper == null) { + errorCallback.invoke("Tried to show an alert while not attached to an Activity"); + return; + } + + final Bundle args = new Bundle(); + if (options.hasKey(KEY_TITLE)) { + args.putString(AlertFragment.ARG_TITLE, options.getString(KEY_TITLE)); + } + if (options.hasKey(KEY_MESSAGE)) { + args.putString(AlertFragment.ARG_MESSAGE, options.getString(KEY_MESSAGE)); + } + if (options.hasKey(KEY_BUTTON_POSITIVE)) { + args.putString(AlertFragment.ARG_BUTTON_POSITIVE, options.getString(KEY_BUTTON_POSITIVE)); + } + if (options.hasKey(KEY_BUTTON_NEGATIVE)) { + args.putString(AlertFragment.ARG_BUTTON_NEGATIVE, options.getString(KEY_BUTTON_NEGATIVE)); + } + if (options.hasKey(KEY_BUTTON_NEUTRAL)) { + args.putString(AlertFragment.ARG_BUTTON_NEUTRAL, options.getString(KEY_BUTTON_NEUTRAL)); + } + + fragmentManagerHelper.showNewAlert(mIsInForeground, args, actionCallback); + } + + /** + * Creates a new helper to work with either the FragmentManager or the legacy support + * FragmentManager transparently. Returns null if we're not attached to an Activity. + * + * DO NOT HOLD LONG-LIVED REFERENCES TO THE OBJECT RETURNED BY THIS METHOD, AS THIS WILL CAUSE + * MEMORY LEAKS. + */ + private @Nullable FragmentManagerHelper getFragmentManagerHelper() { + Activity activity = getCurrentActivity(); + if (activity == null) { + return null; + } + if (activity instanceof FragmentActivity) { + return new FragmentManagerHelper(((FragmentActivity) activity).getSupportFragmentManager()); + } else { + return new FragmentManagerHelper(activity.getFragmentManager()); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/SupportAlertFragment.java b/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/SupportAlertFragment.java new file mode 100644 index 000000000..fb987cd05 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/SupportAlertFragment.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.modules.dialog; + +import javax.annotation.Nullable; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; + +import android.support.v4.app.DialogFragment; + +/** + * {@link AlertFragment} for apps that use the Support FragmentActivity and FragmentManager + * for legacy reasons. + */ +/* package */ class SupportAlertFragment extends DialogFragment implements DialogInterface.OnClickListener { + + private final @Nullable DialogModule.AlertFragmentListener mListener; + + public SupportAlertFragment(@Nullable DialogModule.AlertFragmentListener listener, Bundle arguments) { + mListener = listener; + setArguments(arguments); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return AlertFragment.createDialog(getActivity(), getArguments(), this); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (mListener != null) { + mListener.onClick(dialog, which); + } + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + if (mListener != null) { + mListener.onDismiss(dialog); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java index d6d8e37fb..aae0e9322 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java @@ -17,6 +17,7 @@ import com.facebook.react.ReactPackage; import com.facebook.react.bridge.JavaScriptModule; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.modules.dialog.DialogModule; import com.facebook.react.modules.fresco.FrescoModule; import com.facebook.react.modules.intent.IntentModule; import com.facebook.react.modules.location.LocationModule; @@ -53,6 +54,7 @@ public class MainReactPackage implements ReactPackage { return Arrays.asList( new AsyncStorageModule(reactContext), new ClipboardModule(reactContext), + new DialogModule(reactContext), new FrescoModule(reactContext), new IntentModule(reactContext), new LocationModule(reactContext), diff --git a/ReactAndroid/src/test/java/com/facebook/react/modules/dialog/DialogModuleTest.java b/ReactAndroid/src/test/java/com/facebook/react/modules/dialog/DialogModuleTest.java new file mode 100644 index 000000000..f0ae5dc3a --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/dialog/DialogModuleTest.java @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.modules.dialog; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.app.Activity; + +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.SimpleMap; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.util.ActivityController; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(RobolectricTestRunner.class) +@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*"}) +public class DialogModuleTest { + + private ActivityController mActivityController; + private Activity mActivity; + private DialogModule mDialogModule; + + final static class SimpleCallback implements Callback { + private Object[] mArgs; + private int mCalls; + + @Override + public void invoke(Object... args) { + mCalls++; + mArgs = args; + } + + public int getCalls() { + return mCalls; + } + + public Object[] getArgs() { + return mArgs; + } + } + + @Before + public void setUp() throws Exception { + mActivityController = Robolectric.buildActivity(Activity.class); + mActivity = mActivityController + .create() + .start() + .resume() + .get(); + + final ReactApplicationContext context = PowerMockito.mock(ReactApplicationContext.class); + PowerMockito.when(context.hasActiveCatalystInstance()).thenReturn(true); + PowerMockito.when(context, "getCurrentActivity").thenReturn(mActivity); + + mDialogModule = new DialogModule(context); + mDialogModule.onHostResume(); + } + + @After + public void tearDown() { + mActivityController.pause().stop().destroy(); + + mActivityController = null; + mDialogModule = null; + } + + @Test + public void testAllOptions() { + final SimpleMap options = new SimpleMap(); + options.putString("title", "Title"); + options.putString("message", "Message"); + options.putString("buttonPositive", "OK"); + options.putString("buttonNegative", "Cancel"); + options.putString("buttonNeutral", "Later"); + + mDialogModule.showAlert(options, null, null); + + final AlertFragment fragment = getFragment(); + assertNotNull("Fragment was not displayed", fragment); + + final AlertDialog dialog = (AlertDialog) fragment.getDialog(); + assertEquals("OK", dialog.getButton(DialogInterface.BUTTON_POSITIVE).getText().toString()); + assertEquals("Cancel", dialog.getButton(DialogInterface.BUTTON_NEGATIVE).getText().toString()); + assertEquals("Later", dialog.getButton(DialogInterface.BUTTON_NEUTRAL).getText().toString()); + } + + @Test + public void testCallbackPositive() { + final SimpleMap options = new SimpleMap(); + options.putString("buttonPositive", "OK"); + + final SimpleCallback actionCallback = new SimpleCallback(); + mDialogModule.showAlert(options, null, actionCallback); + + final AlertDialog dialog = (AlertDialog) getFragment().getDialog(); + dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick(); + + assertEquals(1, actionCallback.getCalls()); + assertEquals(DialogModule.ACTION_BUTTON_CLICKED, actionCallback.getArgs()[0]); + assertEquals(DialogInterface.BUTTON_POSITIVE, actionCallback.getArgs()[1]); + } + + @Test + public void testCallbackNegative() { + final SimpleMap options = new SimpleMap(); + options.putString("buttonNegative", "Cancel"); + + final SimpleCallback actionCallback = new SimpleCallback(); + mDialogModule.showAlert(options, null, actionCallback); + + final AlertDialog dialog = (AlertDialog) getFragment().getDialog(); + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).performClick(); + + assertEquals(1, actionCallback.getCalls()); + assertEquals(DialogModule.ACTION_BUTTON_CLICKED, actionCallback.getArgs()[0]); + assertEquals(DialogInterface.BUTTON_NEGATIVE, actionCallback.getArgs()[1]); + } + + @Test + public void testCallbackNeutral() { + final SimpleMap options = new SimpleMap(); + options.putString("buttonNeutral", "Later"); + + final SimpleCallback actionCallback = new SimpleCallback(); + mDialogModule.showAlert(options, null, actionCallback); + + final AlertDialog dialog = (AlertDialog) getFragment().getDialog(); + dialog.getButton(DialogInterface.BUTTON_NEUTRAL).performClick(); + + assertEquals(1, actionCallback.getCalls()); + assertEquals(DialogModule.ACTION_BUTTON_CLICKED, actionCallback.getArgs()[0]); + assertEquals(DialogInterface.BUTTON_NEUTRAL, actionCallback.getArgs()[1]); + } + + @Test + public void testCallbackDismiss() { + final SimpleMap options = new SimpleMap(); + + final SimpleCallback actionCallback = new SimpleCallback(); + mDialogModule.showAlert(options, null, actionCallback); + + getFragment().getDialog().dismiss(); + + assertEquals(1, actionCallback.getCalls()); + assertEquals(DialogModule.ACTION_DISMISSED, actionCallback.getArgs()[0]); + } + + private AlertFragment getFragment() { + return (AlertFragment) mActivity.getFragmentManager() + .findFragmentByTag(DialogModule.FRAGMENT_TAG); + } +}