1const fs = require("node:fs");
2const path = require("node:path");
3const {
4 AndroidConfig,
5 withAndroidColors,
6 withAndroidColorsNight,
7 withAndroidStyles,
8 withDangerousMod,
9} = require("expo/config-plugins");
10
11const POPUP_BACKGROUND_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
12<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
13 <solid android:color="@color/popup_menu_background" />
14 <corners android:radius="12dp" />
15</shape>
16`;
17
18const CHECK_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
19<vector xmlns:android="http://schemas.android.com/apk/res/android"
20 android:width="18dp"
21 android:height="18dp"
22 android:viewportWidth="24"
23 android:viewportHeight="24"
24 android:tint="@color/popup_menu_item_text">
25 <path
26 android:fillColor="#FFFFFFFF"
27 android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
28</vector>
29`;
30
31const CHECKBOX_BUTTON_SELECTOR = `<?xml version="1.0" encoding="utf-8"?>
32<selector xmlns:android="http://schemas.android.com/apk/res/android">
33 <item android:state_checked="true" android:drawable="@drawable/ic_menu_check" />
34 <item android:drawable="@android:color/transparent" />
35</selector>
36`;
37
38const SUBMENU_ARROW_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
39<vector xmlns:android="http://schemas.android.com/apk/res/android"
40 android:width="20dp"
41 android:height="20dp"
42 android:viewportWidth="24"
43 android:viewportHeight="24"
44 android:alpha="0.6"
45 android:tint="@color/popup_menu_item_text">
46 <path
47 android:pathData="M9,6l6,6l-6,6"
48 android:strokeColor="#FFFFFFFF"
49 android:strokeWidth="2"
50 android:strokeLineCap="round"
51 android:strokeLineJoin="round"
52 android:fillColor="#00000000" />
53</vector>
54`;
55
56const COLORS = {
57 light: { background: "#F7F7F7", itemText: "#262626" },
58 night: { background: "#161616", itemText: "#F5F5F5" },
59};
60
61function assignStyleItem(style, name, value) {
62 style.item = style.item ?? [];
63 const existing = style.item.find((item) => item.$?.name === name);
64 if (existing) {
65 existing._ = value;
66 } else {
67 style.item.push({ _: value, $: { name } });
68 }
69}
70
71function withPopupMenuStyles(config) {
72 return withAndroidStyles(config, (config) => {
73 const resources = config.modResults.resources;
74 resources.style = resources.style ?? [];
75
76 const appTheme = resources.style.find((style) => style.$?.name === "AppTheme");
77 if (appTheme) {
78 assignStyleItem(appTheme, "popupMenuStyle", "@style/AppPopupMenu");
79 assignStyleItem(appTheme, "android:popupMenuStyle", "@style/AppPopupMenu");
80 assignStyleItem(
81 appTheme,
82 "textAppearanceLargePopupMenu",
83 "@style/AppPopupMenu.TextAppearance",
84 );
85 assignStyleItem(
86 appTheme,
87 "textAppearanceSmallPopupMenu",
88 "@style/AppPopupMenu.TextAppearance",
89 );
90 // Submenu headers read a separate text appearance.
91 assignStyleItem(
92 appTheme,
93 "textAppearancePopupMenuHeader",
94 "@style/AppPopupMenu.HeaderTextAppearance",
95 );
96 assignStyleItem(appTheme, "android:listMenuViewStyle", "@style/AppPopupMenuListMenuView");
97 // Popups may inflate framework or AppCompat CheckBoxes.
98 assignStyleItem(appTheme, "android:checkboxStyle", "@style/AppPopupMenuCheckBox");
99 assignStyleItem(appTheme, "checkboxStyle", "@style/AppPopupMenuCheckBoxCompat");
100 }
101
102 resources.style = resources.style.filter(
103 (style) =>
104 ![
105 "AppPopupMenu",
106 "AppPopupMenu.TextAppearance",
107 "AppPopupMenu.HeaderTextAppearance",
108 "AppPopupMenuListMenuView",
109 "AppPopupMenuCheckBox",
110 "AppPopupMenuCheckBoxCompat",
111 ].includes(style.$?.name),
112 );
113 resources.style.push(
114 {
115 $: { name: "AppPopupMenu", parent: "Widget.AppCompat.PopupMenu" },
116 item: [
117 { _: "@drawable/popup_menu_background", $: { name: "android:popupBackground" } },
118 { _: "false", $: { name: "android:overlapAnchor" } },
119 { _: "4dp", $: { name: "android:dropDownVerticalOffset" } },
120 ],
121 },
122 {
123 $: { name: "AppPopupMenu.TextAppearance", parent: "TextAppearance.AppCompat.Menu" },
124 item: [
125 { _: "15sp", $: { name: "android:textSize" } },
126 { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } },
127 { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } },
128 ],
129 },
130 {
131 $: {
132 name: "AppPopupMenu.HeaderTextAppearance",
133 parent: "TextAppearance.AppCompat.Widget.PopupMenu.Header",
134 },
135 item: [
136 { _: "15sp", $: { name: "android:textSize" } },
137 { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } },
138 { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } },
139 ],
140 },
141 // The framework default only carries subMenuArrow.
142 {
143 $: { name: "AppPopupMenuListMenuView" },
144 item: [{ _: "@drawable/popup_menu_submenu_arrow", $: { name: "android:subMenuArrow" } }],
145 },
146 {
147 $: {
148 name: "AppPopupMenuCheckBox",
149 parent: "android:Widget.Material.CompoundButton.CheckBox",
150 },
151 item: [{ _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }],
152 },
153 {
154 $: {
155 name: "AppPopupMenuCheckBoxCompat",
156 parent: "Widget.AppCompat.CompoundButton.CheckBox",
157 },
158 item: [
159 { _: "@drawable/popup_menu_check_button", $: { name: "android:button" } },
160 // buttonCompat wins over android:button in AppCompatCheckBox.
161 { _: "@drawable/popup_menu_check_button", $: { name: "buttonCompat" } },
162 ],
163 },
164 );
165
166 return config;
167 });
168}
169
170function withPopupMenuColors(config) {
171 config = withAndroidColors(config, (config) => {
172 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
173 name: "popup_menu_background",
174 value: COLORS.light.background,
175 });
176 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
177 name: "popup_menu_item_text",
178 value: COLORS.light.itemText,
179 });
180 return config;
181 });
182 config = withAndroidColorsNight(config, (config) => {
183 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
184 name: "popup_menu_background",
185 value: COLORS.night.background,
186 });
187 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
188 name: "popup_menu_item_text",
189 value: COLORS.night.itemText,
190 });
191 return config;
192 });
193 return config;
194}
195
196function withPopupMenuBackgroundDrawable(config) {
197 return withDangerousMod(config, [
198 "android",
199 async (config) => {
200 const drawableDir = path.join(
201 config.modRequest.platformProjectRoot,
202 "app",
203 "src",
204 "main",
205 "res",
206 "drawable",
207 );
208 fs.mkdirSync(drawableDir, { recursive: true });
209 fs.writeFileSync(
210 path.join(drawableDir, "popup_menu_background.xml"),
211 POPUP_BACKGROUND_DRAWABLE,
212 );
213 fs.writeFileSync(path.join(drawableDir, "ic_menu_check.xml"), CHECK_DRAWABLE);
214 fs.writeFileSync(
215 path.join(drawableDir, "popup_menu_check_button.xml"),
216 CHECKBOX_BUTTON_SELECTOR,
217 );
218 fs.writeFileSync(
219 path.join(drawableDir, "popup_menu_submenu_arrow.xml"),
220 SUBMENU_ARROW_DRAWABLE,
221 );
222 return config;
223 },
224 ]);
225}
226
227module.exports = function withAndroidModernPopupMenu(config) {
228 return withPopupMenuBackgroundDrawable(withPopupMenuColors(withPopupMenuStyles(config)));
229};