| 1 | const fs = require("node:fs"); |
| 2 | const path = require("node:path"); |
| 3 | const themeVariables = require("../generated-uniwind-default-theme-variables.json"); |
| 4 | const { |
| 5 | AndroidConfig, |
| 6 | withAndroidColors, |
| 7 | withAndroidColorsNight, |
| 8 | withAndroidStyles, |
| 9 | withDangerousMod, |
| 10 | } = require("expo/config-plugins"); |
| 11 | |
| 12 | // React Native's Alert renders an AppCompat AlertDialog on Android, which |
| 13 | // inherits the dated framework dialog chrome (square gray panel, teal |
| 14 | // all-caps buttons) from the app theme. These resources restyle it with the |
| 15 | // generated default palette: card panel, foreground text, readable primary |
| 16 | // buttons, DM Sans type. Alert exposes no runtime custom-palette API, so these |
| 17 | // build-time resources use the stock palette for each native appearance. |
| 18 | // The fonts are embedded by the expo-font plugin config in app.config.ts. |
| 19 | |
| 20 | // AppCompat's default dialog window background is an inset rounded rect, so |
| 21 | // the replacement keeps the same 16dp inset to preserve the dialog's margins. |
| 22 | const DIALOG_BACKGROUND_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?> |
| 23 | <inset xmlns:android="http://schemas.android.com/apk/res/android" |
| 24 | android:insetLeft="16dp" |
| 25 | android:insetTop="16dp" |
| 26 | android:insetRight="16dp" |
| 27 | android:insetBottom="16dp"> |
| 28 | <shape android:shape="rectangle"> |
| 29 | <solid android:color="@color/alert_dialog_background" /> |
| 30 | <corners android:radius="24dp" /> |
| 31 | </shape> |
| 32 | </inset> |
| 33 | `; |
| 34 | |
| 35 | const colorsFor = (appearance) => { |
| 36 | const variables = themeVariables[appearance]; |
| 37 | return { |
| 38 | background: variables["--color-card"], |
| 39 | text: variables["--color-foreground"], |
| 40 | secondaryText: variables["--color-foreground-secondary"], |
| 41 | buttonText: variables["--color-primary-text"], |
| 42 | }; |
| 43 | }; |
| 44 | |
| 45 | const COLORS = { light: colorsFor("light"), night: colorsFor("dark") }; |
| 46 | |
| 47 | function assignStyleItem(style, name, value) { |
| 48 | style.item = style.item ?? []; |
| 49 | const existing = style.item.find((item) => item.$?.name === name); |
| 50 | if (existing) { |
| 51 | existing._ = value; |
| 52 | } else { |
| 53 | style.item.push({ _: value, $: { name } }); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | function withAlertDialogStyles(config) { |
| 58 | return withAndroidStyles(config, (config) => { |
| 59 | const resources = config.modResults.resources; |
| 60 | resources.style = resources.style ?? []; |
| 61 | |
| 62 | const appTheme = resources.style.find((style) => style.$?.name === "AppTheme"); |
| 63 | if (appTheme) { |
| 64 | // React Native's dialog module builds an androidx.appcompat AlertDialog, |
| 65 | // which resolves its theme from the AppCompat attr; the framework attr is |
| 66 | // set too for any native code that inflates a platform AlertDialog. |
| 67 | assignStyleItem(appTheme, "alertDialogTheme", "@style/AppAlertDialog"); |
| 68 | assignStyleItem(appTheme, "android:alertDialogTheme", "@style/AppAlertDialog"); |
| 69 | } |
| 70 | |
| 71 | resources.style = resources.style.filter( |
| 72 | (style) => |
| 73 | !["AppAlertDialog", "AppAlertDialog.Title", "AppAlertDialog.Button"].includes( |
| 74 | style.$?.name, |
| 75 | ), |
| 76 | ); |
| 77 | resources.style.push( |
| 78 | { |
| 79 | $: { name: "AppAlertDialog", parent: "ThemeOverlay.AppCompat.Dialog.Alert" }, |
| 80 | item: [ |
| 81 | { _: "@drawable/alert_dialog_background", $: { name: "android:windowBackground" } }, |
| 82 | // The message body resolves textColorPrimary in AppCompat's alert |
| 83 | // layout; pointing it at the secondary token dims the message |
| 84 | // relative to the title, which keeps full-strength text via the |
| 85 | // explicit color in AppAlertDialog.Title. |
| 86 | { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorPrimary" } }, |
| 87 | { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorSecondary" } }, |
| 88 | // Theme-level fontFamily is the lowest-priority fallback in attribute |
| 89 | // resolution, so it reaches every text view in the dialog that does |
| 90 | // not carry its own fontFamily (the message body in particular). |
| 91 | { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, |
| 92 | // AppCompat's alert title view styles itself from the framework |
| 93 | // attr (?android:attr/windowTitleStyle); there is no unprefixed |
| 94 | // AppCompat equivalent. |
| 95 | { _: "@style/AppAlertDialog.Title", $: { name: "android:windowTitleStyle" } }, |
| 96 | { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarPositiveButtonStyle" } }, |
| 97 | { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNegativeButtonStyle" } }, |
| 98 | { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNeutralButtonStyle" } }, |
| 99 | ], |
| 100 | }, |
| 101 | { |
| 102 | $: { name: "AppAlertDialog.Title", parent: "RtlOverlay.DialogWindowTitle.AppCompat" }, |
| 103 | item: [ |
| 104 | { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, |
| 105 | { _: "18sp", $: { name: "android:textSize" } }, |
| 106 | { _: "@color/alert_dialog_text", $: { name: "android:textColor" } }, |
| 107 | ], |
| 108 | }, |
| 109 | { |
| 110 | $: { |
| 111 | name: "AppAlertDialog.Button", |
| 112 | parent: "Widget.AppCompat.Button.ButtonBar.AlertDialog", |
| 113 | }, |
| 114 | item: [ |
| 115 | // The AppCompat button appearance hardcodes sans-serif-medium, so |
| 116 | // the font must be set here rather than relying on the theme |
| 117 | // fallback. |
| 118 | { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, |
| 119 | { _: "@color/alert_dialog_button_text", $: { name: "android:textColor" } }, |
| 120 | { _: "false", $: { name: "android:textAllCaps" } }, |
| 121 | { _: "0", $: { name: "android:letterSpacing" } }, |
| 122 | ], |
| 123 | }, |
| 124 | ); |
| 125 | |
| 126 | return config; |
| 127 | }); |
| 128 | } |
| 129 | |
| 130 | function assignColors(colorsResource, palette) { |
| 131 | let result = colorsResource; |
| 132 | result = AndroidConfig.Colors.assignColorValue(result, { |
| 133 | name: "alert_dialog_background", |
| 134 | value: palette.background, |
| 135 | }); |
| 136 | result = AndroidConfig.Colors.assignColorValue(result, { |
| 137 | name: "alert_dialog_text", |
| 138 | value: palette.text, |
| 139 | }); |
| 140 | result = AndroidConfig.Colors.assignColorValue(result, { |
| 141 | name: "alert_dialog_secondary_text", |
| 142 | value: palette.secondaryText, |
| 143 | }); |
| 144 | result = AndroidConfig.Colors.assignColorValue(result, { |
| 145 | name: "alert_dialog_button_text", |
| 146 | value: palette.buttonText, |
| 147 | }); |
| 148 | return result; |
| 149 | } |
| 150 | |
| 151 | function withAlertDialogColors(config) { |
| 152 | config = withAndroidColors(config, (config) => { |
| 153 | config.modResults = assignColors(config.modResults, COLORS.light); |
| 154 | return config; |
| 155 | }); |
| 156 | config = withAndroidColorsNight(config, (config) => { |
| 157 | config.modResults = assignColors(config.modResults, COLORS.night); |
| 158 | return config; |
| 159 | }); |
| 160 | return config; |
| 161 | } |
| 162 | |
| 163 | function withAlertDialogBackgroundDrawable(config) { |
| 164 | return withDangerousMod(config, [ |
| 165 | "android", |
| 166 | async (config) => { |
| 167 | const drawableDir = path.join( |
| 168 | config.modRequest.platformProjectRoot, |
| 169 | "app", |
| 170 | "src", |
| 171 | "main", |
| 172 | "res", |
| 173 | "drawable", |
| 174 | ); |
| 175 | fs.mkdirSync(drawableDir, { recursive: true }); |
| 176 | fs.writeFileSync( |
| 177 | path.join(drawableDir, "alert_dialog_background.xml"), |
| 178 | DIALOG_BACKGROUND_DRAWABLE, |
| 179 | ); |
| 180 | return config; |
| 181 | }, |
| 182 | ]); |
| 183 | } |
| 184 | |
| 185 | module.exports = function withAndroidModernAlertDialog(config) { |
| 186 | return withAlertDialogBackgroundDrawable(withAlertDialogColors(withAlertDialogStyles(config))); |
| 187 | }; |