11package expo.modules.focusmenu
22
3- import android.content.Context
4- import android.os.Build
5- import android.os.VibrationEffect
6- import android.os.Vibrator
7- import android.view.Menu
8- import android.view.MenuItem
9- import android.view.View
10- import android.widget.PopupMenu
113import expo.modules.kotlin.modules.Module
124import expo.modules.kotlin.modules.ModuleDefinition
13- import expo.modules.kotlin.Promise
14- import kotlinx.coroutines.CoroutineScope
15- import kotlinx.coroutines.Dispatchers
16- import kotlinx.coroutines.launch
17- import kotlinx.coroutines.withContext
185
196class ExpoFocusMenuModule : Module () {
20- private var currentPopupMenu: PopupMenu ? = null
21- private var menuItemCallbacks = mutableMapOf<String , (String ) - > Unit > ()
22-
23- override fun definition () = ModuleDefinition {
24- Name (" ExpoFocusMenu" )
25-
26- // Define events that can be sent to JavaScript
27- Events (" onMenuItemSelected" , " onMenuShown" , " onMenuDismissed" )
28-
29- // Show the native context menu
30- AsyncFunction (" showNativeMenu" ) { items: List <Map <String , Any >>, config: Map <String , Any >? , promise: Promise ->
31- showMenu(items, config ? : emptyMap(), promise)
32- }
33-
34- // Dismiss the currently visible menu
35- AsyncFunction (" dismissNativeMenu" ) {
36- dismissMenu()
37- }
38-
39- // Check if a menu is currently visible
40- AsyncFunction (" isNativeMenuVisible" ) {
41- currentPopupMenu != null
42- }
43-
44- // Set default configuration for menus
45- AsyncFunction (" setNativeMenuConfig" ) { config: Map <String , Any > ->
46- // Store config for future use (could be stored in SharedPreferences)
47- }
48-
49- // Export the native view
50- View (ExpoFocusMenuView ::class ) {
51- // Define props for the view
52- Prop (" items" ) { view, items: List <Map <String , Any >> ->
53- view.menuItems = items
54- }
55-
56- // Removed triggerMode - always use long press like iOS
57-
58- Prop (" hapticFeedback" ) { view, enabled: Boolean ->
59- view.hapticFeedback = enabled
60- }
61-
62- Prop (" reactions" ) { view, emojis: List <String >? ->
63- view.reactions = emojis ? : emptyList()
64- }
65-
66-
67- // Event handlers
68- Events (" onItemPress" , " onMenuShow" , " onMenuDismiss" , " onReactionPress" )
69- }
70- }
71-
72- private fun showMenu (items : List <Map <String , Any >>, config : Map <String , Any >, promise : Promise ) {
73- if (items.isEmpty()) {
74- promise.reject(" EMPTY_ITEMS" , " Menu items cannot be empty" , null )
75- return
76- }
77-
78- val context = appContext.currentActivity ? : appContext.reactContext ? : run {
79- promise.reject(" NO_ACTIVITY" , " No current activity available" , null )
80- return
81- }
82-
83- CoroutineScope (Dispatchers .Main ).launch {
84- try {
85- // Find the root view of the current activity
86- val rootView = context.window.decorView.findViewById<View >(android.R .id.content)
87-
88- // Create PopupMenu
89- val popupMenu = PopupMenu (context, rootView)
90- currentPopupMenu = popupMenu
91-
92- // Track menu item IDs to their string IDs
93- val menuItemIdMap = mutableMapOf<Int , String >()
94- var menuItemCounter = 1
95-
96- // Add items to the menu
97- addItemsToMenu(popupMenu.menu, items, menuItemIdMap, menuItemCounter)
98-
99- // Handle haptic feedback
100- val hapticFeedback = config[" hapticFeedback" ] as ? Boolean ? : false
101- if (hapticFeedback) {
102- provideHapticFeedback(context)
103- }
104-
105- // Set up menu item click listener
106- var itemSelected = false
107- popupMenu.setOnMenuItemClickListener { menuItem ->
108- val itemId = menuItemIdMap[menuItem.itemId]
109- if (itemId != null && menuItem.isEnabled) {
110- itemSelected = true
111- sendEvent(" onMenuItemSelected" , mapOf (" itemId" to itemId))
112- promise.resolve(itemId)
113- currentPopupMenu = null
114- }
115- true
116- }
117-
118- // Set up dismiss listener
119- popupMenu.setOnDismissListener {
120- if (! itemSelected) {
121- promise.resolve(null )
122- }
123- sendEvent(" onMenuDismissed" , emptyMap())
124- currentPopupMenu = null
125- }
126-
127- // Show the menu
128- popupMenu.show()
129- sendEvent(" onMenuShown" , emptyMap())
130-
131- } catch (e: Exception ) {
132- promise.reject(" MENU_ERROR" , " Failed to show menu: ${e.message} " , e)
133- currentPopupMenu = null
134- }
135- }
136- }
137-
138- private fun addItemsToMenu (
139- menu : Menu ,
140- items : List <Map <String , Any >>,
141- menuItemIdMap : MutableMap <Int , String >,
142- startId : Int
143- ): Int {
144- var currentId = startId
145-
146- for (item in items) {
147- val id = item[" id" ] as ? String ? : continue
148- val title = item[" title" ] as ? String ? : continue
149- val subtitle = item[" subtitle" ] as ? String
150- val disabled = item[" disabled" ] as ? Boolean ? : false
151- val destructive = item[" destructive" ] as ? Boolean ? : false
152- val icon = item[" icon" ] as ? String
153- val children = item[" children" ] as ? List <Map <String , Any >>
154-
155- // Combine title and subtitle for display
156- val displayTitle = if (subtitle != null ) {
157- " $title \n $subtitle "
158- } else {
159- title
160- }
161-
162- if (children != null && children.isNotEmpty()) {
163- // Create submenu (limited to 1 level deep like iOS)
164- val subMenu = menu.addSubMenu(displayTitle)
165-
166- // Add icon if available (only works on some Android versions)
167- if (icon != null ) {
168- val iconResource = getIconResource(icon)
169- if (iconResource != 0 ) {
170- subMenu.setIcon(iconResource)
171- }
172- }
173-
174- // Add children to submenu (no further nesting allowed)
175- for (child in children) {
176- val childId = child[" id" ] as ? String ? : continue
177- val childTitle = child[" title" ] as ? String ? : continue
178- val childSubtitle = child[" subtitle" ] as ? String
179- val childDisabled = child[" disabled" ] as ? Boolean ? : false
180-
181- val childDisplayTitle = if (childSubtitle != null ) {
182- " $childTitle \n $childSubtitle "
183- } else {
184- childTitle
185- }
186-
187- val childMenuItem = subMenu.add(Menu .NONE , currentId, Menu .NONE , childDisplayTitle)
188- menuItemIdMap[currentId] = childId
189- currentId++
190- childMenuItem.isEnabled = ! childDisabled
191-
192- // Add icon for child if available
193- val childIcon = child[" icon" ] as ? String
194- if (childIcon != null ) {
195- val childIconResource = getIconResource(childIcon)
196- if (childIconResource != 0 ) {
197- childMenuItem.setIcon(childIconResource)
7+ override fun definition () = ModuleDefinition {
8+ Name (" ExpoFocusMenu" )
9+
10+ // The native view component
11+ View (ExpoFocusMenuView ::class ) {
12+ // Events that fire back to JS
13+ Events (
14+ " onItemPress" ,
15+ " onReactionPress" ,
16+ " onMenuShow" ,
17+ " onMenuDismiss"
18+ )
19+
20+ // Props from JS
21+ Prop (" items" ) { view: ExpoFocusMenuView , items: List <Map <String , Any ?>> ->
22+ view.setMenuItems(items)
19823 }
199- }
200- }
201- } else {
202- // Create regular menu item
203- val menuItem = menu.add(Menu .NONE , currentId, Menu .NONE , displayTitle)
204- menuItemIdMap[currentId] = id
205- currentId++
20624
207- // Set enabled state
208- menuItem.isEnabled = ! disabled
25+ Prop (" reactions" ) { view: ExpoFocusMenuView , reactions: List <String >? ->
26+ view.setReactions(reactions)
27+ }
20928
210- // Add icon if available
211- if (icon != null ) {
212- val iconResource = getIconResource(icon)
213- if (iconResource != 0 ) {
214- menuItem.setIcon(iconResource)
215- }
29+ Prop (" hapticFeedback" ) { view: ExpoFocusMenuView , enabled: Boolean ->
30+ view.configureHapticFeedback(enabled)
31+ }
21632 }
217-
218- // Note: Android doesn't have a built-in "destructive" style like iOS
219- // You could implement custom styling if needed
220- }
22133 }
222-
223- return currentId
224- }
225-
226- private fun getIconResource (iconName : String ): Int {
227- // Map common icon names to Android drawable resources
228- return when (iconName) {
229- " doc.on.doc" , " copy" -> android.R .drawable.ic_menu_crop
230- " doc.on.clipboard" , " paste" -> android.R .drawable.ic_menu_edit
231- " trash" , " delete" -> android.R .drawable.ic_menu_delete
232- " arrowshape.turn.up.left" , " reply" -> android.R .drawable.ic_menu_revert
233- " arrowshape.turn.up.right" , " forward" -> android.R .drawable.ic_menu_send
234- " share" -> android.R .drawable.ic_menu_share
235- " edit" -> android.R .drawable.ic_menu_edit
236- " search" -> android.R .drawable.ic_menu_search
237- " add" , " plus" -> android.R .drawable.ic_menu_add
238- " close" -> android.R .drawable.ic_menu_close_clear_cancel
239- " info" -> android.R .drawable.ic_menu_info_details
240- " preferences" , " settings" -> android.R .drawable.ic_menu_preferences
241- " help" -> android.R .drawable.ic_menu_help
242- else -> 0
243- }
244- }
245-
246- private fun provideHapticFeedback (context : Context ) {
247- val vibrator = context.getSystemService(Context .VIBRATOR_SERVICE ) as ? Vibrator
248- vibrator?.let {
249- if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .O ) {
250- it.vibrate(VibrationEffect .createOneShot(10 , VibrationEffect .DEFAULT_AMPLITUDE ))
251- } else {
252- @Suppress(" DEPRECATION" )
253- it.vibrate(10 )
254- }
255- }
256- }
257-
258- private fun dismissMenu () {
259- currentPopupMenu?.dismiss()
260- currentPopupMenu = null
261- }
262- }
34+ }
0 commit comments