@clerk/expo-native-components 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/android/build.gradle +152 -0
  4. package/android/src/main/AndroidManifest.xml +4 -0
  5. package/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +241 -0
  6. package/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt +148 -0
  7. package/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +1077 -0
  8. package/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt +156 -0
  9. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt +120 -0
  10. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt +315 -0
  11. package/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +326 -0
  12. package/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt +163 -0
  13. package/app.plugin.d.ts +5 -0
  14. package/app.plugin.js +333 -0
  15. package/dist/AuthView.d.ts +35 -0
  16. package/dist/AuthView.d.ts.map +1 -0
  17. package/dist/AuthView.js +79 -0
  18. package/dist/AuthView.js.map +1 -0
  19. package/dist/AuthView.types.d.ts +62 -0
  20. package/dist/AuthView.types.d.ts.map +1 -0
  21. package/dist/AuthView.types.js +0 -0
  22. package/dist/EmbeddedNavigation.types.d.ts +24 -0
  23. package/dist/EmbeddedNavigation.types.d.ts.map +1 -0
  24. package/dist/EmbeddedNavigation.types.js +0 -0
  25. package/dist/UserButton.d.ts +38 -0
  26. package/dist/UserButton.d.ts.map +1 -0
  27. package/dist/UserButton.js +68 -0
  28. package/dist/UserButton.js.map +1 -0
  29. package/dist/UserProfileCustomPages.d.ts +81 -0
  30. package/dist/UserProfileCustomPages.d.ts.map +1 -0
  31. package/dist/UserProfileCustomPages.js +140 -0
  32. package/dist/UserProfileCustomPages.js.map +1 -0
  33. package/dist/UserProfileView.d.ts +76 -0
  34. package/dist/UserProfileView.d.ts.map +1 -0
  35. package/dist/UserProfileView.js +101 -0
  36. package/dist/UserProfileView.js.map +1 -0
  37. package/dist/_virtual/_rolldown/runtime.js +29 -0
  38. package/dist/index.d.ts +41 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +37 -0
  41. package/dist/specs/NativeClerkAuthView.android.d.ts +15 -0
  42. package/dist/specs/NativeClerkAuthView.android.d.ts.map +1 -0
  43. package/dist/specs/NativeClerkAuthView.android.js +8 -0
  44. package/dist/specs/NativeClerkAuthView.android.js.map +1 -0
  45. package/dist/specs/NativeClerkAuthView.d.ts +15 -0
  46. package/dist/specs/NativeClerkAuthView.d.ts.map +1 -0
  47. package/dist/specs/NativeClerkAuthView.js +9 -0
  48. package/dist/specs/NativeClerkAuthView.js.map +1 -0
  49. package/dist/specs/NativeClerkModule.d.ts +4 -0
  50. package/dist/specs/NativeClerkModule.d.ts.map +1 -0
  51. package/dist/specs/NativeClerkModule.js +8 -0
  52. package/dist/specs/NativeClerkModule.js.map +1 -0
  53. package/dist/specs/NativeClerkModule.types.d.ts +11 -0
  54. package/dist/specs/NativeClerkModule.types.d.ts.map +1 -0
  55. package/dist/specs/NativeClerkModule.types.js +0 -0
  56. package/dist/specs/NativeClerkModule.web.d.ts +4 -0
  57. package/dist/specs/NativeClerkModule.web.d.ts.map +1 -0
  58. package/dist/specs/NativeClerkModule.web.js +7 -0
  59. package/dist/specs/NativeClerkModule.web.js.map +1 -0
  60. package/dist/specs/NativeClerkUserButtonView.android.d.ts +6 -0
  61. package/dist/specs/NativeClerkUserButtonView.android.d.ts.map +1 -0
  62. package/dist/specs/NativeClerkUserButtonView.android.js +8 -0
  63. package/dist/specs/NativeClerkUserButtonView.android.js.map +1 -0
  64. package/dist/specs/NativeClerkUserButtonView.d.ts +6 -0
  65. package/dist/specs/NativeClerkUserButtonView.d.ts.map +1 -0
  66. package/dist/specs/NativeClerkUserButtonView.js +9 -0
  67. package/dist/specs/NativeClerkUserButtonView.js.map +1 -0
  68. package/dist/specs/NativeClerkUserProfileView.android.d.ts +13 -0
  69. package/dist/specs/NativeClerkUserProfileView.android.d.ts.map +1 -0
  70. package/dist/specs/NativeClerkUserProfileView.android.js +8 -0
  71. package/dist/specs/NativeClerkUserProfileView.android.js.map +1 -0
  72. package/dist/specs/NativeClerkUserProfileView.d.ts +13 -0
  73. package/dist/specs/NativeClerkUserProfileView.d.ts.map +1 -0
  74. package/dist/specs/NativeClerkUserProfileView.js +9 -0
  75. package/dist/specs/NativeClerkUserProfileView.js.map +1 -0
  76. package/dist/useAuthViewState.d.ts +12 -0
  77. package/dist/useAuthViewState.d.ts.map +1 -0
  78. package/dist/useAuthViewState.js +76 -0
  79. package/dist/useAuthViewState.js.map +1 -0
  80. package/dist/utils/native-module.d.ts +3 -0
  81. package/dist/utils/native-module.d.ts.map +1 -0
  82. package/dist/utils/native-module.js +14 -0
  83. package/dist/utils/native-module.js.map +1 -0
  84. package/expo-module.config.json +15 -0
  85. package/ios/ClerkAppDelegateSubscriber.swift +16 -0
  86. package/ios/ClerkAuthNativeView.swift +157 -0
  87. package/ios/ClerkExpo.podspec +67 -0
  88. package/ios/ClerkExpoModule.swift +315 -0
  89. package/ios/ClerkNativeBridge.swift +1575 -0
  90. package/ios/ClerkNativeViewHost.swift +204 -0
  91. package/ios/ClerkUserButtonNativeView.swift +34 -0
  92. package/ios/ClerkUserProfileNativeView.swift +92 -0
  93. package/ios/Tests/ClerkAuthNativeViewPaperTests.swift +30 -0
  94. package/ios/Tests/ClerkNativeBridgeTests.swift +185 -0
  95. package/ios/Tests/ClerkNativeViewHostTests.swift +66 -0
  96. package/ios/Tests/ClerkUserProfileCustomPageStateTests.swift +311 -0
  97. package/package.json +79 -3
  98. package/react-native.config.js +8 -0
  99. package/src/specs/NativeClerkAuthView.android.ts +15 -0
  100. package/src/specs/NativeClerkAuthView.ts +19 -0
  101. package/src/specs/NativeClerkModule.ts +6 -0
  102. package/src/specs/NativeClerkModule.types.ts +9 -0
  103. package/src/specs/NativeClerkModule.web.ts +3 -0
  104. package/src/specs/NativeClerkUserButtonView.android.ts +9 -0
  105. package/src/specs/NativeClerkUserButtonView.ts +11 -0
  106. package/src/specs/NativeClerkUserProfileView.android.ts +13 -0
  107. package/src/specs/NativeClerkUserProfileView.ts +17 -0
  108. package/src/specs/__tests__/native-view.web.test.ts +43 -0
@@ -0,0 +1,156 @@
1
+ package expo.modules.clerk
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewGroup
6
+ import androidx.compose.foundation.layout.Box
7
+ import androidx.compose.foundation.layout.fillMaxSize
8
+ import androidx.compose.runtime.Composable
9
+ import androidx.compose.runtime.DisposableEffect
10
+ import androidx.compose.runtime.LaunchedEffect
11
+ import androidx.compose.runtime.getValue
12
+ import androidx.compose.ui.Alignment
13
+ import androidx.compose.ui.Modifier
14
+ import androidx.compose.ui.viewinterop.AndroidView
15
+ import androidx.lifecycle.compose.collectAsStateWithLifecycle
16
+ import com.clerk.api.Clerk
17
+ import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
18
+ import com.clerk.ui.userprofile.custom.UserProfileCustomRow
19
+ import com.clerk.ui.userbutton.UserButton
20
+ import expo.modules.kotlin.AppContext
21
+ import expo.modules.kotlin.modules.Module
22
+ import expo.modules.kotlin.modules.ModuleDefinition
23
+ import expo.modules.kotlin.viewevent.EventDispatcher
24
+
25
+ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
26
+ private var customPagesJson: String = "[]"
27
+ private val customPageViews = mutableListOf<View>()
28
+ private val onCustomPageEvent by EventDispatcher()
29
+ private val customPageState =
30
+ ClerkUserProfileCustomPageState(resetCoveredPathsWhenInactive = true) { type, path ->
31
+ onCustomPageEvent(mapOf("type" to type, "path" to path))
32
+ }
33
+
34
+ init {
35
+ activity?.let { Clerk.attachActivity(it) }
36
+ }
37
+
38
+ @Composable
39
+ override fun Content() {
40
+ val user by Clerk.userFlow.collectAsStateWithLifecycle()
41
+
42
+ LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }
43
+
44
+ Box(
45
+ modifier = Modifier.fillMaxSize(),
46
+ contentAlignment = Alignment.Center,
47
+ ) {
48
+ UserButton(
49
+ clerkTheme = Clerk.customTheme,
50
+ customRows = customRows(),
51
+ customDestination =
52
+ if (customPageViews.isEmpty()) null
53
+ else { routeKey -> CustomPageDestination(routeKey) },
54
+ )
55
+ }
56
+ }
57
+
58
+ fun addCustomPageView(view: View, index: Int) {
59
+ (view.parent as? ViewGroup)?.removeView(view)
60
+ customPageViews.add(index.coerceIn(0, customPageViews.size), view)
61
+ setupView()
62
+ }
63
+
64
+ fun removeCustomPageView(view: View) {
65
+ customPageViews.remove(view)
66
+ (view.parent as? ViewGroup)?.removeView(view)
67
+ setupView()
68
+ }
69
+
70
+ fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)
71
+
72
+ fun customPageCount(): Int = customPageViews.size
73
+
74
+ fun setCustomPages(customPages: String) {
75
+ if (customPagesJson == customPages) return
76
+ val validPaths = runCatching { userProfileCustomPagePaths(customPages) }.getOrDefault(emptySet())
77
+ customPageState.reconcileCustomPagePaths(validPaths)
78
+ customPagesJson = customPages
79
+ }
80
+
81
+ fun navigateCustomPage(action: String, routeKey: String?) {
82
+ customPageState.navigate(action, routeKey)
83
+ }
84
+
85
+ @Composable
86
+ private fun CustomPageDestination(routeKey: String) {
87
+ val customNavigator = LocalUserProfileCustomNavigator.current
88
+ val pages = customPages()
89
+ val view = customPageViews.getOrNull(pages.indexOfFirst { it.routeKey == routeKey })
90
+
91
+ LaunchedEffect(routeKey, customNavigator, view) {
92
+ customPageState.configureNavigation(
93
+ navigateBack = customNavigator::navigateBack,
94
+ popToRoot = customNavigator::popToRoot,
95
+ push = customNavigator::push,
96
+ )
97
+ if (view == null) {
98
+ customNavigator.popToRoot()
99
+ return@LaunchedEffect
100
+ }
101
+ layoutAndroidViewHandler(view)
102
+ customPageState.pageDidPresent(routeKey)
103
+ }
104
+
105
+ if (view == null) return
106
+
107
+ DisposableEffect(routeKey) {
108
+ onDispose { customPageState.pageDidDismiss(routeKey) }
109
+ }
110
+
111
+ AndroidView(
112
+ modifier = Modifier.fillMaxSize(),
113
+ factory = {
114
+ (view.parent as? ViewGroup)?.removeView(view)
115
+ view
116
+ },
117
+ )
118
+ }
119
+
120
+ private fun customRows(): List<UserProfileCustomRow> = customPages().filter { it.showAsRow }.map { it.nativeRow }
121
+
122
+ private fun customPages(): List<ClerkUserProfileCustomPageConfig> =
123
+ runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())
124
+ }
125
+
126
+ class ClerkUserButtonViewModule : Module() {
127
+ override fun definition() = ModuleDefinition {
128
+ Name("ClerkUserButtonView")
129
+
130
+ View(ClerkUserButtonNativeView::class) {
131
+ Events("onCustomPageEvent")
132
+
133
+ GroupView<ClerkUserButtonNativeView> {
134
+ AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
135
+ GetChildCount { parent -> parent.customPageCount() }
136
+ GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
137
+ RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
138
+ RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
139
+ }
140
+
141
+ Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
142
+ view.setCustomPages(customPages)
143
+ }
144
+
145
+ AsyncFunction("navigateCustomPage") {
146
+ view: ClerkUserButtonNativeView,
147
+ action: String,
148
+ routeKey: String? -> view.navigateCustomPage(action, routeKey)
149
+ }
150
+
151
+ OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
152
+ view.setupView()
153
+ }
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,120 @@
1
+ package expo.modules.clerk
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+
6
+ private fun postToMainThread(action: () -> Unit) {
7
+ Handler(Looper.getMainLooper()).post { action() }
8
+ }
9
+
10
+ internal class ClerkUserProfileCustomPageState(
11
+ private val resetCoveredPathsWhenInactive: Boolean = false,
12
+ private val postInactiveReset: ((() -> Unit) -> Unit) = ::postToMainThread,
13
+ private val pageEventHandler: (type: String, path: String) -> Unit,
14
+ ) {
15
+ private val retainedPaths = mutableListOf<String>()
16
+ private var navigateBackAction: (() -> Unit)? = null
17
+ private var popToRootAction: (() -> Unit)? = null
18
+ private var pushAction: ((String) -> Unit)? = null
19
+ private var hasObservedUserId = false
20
+ private var observedUserId: String? = null
21
+ private var navigationTransitionGeneration = 0
22
+
23
+ fun configureNavigation(
24
+ navigateBack: () -> Unit,
25
+ popToRoot: () -> Unit,
26
+ push: (String) -> Unit,
27
+ ) {
28
+ navigateBackAction = navigateBack
29
+ popToRootAction = popToRoot
30
+ pushAction = push
31
+ }
32
+
33
+ fun pageDidPresent(path: String) {
34
+ cancelPendingInactiveReset()
35
+ if (path !in retainedPaths) {
36
+ retainedPaths.add(path)
37
+ }
38
+ pageEventHandler("presented", path)
39
+ }
40
+
41
+ fun pageDidDismiss(path: String) {
42
+ val pathIndex = retainedPaths.lastIndexOf(path)
43
+ if (pathIndex == -1) return
44
+
45
+ // Navigation 3 disposes a destination when another page covers it. Keep that
46
+ // earlier path until it is actually removed from the native back stack.
47
+ if (pathIndex == retainedPaths.lastIndex) {
48
+ retainedPaths.removeAt(pathIndex)
49
+ if (resetCoveredPathsWhenInactive) {
50
+ scheduleInactiveReset()
51
+ }
52
+ }
53
+ pageEventHandler("dismissed", path)
54
+ }
55
+
56
+ fun userDidChange(userId: String?) {
57
+ if (!hasObservedUserId) {
58
+ observedUserId = userId
59
+ hasObservedUserId = true
60
+ return
61
+ }
62
+
63
+ if (observedUserId == userId) return
64
+ observedUserId = userId
65
+ invalidateNavigation()
66
+ }
67
+
68
+ fun reconcileCustomPagePaths(validPaths: Set<String>) {
69
+ if (retainedPaths.all(validPaths::contains)) return
70
+ invalidateNavigation()
71
+ }
72
+
73
+ fun navigate(action: String, routeKey: String?) {
74
+ when (action) {
75
+ "back" -> navigateBackAction?.invoke()
76
+ "popToRoot" -> invalidateNavigation()
77
+ "push" -> {
78
+ val path = routeKey ?: return
79
+ val push = pushAction ?: return
80
+ if (path in retainedPaths) return
81
+ retainedPaths.add(path)
82
+ push(path)
83
+ }
84
+ }
85
+ }
86
+
87
+ internal fun retainedPathsForTesting(): List<String> = retainedPaths.toList()
88
+
89
+ private fun invalidateNavigation() {
90
+ cancelPendingInactiveReset()
91
+ if (retainedPaths.isEmpty()) return
92
+
93
+ val dismissedPaths = retainedPaths.asReversed().distinct()
94
+ retainedPaths.clear()
95
+ popToRootAction?.invoke()
96
+ dismissedPaths.forEach { pageEventHandler("dismissed", it) }
97
+ }
98
+
99
+ private fun scheduleInactiveReset() {
100
+ val generation = ++navigationTransitionGeneration
101
+ postInactiveReset {
102
+ if (generation == navigationTransitionGeneration) {
103
+ resetInactivePaths()
104
+ }
105
+ }
106
+ }
107
+
108
+ private fun cancelPendingInactiveReset() {
109
+ navigationTransitionGeneration += 1
110
+ }
111
+
112
+ private fun resetInactivePaths() {
113
+ navigateBackAction = null
114
+ popToRootAction = null
115
+ pushAction = null
116
+ val dismissedPaths = retainedPaths.asReversed().toList()
117
+ retainedPaths.clear()
118
+ dismissedPaths.forEach { pageEventHandler("dismissed", it) }
119
+ }
120
+ }
@@ -0,0 +1,315 @@
1
+ @file:OptIn(FrameworkIntegrationApi::class)
2
+
3
+ package expo.modules.clerk
4
+
5
+ import android.content.Context
6
+ import android.util.Log
7
+ import android.view.View
8
+ import android.view.ViewGroup
9
+ import androidx.compose.foundation.layout.fillMaxSize
10
+ import androidx.compose.runtime.Composable
11
+ import androidx.compose.runtime.DisposableEffect
12
+ import androidx.compose.runtime.LaunchedEffect
13
+ import androidx.compose.runtime.getValue
14
+ import androidx.compose.ui.Modifier
15
+ import androidx.compose.ui.viewinterop.AndroidView
16
+ import androidx.lifecycle.ViewModelStore
17
+ import androidx.lifecycle.ViewModelStoreOwner
18
+ import androidx.lifecycle.compose.collectAsStateWithLifecycle
19
+ import com.clerk.api.Clerk
20
+ import com.clerk.api.FrameworkIntegrationApi
21
+ import com.clerk.ui.R
22
+ import com.clerk.ui.navigation.ClerkHostBackActionProvider
23
+ import com.clerk.ui.userprofile.UserProfileView
24
+ import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
25
+ import com.clerk.ui.userprofile.custom.UserProfileCustomRow
26
+ import com.clerk.ui.userprofile.custom.UserProfileCustomRowPlacement
27
+ import com.clerk.ui.userprofile.custom.UserProfileRow
28
+ import com.clerk.ui.userprofile.custom.UserProfileRowIcon
29
+ import com.clerk.ui.userprofile.custom.UserProfileSection
30
+ import expo.modules.kotlin.AppContext
31
+ import expo.modules.kotlin.modules.Module
32
+ import expo.modules.kotlin.modules.ModuleDefinition
33
+ import expo.modules.kotlin.viewevent.EventDispatcher
34
+ import org.json.JSONArray
35
+ import org.json.JSONObject
36
+
37
+ private const val TAG = "ClerkUserProfileViewModule"
38
+
39
+ private fun debugLog(tag: String, message: String) {
40
+ if (BuildConfig.DEBUG) {
41
+ Log.d(tag, message)
42
+ }
43
+ }
44
+
45
+ internal data class ClerkUserProfileCustomPageConfig(
46
+ val routeKey: String,
47
+ val title: String,
48
+ val icon: Int,
49
+ val placement: UserProfileCustomRowPlacement,
50
+ val showAsRow: Boolean,
51
+ ) {
52
+ val nativeRow: UserProfileCustomRow
53
+ get() =
54
+ UserProfileCustomRow(
55
+ routeKey = routeKey,
56
+ title = title,
57
+ icon = UserProfileRowIcon.Resource(icon),
58
+ placement = placement,
59
+ )
60
+ }
61
+
62
+ internal fun parseUserProfileCustomPages(
63
+ customPagesJson: String,
64
+ customPageCount: Int,
65
+ ): List<ClerkUserProfileCustomPageConfig> {
66
+ val pages = JSONArray(customPagesJson)
67
+ return buildList {
68
+ for (index in 0 until minOf(pages.length(), customPageCount)) {
69
+ val page = pages.getJSONObject(index)
70
+ add(
71
+ ClerkUserProfileCustomPageConfig(
72
+ routeKey = page.getString("path"),
73
+ title = page.getString("label"),
74
+ icon = userProfileCustomRowIcon(page.optString("icon")),
75
+ placement = userProfileCustomRowPlacement(page.optJSONObject("placement")),
76
+ showAsRow = page.optBoolean("showAsRow", true),
77
+ ),
78
+ )
79
+ }
80
+ }
81
+ }
82
+
83
+ internal fun userProfileCustomPagePaths(customPagesJson: String): Set<String> {
84
+ val pages = JSONArray(customPagesJson)
85
+ return buildSet {
86
+ for (index in 0 until pages.length()) {
87
+ add(pages.getJSONObject(index).getString("path"))
88
+ }
89
+ }
90
+ }
91
+
92
+ private fun userProfileCustomRowIcon(icon: String): Int =
93
+ when (icon) {
94
+ "user" -> R.drawable.ic_user
95
+ "profile" -> R.drawable.ic_profile
96
+ "security" -> R.drawable.ic_security
97
+ "billing" -> R.drawable.ic_credit_card
98
+ "key" -> R.drawable.ic_key
99
+ "lock" -> R.drawable.ic_lock
100
+ "email" -> R.drawable.ic_email
101
+ "phone" -> R.drawable.ic_phone
102
+ "add" -> R.drawable.ic_plus
103
+ "switch" -> R.drawable.ic_switch
104
+ "users" -> R.drawable.ic_users
105
+ "warning" -> R.drawable.ic_warning
106
+ "info" -> R.drawable.ic_information_circle
107
+ "globe" -> R.drawable.ic_globe
108
+ "folder" -> R.drawable.ic_folder
109
+ "book" -> R.drawable.ic_folder
110
+ else -> R.drawable.ic_cog
111
+ }
112
+
113
+ private fun userProfileCustomRowPlacement(placement: JSONObject?): UserProfileCustomRowPlacement {
114
+ val type = placement?.optString("type")
115
+ return when (type) {
116
+ "sectionStart" -> UserProfileCustomRowPlacement.SectionStart(userProfileSection(placement.optString("section")))
117
+ "before" -> UserProfileCustomRowPlacement.Before(userProfileRow(placement.optString("row")))
118
+ "after" -> UserProfileCustomRowPlacement.After(userProfileRow(placement.optString("row")))
119
+ else -> UserProfileCustomRowPlacement.SectionEnd(userProfileSection(placement?.optString("section")))
120
+ }
121
+ }
122
+
123
+ private fun userProfileSection(section: String?): UserProfileSection =
124
+ if (section == "account") UserProfileSection.Account else UserProfileSection.Profile
125
+
126
+ private fun userProfileRow(row: String): UserProfileRow =
127
+ when (row) {
128
+ "security" -> UserProfileRow.Security
129
+ "switchAccount" -> UserProfileRow.SwitchAccount
130
+ "addAccount" -> UserProfileRow.AddAccount
131
+ "signOut" -> UserProfileRow.SignOut
132
+ else -> UserProfileRow.ManageAccount
133
+ }
134
+
135
+ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) :
136
+ ClerkComposeNativeViewHost(context, appContext, retainCompositionOnDetach = true) {
137
+ // clerk-android UserProfileView dismissibility is controlled by its onDismiss callback.
138
+ var isDismissible: Boolean = true
139
+ var hostBackButton: Boolean = false
140
+ private var customPagesJson: String = "[]"
141
+ private val customPageViews = mutableListOf<View>()
142
+ private val onProfileEvent by EventDispatcher()
143
+ private val onCustomPageEvent by EventDispatcher()
144
+ private val onHostBack by EventDispatcher()
145
+ private val customPageState =
146
+ ClerkUserProfileCustomPageState { type, path ->
147
+ onCustomPageEvent(mapOf("type" to type, "path" to path))
148
+ }
149
+
150
+ private val viewModelStoreOwner = object : ViewModelStoreOwner {
151
+ private val store = ViewModelStore()
152
+ override val viewModelStore: ViewModelStore = store
153
+ }
154
+
155
+ override fun localViewModelStoreOwner(): ViewModelStoreOwner = viewModelStoreOwner
156
+
157
+ override fun onHostDestroyed() {
158
+ viewModelStoreOwner.viewModelStore.clear()
159
+ }
160
+
161
+ @Composable
162
+ override fun Content() {
163
+ debugLog(TAG, "setupView - isDismissible: $isDismissible, hostBackButton: $hostBackButton")
164
+
165
+ if (hostBackButton) {
166
+ ClerkHostBackActionProvider(onHostBack = { onHostBack(mapOf()) }) { ProfileView() }
167
+ } else {
168
+ ProfileView()
169
+ }
170
+ }
171
+
172
+ @Composable
173
+ private fun ProfileView() {
174
+ val user by Clerk.userFlow.collectAsStateWithLifecycle()
175
+
176
+ LaunchedEffect(user?.id) { customPageState.userDidChange(user?.id) }
177
+
178
+ UserProfileView(
179
+ clerkTheme = Clerk.customTheme,
180
+ customRows = customRows(),
181
+ customDestination =
182
+ if (customPageViews.isEmpty()) null
183
+ else { routeKey -> CustomPageDestination(routeKey) },
184
+ isDismissible = isDismissible,
185
+ onDismiss = {
186
+ debugLog(TAG, "Profile dismissed")
187
+ sendEvent("dismissed")
188
+ },
189
+ )
190
+ }
191
+
192
+ fun addCustomPageView(view: View, index: Int) {
193
+ (view.parent as? ViewGroup)?.removeView(view)
194
+ customPageViews.add(index.coerceIn(0, customPageViews.size), view)
195
+ setupView()
196
+ }
197
+
198
+ fun removeCustomPageView(view: View) {
199
+ customPageViews.remove(view)
200
+ (view.parent as? ViewGroup)?.removeView(view)
201
+ setupView()
202
+ }
203
+
204
+ fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)
205
+
206
+ fun customPageCount(): Int = customPageViews.size
207
+
208
+ fun setCustomPages(customPages: String) {
209
+ if (customPagesJson == customPages) return
210
+ val validPaths = runCatching { userProfileCustomPagePaths(customPages) }.getOrDefault(emptySet())
211
+ customPageState.reconcileCustomPagePaths(validPaths)
212
+ customPagesJson = customPages
213
+ }
214
+
215
+ fun navigateCustomPage(action: String, routeKey: String?) {
216
+ customPageState.navigate(action, routeKey)
217
+ }
218
+
219
+ @Composable
220
+ private fun CustomPageDestination(routeKey: String) {
221
+ val customNavigator = LocalUserProfileCustomNavigator.current
222
+ val pages = customPages()
223
+ val view = customPageViews.getOrNull(pages.indexOfFirst { it.routeKey == routeKey })
224
+
225
+ LaunchedEffect(routeKey, customNavigator, view) {
226
+ customPageState.configureNavigation(
227
+ navigateBack = customNavigator::navigateBack,
228
+ popToRoot = customNavigator::popToRoot,
229
+ push = customNavigator::push,
230
+ )
231
+ if (view == null) {
232
+ customNavigator.popToRoot()
233
+ return@LaunchedEffect
234
+ }
235
+ layoutAndroidViewHandler(view)
236
+ customPageState.pageDidPresent(routeKey)
237
+ }
238
+
239
+ if (view == null) return
240
+
241
+ DisposableEffect(routeKey) {
242
+ onDispose { customPageState.pageDidDismiss(routeKey) }
243
+ }
244
+
245
+ AndroidView(
246
+ modifier = Modifier.fillMaxSize(),
247
+ factory = {
248
+ (view.parent as? ViewGroup)?.removeView(view)
249
+ view
250
+ },
251
+ )
252
+ }
253
+
254
+ private fun customRows(): List<UserProfileCustomRow> {
255
+ return customPages().filter { it.showAsRow }.map { it.nativeRow }
256
+ }
257
+
258
+ private fun customPages(): List<ClerkUserProfileCustomPageConfig> {
259
+ return runCatching {
260
+ parseUserProfileCustomPages(customPagesJson, customPageViews.size)
261
+ }
262
+ .getOrElse {
263
+ debugLog(TAG, "Ignoring invalid custom rows: ${it.message}")
264
+ emptyList()
265
+ }
266
+ }
267
+
268
+ private fun sendEvent(type: String) {
269
+ onProfileEvent(mapOf("type" to type))
270
+ }
271
+ }
272
+
273
+ class ClerkUserProfileViewModule : Module() {
274
+ override fun definition() = ModuleDefinition {
275
+ Name("ClerkUserProfileView")
276
+
277
+ View(ClerkUserProfileNativeView::class) {
278
+ Events("onProfileEvent", "onCustomPageEvent", "onHostBack")
279
+
280
+ GroupView<ClerkUserProfileNativeView> {
281
+ AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
282
+ GetChildCount { parent -> parent.customPageCount() }
283
+ GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
284
+ RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
285
+ RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
286
+ }
287
+
288
+ Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean ->
289
+ view.isDismissible = isDismissible
290
+ }
291
+
292
+ Prop("hostBackButton") { view: ClerkUserProfileNativeView, hostBackButton: Boolean ->
293
+ view.hostBackButton = hostBackButton
294
+ }
295
+
296
+ Prop("customPages") { view: ClerkUserProfileNativeView, customPages: String ->
297
+ view.setCustomPages(customPages)
298
+ }
299
+
300
+ AsyncFunction("navigateCustomPage") {
301
+ view: ClerkUserProfileNativeView,
302
+ action: String,
303
+ routeKey: String? -> view.navigateCustomPage(action, routeKey)
304
+ }
305
+
306
+ OnViewDidUpdateProps { view: ClerkUserProfileNativeView ->
307
+ view.setupView()
308
+ }
309
+
310
+ OnViewDestroys { view: ClerkUserProfileNativeView ->
311
+ view.destroyHost()
312
+ }
313
+ }
314
+ }
315
+ }