const fs = require("node:fs"); const path = require("node:path"); const { AndroidConfig, withAndroidColors, withAndroidColorsNight, withAndroidStyles, withDangerousMod, } = require("expo/config-plugins"); // React Native's Alert renders an AppCompat AlertDialog on Android, which // inherits the dated framework dialog chrome (square gray panel, teal // all-caps buttons) from the app theme. These resources restyle it with the // app's uniwind tokens from global.css: --color-card panel, --color-foreground // text, --color-primary buttons, DM Sans type. The @font resources referenced // here are embedded by the expo-font plugin config in app.config.ts. // AppCompat's default dialog window background is an inset rounded rect, so // the replacement keeps the same 16dp inset to preserve the dialog's margins. const DIALOG_BACKGROUND_DRAWABLE = ` `; const COLORS = { light: { background: "#FFFFFF", // --color-card text: "#262626", // --color-foreground secondaryText: "#525252", // --color-foreground-secondary buttonText: "#262626", // --color-primary }, night: { background: "#171717", text: "#F5F5F5", secondaryText: "#A3A3A3", buttonText: "#F5F5F5", }, }; function assignStyleItem(style, name, value) { style.item = style.item ?? []; const existing = style.item.find((item) => item.$?.name === name); if (existing) { existing._ = value; } else { style.item.push({ _: value, $: { name } }); } } function withAlertDialogStyles(config) { return withAndroidStyles(config, (config) => { const resources = config.modResults.resources; resources.style = resources.style ?? []; const appTheme = resources.style.find((style) => style.$?.name === "AppTheme"); if (appTheme) { // React Native's dialog module builds an androidx.appcompat AlertDialog, // which resolves its theme from the AppCompat attr; the framework attr is // set too for any native code that inflates a platform AlertDialog. assignStyleItem(appTheme, "alertDialogTheme", "@style/AppAlertDialog"); assignStyleItem(appTheme, "android:alertDialogTheme", "@style/AppAlertDialog"); } resources.style = resources.style.filter( (style) => !["AppAlertDialog", "AppAlertDialog.Title", "AppAlertDialog.Button"].includes( style.$?.name, ), ); resources.style.push( { $: { name: "AppAlertDialog", parent: "ThemeOverlay.AppCompat.Dialog.Alert" }, item: [ { _: "@drawable/alert_dialog_background", $: { name: "android:windowBackground" } }, // The message body resolves textColorPrimary in AppCompat's alert // layout; pointing it at the secondary token dims the message // relative to the title, which keeps full-strength text via the // explicit color in AppAlertDialog.Title. { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorPrimary" } }, { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorSecondary" } }, // Theme-level fontFamily is the lowest-priority fallback in attribute // resolution, so it reaches every text view in the dialog that does // not carry its own fontFamily (the message body in particular). { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, // AppCompat's alert title view styles itself from the framework // attr (?android:attr/windowTitleStyle); there is no unprefixed // AppCompat equivalent. { _: "@style/AppAlertDialog.Title", $: { name: "android:windowTitleStyle" } }, { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarPositiveButtonStyle" } }, { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNegativeButtonStyle" } }, { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNeutralButtonStyle" } }, ], }, { $: { name: "AppAlertDialog.Title", parent: "RtlOverlay.DialogWindowTitle.AppCompat" }, item: [ { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, { _: "18sp", $: { name: "android:textSize" } }, { _: "@color/alert_dialog_text", $: { name: "android:textColor" } }, ], }, { $: { name: "AppAlertDialog.Button", parent: "Widget.AppCompat.Button.ButtonBar.AlertDialog", }, item: [ // The AppCompat button appearance hardcodes sans-serif-medium, so // the font must be set here rather than relying on the theme // fallback. { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, { _: "@color/alert_dialog_button_text", $: { name: "android:textColor" } }, { _: "false", $: { name: "android:textAllCaps" } }, { _: "0", $: { name: "android:letterSpacing" } }, ], }, ); return config; }); } function assignColors(colorsResource, palette) { let result = colorsResource; result = AndroidConfig.Colors.assignColorValue(result, { name: "alert_dialog_background", value: palette.background, }); result = AndroidConfig.Colors.assignColorValue(result, { name: "alert_dialog_text", value: palette.text, }); result = AndroidConfig.Colors.assignColorValue(result, { name: "alert_dialog_secondary_text", value: palette.secondaryText, }); result = AndroidConfig.Colors.assignColorValue(result, { name: "alert_dialog_button_text", value: palette.buttonText, }); return result; } function withAlertDialogColors(config) { config = withAndroidColors(config, (config) => { config.modResults = assignColors(config.modResults, COLORS.light); return config; }); config = withAndroidColorsNight(config, (config) => { config.modResults = assignColors(config.modResults, COLORS.night); return config; }); return config; } function withAlertDialogBackgroundDrawable(config) { return withDangerousMod(config, [ "android", async (config) => { const drawableDir = path.join( config.modRequest.platformProjectRoot, "app", "src", "main", "res", "drawable", ); fs.mkdirSync(drawableDir, { recursive: true }); fs.writeFileSync( path.join(drawableDir, "alert_dialog_background.xml"), DIALOG_BACKGROUND_DRAWABLE, ); return config; }, ]); } module.exports = function withAndroidModernAlertDialog(config) { return withAlertDialogBackgroundDrawable(withAlertDialogColors(withAlertDialogStyles(config))); };