1const fs = require("node:fs");
2const path = require("node:path");
3const themeVariables = require("../generated-uniwind-default-theme-variables.json");
4const {
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.
22const 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
35const 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
45const COLORS = { light: colorsFor("light"), night: colorsFor("dark") };
46
47function 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
57function 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
130function 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
151function 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
163function 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
185module.exports = function withAndroidModernAlertDialog(config) {
186 return withAlertDialogBackgroundDrawable(withAlertDialogColors(withAlertDialogStyles(config)));
187};