@whop/elements-react-native 0.1.0
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.
- package/README.md +179 -0
- package/android/build.gradle +64 -0
- package/android/consumer-rules.pro +3 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/java/com/whop/elements/WhopAddressSuggestionsModule.kt +102 -0
- package/android/src/main/java/com/whop/elements/WhopAuthSessionModule.kt +126 -0
- package/android/src/main/java/com/whop/elements/WhopElementsPackage.kt +39 -0
- package/android/src/main/java/com/whop/elements/WhopPayButtonManager.kt +74 -0
- package/android/src/main/java/com/whop/elements/WhopPayButtonPressEvent.kt +17 -0
- package/android/src/main/java/com/whop/elements/WhopPayButtonView.kt +178 -0
- package/android/src/main/java/com/whop/elements/WhopPlatformPayModule.kt +212 -0
- package/assets/whop-wordmark-dark.png +0 -0
- package/assets/whop-wordmark-dark@2x.png +0 -0
- package/assets/whop-wordmark-dark@3x.png +0 -0
- package/assets/whop-wordmark-light.png +0 -0
- package/assets/whop-wordmark-light@2x.png +0 -0
- package/assets/whop-wordmark-light@3x.png +0 -0
- package/ios/Package.swift +59 -0
- package/ios/Sources/ElementsReactNative/WhopAddressSuggestionsImpl.swift +189 -0
- package/ios/Sources/ElementsReactNative/WhopAuthSessionImpl.swift +106 -0
- package/ios/Sources/ElementsReactNative/WhopPayButtonImpl.swift +89 -0
- package/ios/Sources/ElementsReactNative/WhopPlatformPayImpl.swift +181 -0
- package/ios/generated/WhopAddressSuggestionsModule.h +11 -0
- package/ios/generated/WhopAddressSuggestionsModule.mm +46 -0
- package/ios/generated/WhopAuthSessionModule.h +11 -0
- package/ios/generated/WhopAuthSessionModule.mm +45 -0
- package/ios/generated/WhopElementsShims.h +35 -0
- package/ios/generated/WhopPayButtonComponentView.h +12 -0
- package/ios/generated/WhopPayButtonComponentView.mm +82 -0
- package/ios/generated/WhopPlatformPayModule.h +11 -0
- package/ios/generated/WhopPlatformPayModule.mm +51 -0
- package/lib/module/api/client.js +139 -0
- package/lib/module/api/client.js.map +1 -0
- package/lib/module/api/client.test.js +137 -0
- package/lib/module/api/client.test.js.map +1 -0
- package/lib/module/api/types.js +2 -0
- package/lib/module/api/types.js.map +1 -0
- package/lib/module/controller/actions.js +148 -0
- package/lib/module/controller/actions.js.map +1 -0
- package/lib/module/controller/actions.test.js +102 -0
- package/lib/module/controller/actions.test.js.map +1 -0
- package/lib/module/controller/store.js +158 -0
- package/lib/module/controller/store.js.map +1 -0
- package/lib/module/core/address/country-data.js +1370 -0
- package/lib/module/core/address/country-data.js.map +1 -0
- package/lib/module/core/address/country-name.js +38 -0
- package/lib/module/core/address/country-name.js.map +1 -0
- package/lib/module/core/address/country-names.generated.js +249 -0
- package/lib/module/core/address/country-names.generated.js.map +1 -0
- package/lib/module/core/address/engine.js +245 -0
- package/lib/module/core/address/engine.js.map +1 -0
- package/lib/module/core/address/engine.test.js +216 -0
- package/lib/module/core/address/engine.test.js.map +1 -0
- package/lib/module/core/address/form-state.js +409 -0
- package/lib/module/core/address/form-state.js.map +1 -0
- package/lib/module/core/address/form-state.test.js +516 -0
- package/lib/module/core/address/form-state.test.js.map +1 -0
- package/lib/module/core/address/places.js +36 -0
- package/lib/module/core/address/places.js.map +1 -0
- package/lib/module/core/address/required-coverage.test.js +57 -0
- package/lib/module/core/address/required-coverage.test.js.map +1 -0
- package/lib/module/core/address/suggest.js +102 -0
- package/lib/module/core/address/suggest.js.map +1 -0
- package/lib/module/core/address/suggest.test.js +156 -0
- package/lib/module/core/address/suggest.test.js.map +1 -0
- package/lib/module/core/address/use-suggestions.js +33 -0
- package/lib/module/core/address/use-suggestions.js.map +1 -0
- package/lib/module/core/payments/address-change.js +91 -0
- package/lib/module/core/payments/address-change.js.map +1 -0
- package/lib/module/core/payments/billing-merge.js +62 -0
- package/lib/module/core/payments/billing-merge.js.map +1 -0
- package/lib/module/core/payments/browser-info.js +42 -0
- package/lib/module/core/payments/browser-info.js.map +1 -0
- package/lib/module/core/payments/bt.js +151 -0
- package/lib/module/core/payments/bt.js.map +1 -0
- package/lib/module/core/payments/bt.test.js +23 -0
- package/lib/module/core/payments/bt.test.js.map +1 -0
- package/lib/module/core/payments/currency.js +37 -0
- package/lib/module/core/payments/currency.js.map +1 -0
- package/lib/module/core/payments/detail.js +159 -0
- package/lib/module/core/payments/detail.js.map +1 -0
- package/lib/module/core/payments/detail.test.js +225 -0
- package/lib/module/core/payments/detail.test.js.map +1 -0
- package/lib/module/core/payments/errors.js +115 -0
- package/lib/module/core/payments/errors.js.map +1 -0
- package/lib/module/core/payments/mint.js +89 -0
- package/lib/module/core/payments/mint.js.map +1 -0
- package/lib/module/core/payments/plan.js +131 -0
- package/lib/module/core/payments/plan.js.map +1 -0
- package/lib/module/core/payments/refusals.js +17 -0
- package/lib/module/core/payments/refusals.js.map +1 -0
- package/lib/module/core/payments/secure-fields.js +87 -0
- package/lib/module/core/payments/secure-fields.js.map +1 -0
- package/lib/module/core/payments/tax-id-catalog.js +616 -0
- package/lib/module/core/payments/tax-id-catalog.js.map +1 -0
- package/lib/module/core/payments/wallet-contact.js +42 -0
- package/lib/module/core/payments/wallet-contact.js.map +1 -0
- package/lib/module/core/payments/wallet-contact.test.js +77 -0
- package/lib/module/core/payments/wallet-contact.test.js.map +1 -0
- package/lib/module/elements/address.js +467 -0
- package/lib/module/elements/address.js.map +1 -0
- package/lib/module/elements/branding.js +114 -0
- package/lib/module/elements/branding.js.map +1 -0
- package/lib/module/elements/card-fields/context.js +254 -0
- package/lib/module/elements/card-fields/context.js.map +1 -0
- package/lib/module/elements/card-fields/fields.js +309 -0
- package/lib/module/elements/card-fields/fields.js.map +1 -0
- package/lib/module/elements/card-fields/index.js +30 -0
- package/lib/module/elements/card-fields/index.js.map +1 -0
- package/lib/module/elements/card.js +107 -0
- package/lib/module/elements/card.js.map +1 -0
- package/lib/module/elements/email.js +84 -0
- package/lib/module/elements/email.js.map +1 -0
- package/lib/module/elements/payment/declared-fields.js +186 -0
- package/lib/module/elements/payment/declared-fields.js.map +1 -0
- package/lib/module/elements/payment/index.js +448 -0
- package/lib/module/elements/payment/index.js.map +1 -0
- package/lib/module/elements/payment/wallet.js +132 -0
- package/lib/module/elements/payment/wallet.js.map +1 -0
- package/lib/module/elements/tax-id.js +100 -0
- package/lib/module/elements/tax-id.js.map +1 -0
- package/lib/module/i18n/catalogs.js +103 -0
- package/lib/module/i18n/catalogs.js.map +1 -0
- package/lib/module/i18n/index.js +45 -0
- package/lib/module/i18n/index.js.map +1 -0
- package/lib/module/index.js +27 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/namespace.js +174 -0
- package/lib/module/namespace.js.map +1 -0
- package/lib/module/native/index.js +128 -0
- package/lib/module/native/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/provider.js +56 -0
- package/lib/module/provider.js.map +1 -0
- package/lib/module/specs/NativeWhopAddressSuggestions.js +6 -0
- package/lib/module/specs/NativeWhopAddressSuggestions.js.map +1 -0
- package/lib/module/specs/NativeWhopAuthSession.js +10 -0
- package/lib/module/specs/NativeWhopAuthSession.js.map +1 -0
- package/lib/module/specs/NativeWhopPlatformPay.js +10 -0
- package/lib/module/specs/NativeWhopPlatformPay.js.map +1 -0
- package/lib/module/specs/WhopPayButtonNativeComponent.ts +22 -0
- package/lib/module/theme/appearance.js +85 -0
- package/lib/module/theme/appearance.js.map +1 -0
- package/lib/module/theme/appearance.test.js +124 -0
- package/lib/module/theme/appearance.test.js.map +1 -0
- package/lib/module/theme/provider.js +25 -0
- package/lib/module/theme/provider.js.map +1 -0
- package/lib/module/theme/resolve.js +69 -0
- package/lib/module/theme/resolve.js.map +1 -0
- package/lib/module/theme/scales.generated.js +419 -0
- package/lib/module/theme/scales.generated.js.map +1 -0
- package/lib/module/ui/element-frame.js +83 -0
- package/lib/module/ui/element-frame.js.map +1 -0
- package/lib/module/ui/payment-method-icon.js +69 -0
- package/lib/module/ui/payment-method-icon.js.map +1 -0
- package/lib/module/ui/primitives.js +266 -0
- package/lib/module/ui/primitives.js.map +1 -0
- package/lib/module/ui/select.js +281 -0
- package/lib/module/ui/select.js.map +1 -0
- package/lib/module/ui/suggestions.js +71 -0
- package/lib/module/ui/suggestions.js.map +1 -0
- package/lib/module/ui/whop-logo.js +42 -0
- package/lib/module/ui/whop-logo.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/api/client.d.ts +87 -0
- package/lib/typescript/src/api/client.d.ts.map +1 -0
- package/lib/typescript/src/api/types.d.ts +175 -0
- package/lib/typescript/src/api/types.d.ts.map +1 -0
- package/lib/typescript/src/controller/actions.d.ts +42 -0
- package/lib/typescript/src/controller/actions.d.ts.map +1 -0
- package/lib/typescript/src/controller/store.d.ts +90 -0
- package/lib/typescript/src/controller/store.d.ts.map +1 -0
- package/lib/typescript/src/core/address/country-data.d.ts +4 -0
- package/lib/typescript/src/core/address/country-data.d.ts.map +1 -0
- package/lib/typescript/src/core/address/country-name.d.ts +2 -0
- package/lib/typescript/src/core/address/country-name.d.ts.map +1 -0
- package/lib/typescript/src/core/address/country-names.generated.d.ts +2 -0
- package/lib/typescript/src/core/address/country-names.generated.d.ts.map +1 -0
- package/lib/typescript/src/core/address/engine.d.ts +97 -0
- package/lib/typescript/src/core/address/engine.d.ts.map +1 -0
- package/lib/typescript/src/core/address/form-state.d.ts +144 -0
- package/lib/typescript/src/core/address/form-state.d.ts.map +1 -0
- package/lib/typescript/src/core/address/places.d.ts +23 -0
- package/lib/typescript/src/core/address/places.d.ts.map +1 -0
- package/lib/typescript/src/core/address/suggest.d.ts +47 -0
- package/lib/typescript/src/core/address/suggest.d.ts.map +1 -0
- package/lib/typescript/src/core/address/use-suggestions.d.ts +3 -0
- package/lib/typescript/src/core/address/use-suggestions.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/address-change.d.ts +52 -0
- package/lib/typescript/src/core/payments/address-change.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/billing-merge.d.ts +39 -0
- package/lib/typescript/src/core/payments/billing-merge.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/browser-info.d.ts +12 -0
- package/lib/typescript/src/core/payments/browser-info.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/bt.d.ts +55 -0
- package/lib/typescript/src/core/payments/bt.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/currency.d.ts +7 -0
- package/lib/typescript/src/core/payments/currency.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/detail.d.ts +53 -0
- package/lib/typescript/src/core/payments/detail.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/errors.d.ts +62 -0
- package/lib/typescript/src/core/payments/errors.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/mint.d.ts +74 -0
- package/lib/typescript/src/core/payments/mint.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/plan.d.ts +69 -0
- package/lib/typescript/src/core/payments/plan.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/refusals.d.ts +12 -0
- package/lib/typescript/src/core/payments/refusals.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/secure-fields.d.ts +33 -0
- package/lib/typescript/src/core/payments/secure-fields.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/tax-id-catalog.d.ts +481 -0
- package/lib/typescript/src/core/payments/tax-id-catalog.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/wallet-contact.d.ts +40 -0
- package/lib/typescript/src/core/payments/wallet-contact.d.ts.map +1 -0
- package/lib/typescript/src/elements/address.d.ts +55 -0
- package/lib/typescript/src/elements/address.d.ts.map +1 -0
- package/lib/typescript/src/elements/branding.d.ts +18 -0
- package/lib/typescript/src/elements/branding.d.ts.map +1 -0
- package/lib/typescript/src/elements/card-fields/context.d.ts +67 -0
- package/lib/typescript/src/elements/card-fields/context.d.ts.map +1 -0
- package/lib/typescript/src/elements/card-fields/fields.d.ts +15 -0
- package/lib/typescript/src/elements/card-fields/fields.d.ts.map +1 -0
- package/lib/typescript/src/elements/card-fields/index.d.ts +21 -0
- package/lib/typescript/src/elements/card-fields/index.d.ts.map +1 -0
- package/lib/typescript/src/elements/card.d.ts +21 -0
- package/lib/typescript/src/elements/card.d.ts.map +1 -0
- package/lib/typescript/src/elements/email.d.ts +31 -0
- package/lib/typescript/src/elements/email.d.ts.map +1 -0
- package/lib/typescript/src/elements/payment/declared-fields.d.ts +43 -0
- package/lib/typescript/src/elements/payment/declared-fields.d.ts.map +1 -0
- package/lib/typescript/src/elements/payment/index.d.ts +37 -0
- package/lib/typescript/src/elements/payment/index.d.ts.map +1 -0
- package/lib/typescript/src/elements/payment/wallet.d.ts +39 -0
- package/lib/typescript/src/elements/payment/wallet.d.ts.map +1 -0
- package/lib/typescript/src/elements/tax-id.d.ts +41 -0
- package/lib/typescript/src/elements/tax-id.d.ts.map +1 -0
- package/lib/typescript/src/i18n/catalogs.d.ts +7 -0
- package/lib/typescript/src/i18n/catalogs.d.ts.map +1 -0
- package/lib/typescript/src/i18n/index.d.ts +17 -0
- package/lib/typescript/src/i18n/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +42 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/namespace.d.ts +36 -0
- package/lib/typescript/src/namespace.d.ts.map +1 -0
- package/lib/typescript/src/native/index.d.ts +62 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -0
- package/lib/typescript/src/provider.d.ts +37 -0
- package/lib/typescript/src/provider.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeWhopAddressSuggestions.d.ts +13 -0
- package/lib/typescript/src/specs/NativeWhopAddressSuggestions.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeWhopAuthSession.d.ts +20 -0
- package/lib/typescript/src/specs/NativeWhopAuthSession.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeWhopPlatformPay.d.ts +24 -0
- package/lib/typescript/src/specs/NativeWhopPlatformPay.d.ts.map +1 -0
- package/lib/typescript/src/specs/WhopPayButtonNativeComponent.d.ts +21 -0
- package/lib/typescript/src/specs/WhopPayButtonNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/theme/appearance.d.ts +71 -0
- package/lib/typescript/src/theme/appearance.d.ts.map +1 -0
- package/lib/typescript/src/theme/provider.d.ts +11 -0
- package/lib/typescript/src/theme/provider.d.ts.map +1 -0
- package/lib/typescript/src/theme/resolve.d.ts +19 -0
- package/lib/typescript/src/theme/resolve.d.ts.map +1 -0
- package/lib/typescript/src/theme/scales.generated.d.ts +18 -0
- package/lib/typescript/src/theme/scales.generated.d.ts.map +1 -0
- package/lib/typescript/src/ui/element-frame.d.ts +32 -0
- package/lib/typescript/src/ui/element-frame.d.ts.map +1 -0
- package/lib/typescript/src/ui/payment-method-icon.d.ts +21 -0
- package/lib/typescript/src/ui/payment-method-icon.d.ts.map +1 -0
- package/lib/typescript/src/ui/primitives.d.ts +94 -0
- package/lib/typescript/src/ui/primitives.d.ts.map +1 -0
- package/lib/typescript/src/ui/select.d.ts +25 -0
- package/lib/typescript/src/ui/select.d.ts.map +1 -0
- package/lib/typescript/src/ui/suggestions.d.ts +15 -0
- package/lib/typescript/src/ui/suggestions.d.ts.map +1 -0
- package/lib/typescript/src/ui/whop-logo.d.ts +4 -0
- package/lib/typescript/src/ui/whop-logo.d.ts.map +1 -0
- package/package.json +121 -0
- package/scripts/check-swift.sh +27 -0
- package/scripts/codegen-shims.mjs +409 -0
- package/scripts/gen-country-names.mjs +58 -0
- package/scripts/gen-theme.mjs +124 -0
- package/scripts/gen-wordmark.mjs +68 -0
- package/scripts/mirror.mjs +112 -0
- package/src/api/client.ts +202 -0
- package/src/api/types.ts +166 -0
- package/src/controller/actions.ts +197 -0
- package/src/controller/store.ts +201 -0
- package/src/core/address/country-data.ts +927 -0
- package/src/core/address/country-name.ts +36 -0
- package/src/core/address/country-names.generated.ts +246 -0
- package/src/core/address/engine.ts +349 -0
- package/src/core/address/form-state.ts +479 -0
- package/src/core/address/places.ts +39 -0
- package/src/core/address/suggest.ts +153 -0
- package/src/core/address/use-suggestions.ts +37 -0
- package/src/core/payments/address-change.ts +121 -0
- package/src/core/payments/billing-merge.ts +91 -0
- package/src/core/payments/browser-info.ts +43 -0
- package/src/core/payments/bt.ts +176 -0
- package/src/core/payments/currency.ts +54 -0
- package/src/core/payments/detail.ts +204 -0
- package/src/core/payments/errors.ts +171 -0
- package/src/core/payments/mint.ts +130 -0
- package/src/core/payments/plan.ts +198 -0
- package/src/core/payments/refusals.ts +24 -0
- package/src/core/payments/secure-fields.ts +85 -0
- package/src/core/payments/tax-id-catalog.ts +320 -0
- package/src/core/payments/wallet-contact.ts +56 -0
- package/src/elements/address.tsx +570 -0
- package/src/elements/branding.tsx +87 -0
- package/src/elements/card-fields/context.tsx +310 -0
- package/src/elements/card-fields/fields.tsx +281 -0
- package/src/elements/card-fields/index.tsx +37 -0
- package/src/elements/card.tsx +101 -0
- package/src/elements/email.tsx +100 -0
- package/src/elements/payment/declared-fields.tsx +238 -0
- package/src/elements/payment/index.tsx +497 -0
- package/src/elements/payment/wallet.tsx +155 -0
- package/src/elements/tax-id.tsx +143 -0
- package/src/i18n/catalogs.ts +100 -0
- package/src/i18n/index.tsx +46 -0
- package/src/index.ts +68 -0
- package/src/namespace.tsx +231 -0
- package/src/native/index.ts +173 -0
- package/src/provider.tsx +76 -0
- package/src/specs/NativeWhopAddressSuggestions.ts +14 -0
- package/src/specs/NativeWhopAuthSession.ts +21 -0
- package/src/specs/NativeWhopPlatformPay.ts +25 -0
- package/src/specs/WhopPayButtonNativeComponent.ts +22 -0
- package/src/theme/appearance.ts +150 -0
- package/src/theme/provider.tsx +28 -0
- package/src/theme/resolve.ts +68 -0
- package/src/theme/scales.generated.ts +427 -0
- package/src/ui/element-frame.tsx +94 -0
- package/src/ui/payment-method-icon.tsx +55 -0
- package/src/ui/primitives.tsx +269 -0
- package/src/ui/select.tsx +255 -0
- package/src/ui/suggestions.tsx +69 -0
- package/src/ui/whop-logo.tsx +37 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
package com.whop.elements
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.res.Configuration
|
|
5
|
+
import android.view.View
|
|
6
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
7
|
+
import android.widget.FrameLayout
|
|
8
|
+
import com.google.android.gms.wallet.button.ButtonConstants
|
|
9
|
+
import com.google.android.gms.wallet.button.ButtonOptions
|
|
10
|
+
import com.google.android.gms.wallet.button.PayButton
|
|
11
|
+
import org.json.JSONArray
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Google's own `PayButton`, wrapped rather than subclassed: `PayButton` is `final`, so this holds
|
|
16
|
+
* one as its single child and keeps the prop state beside it.
|
|
17
|
+
*
|
|
18
|
+
* `PayButton` takes its whole configuration as one immutable options bag, so every prop change has
|
|
19
|
+
* to replay the others. The state lives on the view rather than in `setTag`, which requires a real
|
|
20
|
+
* resource id and throws on anything else.
|
|
21
|
+
*/
|
|
22
|
+
class WhopPayButtonView(context: Context) : FrameLayout(context) {
|
|
23
|
+
|
|
24
|
+
private var button: PayButton? = null
|
|
25
|
+
private var pressListener: View.OnClickListener? = null
|
|
26
|
+
private var rebuildScheduled = false
|
|
27
|
+
|
|
28
|
+
private var buttonType: Int = ButtonConstants.ButtonType.BUY
|
|
29
|
+
private var buttonTheme: Int = ButtonConstants.ButtonTheme.DARK
|
|
30
|
+
private var radius: Int = DEFAULT_RADIUS
|
|
31
|
+
private var themeFollowsSystem: Boolean = true
|
|
32
|
+
private var disabled: Boolean = false
|
|
33
|
+
|
|
34
|
+
init {
|
|
35
|
+
scheduleRebuild()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Google routes `PayButton.setOnClickListener` through its own internal handler, so the press
|
|
39
|
+
* must be bound on the button itself rather than on this wrapper. */
|
|
40
|
+
fun setOnPressListener(listener: View.OnClickListener) {
|
|
41
|
+
pressListener = listener
|
|
42
|
+
button?.setOnClickListener(listener)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fun setVariant(value: String?) {
|
|
46
|
+
// PayButton offers a small verb set; anything else maps onto BUY, which is what Google's
|
|
47
|
+
// guidelines prescribe for a checkout action.
|
|
48
|
+
buttonType = when (value) {
|
|
49
|
+
"pay" -> ButtonConstants.ButtonType.PAY
|
|
50
|
+
"checkout" -> ButtonConstants.ButtonType.CHECKOUT
|
|
51
|
+
"book" -> ButtonConstants.ButtonType.BOOK
|
|
52
|
+
"subscribe" -> ButtonConstants.ButtonType.SUBSCRIBE
|
|
53
|
+
"order" -> ButtonConstants.ButtonType.ORDER
|
|
54
|
+
else -> ButtonConstants.ButtonType.BUY
|
|
55
|
+
}
|
|
56
|
+
scheduleRebuild()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun setButtonAppearance(value: String?) {
|
|
60
|
+
themeFollowsSystem = value == null || value == "auto"
|
|
61
|
+
buttonTheme = when (value) {
|
|
62
|
+
"light" -> ButtonConstants.ButtonTheme.LIGHT
|
|
63
|
+
"dark" -> ButtonConstants.ButtonTheme.DARK
|
|
64
|
+
else -> systemTheme()
|
|
65
|
+
}
|
|
66
|
+
scheduleRebuild()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fun setCornerRadius(value: Double) {
|
|
70
|
+
radius = value.toInt()
|
|
71
|
+
scheduleRebuild()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fun setDisabledState(value: Boolean) {
|
|
75
|
+
disabled = value
|
|
76
|
+
applyEnabled()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override fun onConfigurationChanged(newConfig: Configuration?) {
|
|
80
|
+
super.onConfigurationChanged(newConfig)
|
|
81
|
+
if (!themeFollowsSystem) return
|
|
82
|
+
buttonTheme = systemTheme()
|
|
83
|
+
scheduleRebuild()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun systemTheme(): Int =
|
|
87
|
+
if ((resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
|
|
88
|
+
Configuration.UI_MODE_NIGHT_YES
|
|
89
|
+
) {
|
|
90
|
+
ButtonConstants.ButtonTheme.LIGHT
|
|
91
|
+
} else {
|
|
92
|
+
ButtonConstants.ButtonTheme.DARK
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private fun applyEnabled() {
|
|
96
|
+
val current = button ?: return
|
|
97
|
+
current.isEnabled = !disabled
|
|
98
|
+
current.alpha = if (disabled) 0.5f else 1f
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `PayButton.initialize` is documented as a once-per-instance call, and Fabric writes every prop
|
|
103
|
+
* separately right after construction. Applying each write in place meant initializing the same
|
|
104
|
+
* button four times before it was ever shown. Coalescing to one pass per frame and handing that
|
|
105
|
+
* pass a FRESH button keeps the contract without having to diff the options bag.
|
|
106
|
+
*/
|
|
107
|
+
private fun scheduleRebuild() {
|
|
108
|
+
if (rebuildScheduled) return
|
|
109
|
+
rebuildScheduled = true
|
|
110
|
+
post {
|
|
111
|
+
rebuildScheduled = false
|
|
112
|
+
rebuild()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** PayButton inflates AppCompat widgets, so it is built against the Activity where there is one. */
|
|
117
|
+
private fun hostContext(): Context = (context as? ThemedReactContext)?.currentActivity ?: context
|
|
118
|
+
|
|
119
|
+
private fun rebuild() {
|
|
120
|
+
removeAllViews()
|
|
121
|
+
val next = PayButton(hostContext())
|
|
122
|
+
next.initialize(
|
|
123
|
+
ButtonOptions.newBuilder()
|
|
124
|
+
.setButtonType(buttonType)
|
|
125
|
+
.setButtonTheme(buttonTheme)
|
|
126
|
+
.setCornerRadius(radius)
|
|
127
|
+
.setAllowedPaymentMethods(ALLOWED_METHODS)
|
|
128
|
+
.build(),
|
|
129
|
+
)
|
|
130
|
+
pressListener?.let(next::setOnClickListener)
|
|
131
|
+
button = next
|
|
132
|
+
applyEnabled()
|
|
133
|
+
addView(next, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
134
|
+
layoutButton()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
138
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
139
|
+
layoutButton()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* React Native lays out only the views IT created. A child added here never gets a measure pass,
|
|
144
|
+
* so `PayButton` sat at 0x0 inside a correctly sized wrapper and drew nothing — the button looked
|
|
145
|
+
* like it had failed to load when it had initialized fine. MATCH_PARENT means nothing without
|
|
146
|
+
* this, because nothing ever resolves it.
|
|
147
|
+
*/
|
|
148
|
+
private fun layoutButton() {
|
|
149
|
+
val child = button ?: return
|
|
150
|
+
if (width == 0 || height == 0) return
|
|
151
|
+
child.measure(
|
|
152
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
153
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
|
154
|
+
)
|
|
155
|
+
child.layout(0, 0, width, height)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
companion object {
|
|
159
|
+
private const val DEFAULT_RADIUS = 8
|
|
160
|
+
|
|
161
|
+
private val ALLOWED_METHODS: String =
|
|
162
|
+
JSONArray()
|
|
163
|
+
.put(
|
|
164
|
+
JSONObject()
|
|
165
|
+
.put("type", "CARD")
|
|
166
|
+
.put(
|
|
167
|
+
"parameters",
|
|
168
|
+
JSONObject()
|
|
169
|
+
.put("allowedAuthMethods", JSONArray().put("PAN_ONLY").put("CRYPTOGRAM_3DS"))
|
|
170
|
+
.put(
|
|
171
|
+
"allowedCardNetworks",
|
|
172
|
+
JSONArray().put("VISA").put("MASTERCARD").put("AMEX").put("DISCOVER"),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
.toString()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
package com.whop.elements
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import com.facebook.react.bridge.ActivityEventListener
|
|
6
|
+
import com.facebook.react.bridge.Promise
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
9
|
+
import com.google.android.gms.tasks.Task
|
|
10
|
+
import com.google.android.gms.wallet.AutoResolveHelper
|
|
11
|
+
import com.google.android.gms.wallet.IsReadyToPayRequest
|
|
12
|
+
import com.google.android.gms.wallet.PaymentData
|
|
13
|
+
import com.google.android.gms.wallet.PaymentDataRequest
|
|
14
|
+
import com.google.android.gms.wallet.PaymentsClient
|
|
15
|
+
import com.google.android.gms.wallet.Wallet
|
|
16
|
+
import com.google.android.gms.wallet.WalletConstants
|
|
17
|
+
import org.json.JSONArray
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Google Pay, through the Wallet API.
|
|
22
|
+
*
|
|
23
|
+
* What crosses back is `PaymentData`'s tokenization token — the same shape the web wallet lane posts
|
|
24
|
+
* to Basis Theory as a token intent, so the confirmation-token payload downstream is identical on
|
|
25
|
+
* both platforms.
|
|
26
|
+
*/
|
|
27
|
+
@ReactModule(name = WhopPlatformPayModule.NAME)
|
|
28
|
+
class WhopPlatformPayModule(private val reactContext: ReactApplicationContext) :
|
|
29
|
+
NativeWhopPlatformPaySpec(reactContext), ActivityEventListener {
|
|
30
|
+
|
|
31
|
+
private var pending: Promise? = null
|
|
32
|
+
|
|
33
|
+
init {
|
|
34
|
+
reactContext.addActivityEventListener(this)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override fun getName(): String = NAME
|
|
38
|
+
|
|
39
|
+
override fun capability(): String = "google"
|
|
40
|
+
|
|
41
|
+
override fun canPay(configJson: String?, promise: Promise) {
|
|
42
|
+
val config = runCatching { JSONObject(configJson ?: "{}") }.getOrNull()
|
|
43
|
+
if (config == null) {
|
|
44
|
+
promise.reject("INVALID_CONFIG", "whop elements: could not decode the wallet config")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
val request = IsReadyToPayRequest.fromJson(
|
|
48
|
+
JSONObject()
|
|
49
|
+
.put("apiVersion", 2)
|
|
50
|
+
.put("apiVersionMinor", 0)
|
|
51
|
+
.put("allowedPaymentMethods", JSONArray().put(baseCardMethod(config)))
|
|
52
|
+
.toString(),
|
|
53
|
+
)
|
|
54
|
+
client(config.optString("environment", "TEST"))
|
|
55
|
+
.isReadyToPay(request)
|
|
56
|
+
.addOnCompleteListener { task: Task<Boolean> ->
|
|
57
|
+
promise.resolve(runCatching { task.getResult(ApiExceptionClass) }.getOrDefault(false))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override fun present(requestJson: String?, promise: Promise) {
|
|
62
|
+
val request = runCatching { JSONObject(requestJson ?: "{}") }.getOrNull()
|
|
63
|
+
val activity: Activity? = reactApplicationContext.currentActivity
|
|
64
|
+
if (request == null) {
|
|
65
|
+
promise.reject("INVALID_REQUEST", "whop elements: could not decode the wallet request")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (activity == null) {
|
|
69
|
+
promise.reject("NO_ACTIVITY", "whop elements: no activity is attached")
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pending?.resolve(null)
|
|
74
|
+
pending = promise
|
|
75
|
+
|
|
76
|
+
val payload = JSONObject()
|
|
77
|
+
.put("apiVersion", 2)
|
|
78
|
+
.put("apiVersionMinor", 0)
|
|
79
|
+
.put("allowedPaymentMethods", JSONArray().put(tokenizedCardMethod(request)))
|
|
80
|
+
.put(
|
|
81
|
+
"transactionInfo",
|
|
82
|
+
JSONObject()
|
|
83
|
+
.put("totalPrice", request.optString("totalPrice"))
|
|
84
|
+
.put("totalPriceStatus", "FINAL")
|
|
85
|
+
.put("countryCode", request.optString("countryCode"))
|
|
86
|
+
.put("currencyCode", request.optString("currencyCode").uppercase()),
|
|
87
|
+
)
|
|
88
|
+
.put("merchantInfo", JSONObject().put("merchantName", request.optString("merchantName", "Whop")))
|
|
89
|
+
.put("emailRequired", request.optBoolean("requiresEmail", false))
|
|
90
|
+
|
|
91
|
+
AutoResolveHelper.resolveTask(
|
|
92
|
+
client(request.optString("environment", "TEST"))
|
|
93
|
+
.loadPaymentData(PaymentDataRequest.fromJson(payload.toString())),
|
|
94
|
+
activity,
|
|
95
|
+
LOAD_PAYMENT_DATA,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Google Pay's sheet dismisses itself on result, so the confirm verdict has nothing to settle. */
|
|
100
|
+
override fun complete(success: Boolean) = Unit
|
|
101
|
+
|
|
102
|
+
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
|
|
103
|
+
if (requestCode != LOAD_PAYMENT_DATA) return
|
|
104
|
+
val promise = pending ?: return
|
|
105
|
+
pending = null
|
|
106
|
+
if (resultCode != Activity.RESULT_OK || data == null) {
|
|
107
|
+
promise.resolve(null)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
val payload = PaymentData.getFromIntent(data)?.toJson()?.let(::JSONObject)
|
|
111
|
+
val token = payload
|
|
112
|
+
?.optJSONObject("paymentMethodData")
|
|
113
|
+
?.optJSONObject("tokenizationData")
|
|
114
|
+
?.optString("token")
|
|
115
|
+
?.takeIf { it.isNotEmpty() }
|
|
116
|
+
if (token == null || payload == null) {
|
|
117
|
+
promise.resolve(null)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
promise.resolve(envelope(token, payload).toString())
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The token PLUS whatever contact fields the sheet collected, which are otherwise discarded. */
|
|
124
|
+
private fun envelope(token: String, payload: JSONObject): JSONObject {
|
|
125
|
+
val out = JSONObject().put("paymentData", token)
|
|
126
|
+
payload.optString("email").takeIf { it.isNotEmpty() }?.let { out.put("email", it) }
|
|
127
|
+
val billing = payload
|
|
128
|
+
.optJSONObject("paymentMethodData")
|
|
129
|
+
?.optJSONObject("info")
|
|
130
|
+
?.optJSONObject("billingAddress")
|
|
131
|
+
?: return out
|
|
132
|
+
|
|
133
|
+
billing.optString("name").takeIf { it.isNotEmpty() }?.let { out.put("name", it) }
|
|
134
|
+
val address = JSONObject()
|
|
135
|
+
// Google's FULL billing address: address1..address3, locality, administrativeArea, postalCode.
|
|
136
|
+
billing.optString("address1").takeIf { it.isNotEmpty() }?.let { address.put("line1", it) }
|
|
137
|
+
listOf(billing.optString("address2"), billing.optString("address3"))
|
|
138
|
+
.filter { it.isNotEmpty() }
|
|
139
|
+
.takeIf { it.isNotEmpty() }
|
|
140
|
+
?.let { address.put("line2", it.joinToString(" ")) }
|
|
141
|
+
billing.optString("locality").takeIf { it.isNotEmpty() }?.let { address.put("city", it) }
|
|
142
|
+
billing.optString("administrativeArea").takeIf { it.isNotEmpty() }?.let { address.put("state", it) }
|
|
143
|
+
billing.optString("postalCode").takeIf { it.isNotEmpty() }?.let { address.put("postal_code", it) }
|
|
144
|
+
billing.optString("countryCode").takeIf { it.isNotEmpty() }?.let { address.put("country", it.uppercase()) }
|
|
145
|
+
if (address.length() > 0) out.put("address", address)
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override fun onNewIntent(intent: Intent) = Unit
|
|
150
|
+
|
|
151
|
+
private fun client(environment: String): PaymentsClient =
|
|
152
|
+
Wallet.getPaymentsClient(
|
|
153
|
+
reactContext,
|
|
154
|
+
Wallet.WalletOptions.Builder()
|
|
155
|
+
.setEnvironment(
|
|
156
|
+
if (environment.equals("PRODUCTION", ignoreCase = true)) {
|
|
157
|
+
WalletConstants.ENVIRONMENT_PRODUCTION
|
|
158
|
+
} else {
|
|
159
|
+
WalletConstants.ENVIRONMENT_TEST
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
.build(),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
private fun baseCardMethod(config: JSONObject): JSONObject =
|
|
166
|
+
JSONObject()
|
|
167
|
+
.put("type", "CARD")
|
|
168
|
+
.put(
|
|
169
|
+
"parameters",
|
|
170
|
+
JSONObject()
|
|
171
|
+
.put("allowedAuthMethods", config.optJSONArray("allowedAuthMethods") ?: defaultAuthMethods())
|
|
172
|
+
.put("allowedCardNetworks", config.optJSONArray("allowedCardNetworks") ?: defaultNetworks()),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
private fun tokenizedCardMethod(request: JSONObject): JSONObject {
|
|
176
|
+
val method = baseCardMethod(request).put(
|
|
177
|
+
"tokenizationSpecification",
|
|
178
|
+
JSONObject()
|
|
179
|
+
.put("type", "PAYMENT_GATEWAY")
|
|
180
|
+
.put(
|
|
181
|
+
"parameters",
|
|
182
|
+
JSONObject()
|
|
183
|
+
.put("gateway", request.optString("gateway"))
|
|
184
|
+
.put("gatewayMerchantId", request.optString("gatewayMerchantId")),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
// Mirrors iOS's requiredBillingContactFields: ask only when no address element is mounted.
|
|
188
|
+
// FULL rather than MIN, because the mint wants line1/city/state, not just a postal code.
|
|
189
|
+
if (request.optBoolean("requiresBillingAddress", false)) {
|
|
190
|
+
method.getJSONObject("parameters")
|
|
191
|
+
.put("billingAddressRequired", true)
|
|
192
|
+
.put("billingAddressParameters", JSONObject().put("format", "FULL"))
|
|
193
|
+
}
|
|
194
|
+
return method
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun defaultAuthMethods() = JSONArray().put("PAN_ONLY").put("CRYPTOGRAM_3DS")
|
|
198
|
+
|
|
199
|
+
private fun defaultNetworks() =
|
|
200
|
+
JSONArray().put("VISA").put("MASTERCARD").put("AMEX").put("DISCOVER")
|
|
201
|
+
|
|
202
|
+
companion object {
|
|
203
|
+
const val NAME = "WhopPlatformPay"
|
|
204
|
+
/**
|
|
205
|
+
* Low 16 bits only. FragmentActivity's startActivityForResult rejects a request code with any
|
|
206
|
+
* high bit set, so the "WHOP" spelling (0x57484F50) made AutoResolveHelper throw at the moment
|
|
207
|
+
* the sheet should have opened — the tile rendered and could never complete a payment.
|
|
208
|
+
*/
|
|
209
|
+
private const val LOAD_PAYMENT_DATA = 0x4F50
|
|
210
|
+
private val ApiExceptionClass = com.google.android.gms.common.api.ApiException::class.java
|
|
211
|
+
}
|
|
212
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// swift-tools-version: 6.0
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
/// The two relative paths below are fixed constants, not app-specific: React Native's autolinker
|
|
5
|
+
/// exposes every self-managed dependency through a symlink at
|
|
6
|
+
/// <appRoot>/ios/build/generated/autolinking/libs/<SwiftName>/, a known depth from the app root.
|
|
7
|
+
let reactHeaders: [Target.Dependency] = [
|
|
8
|
+
.product(name: "ReactHeaders", package: "ReactNative"),
|
|
9
|
+
.product(name: "ReactNativeHeaders", package: "ReactNative"),
|
|
10
|
+
.product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"),
|
|
11
|
+
.product(name: "ReactAppHeaders", package: "React-GeneratedCode"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
let package = Package(
|
|
15
|
+
name: "ElementsReactNative",
|
|
16
|
+
// .v15, NOT 15.1, even though React Native declares 15.1 and generates an app target at 15.1:
|
|
17
|
+
// its autolink aggregate target is built at 15.0, and a package product demanding more than
|
|
18
|
+
// that fails to link with "requires minimum platform version 15.1 ... but this target supports
|
|
19
|
+
// 15.0". The floor a CONSUMER meets is still 15.1, set by the app target React Native writes.
|
|
20
|
+
platforms: [.iOS(.v15)],
|
|
21
|
+
products: [
|
|
22
|
+
.library(name: "ElementsReactNative", targets: ["ElementsReactNative", "ElementsReactNativeObjC"]),
|
|
23
|
+
],
|
|
24
|
+
dependencies: [
|
|
25
|
+
.package(name: "ReactNative", path: "../../../../xcframeworks"),
|
|
26
|
+
.package(name: "React-GeneratedCode", path: "../../../ios"),
|
|
27
|
+
],
|
|
28
|
+
targets: [
|
|
29
|
+
// Generated by scripts/codegen-shims.mjs. SwiftPM cannot mix languages in one target, and
|
|
30
|
+
// only the objc -> swift direction is supported, so this target must come first.
|
|
31
|
+
.target(
|
|
32
|
+
name: "ElementsReactNativeObjC",
|
|
33
|
+
dependencies: reactHeaders,
|
|
34
|
+
path: "generated",
|
|
35
|
+
publicHeadersPath: ".",
|
|
36
|
+
cSettings: [.headerSearchPath(".")],
|
|
37
|
+
cxxSettings: [
|
|
38
|
+
.headerSearchPath("."),
|
|
39
|
+
.define("DEBUG", .when(configuration: .debug)),
|
|
40
|
+
// React.xcframework has a configuration-gated C++ ABI; without this, Release links
|
|
41
|
+
// against Debug vtables and fails with undefined symbols.
|
|
42
|
+
.define("NDEBUG", .when(configuration: .release)),
|
|
43
|
+
]
|
|
44
|
+
),
|
|
45
|
+
.target(
|
|
46
|
+
name: "ElementsReactNative",
|
|
47
|
+
dependencies: reactHeaders + ["ElementsReactNativeObjC"],
|
|
48
|
+
path: "Sources/ElementsReactNative",
|
|
49
|
+
linkerSettings: [
|
|
50
|
+
.linkedFramework("UIKit"),
|
|
51
|
+
.linkedFramework("Foundation"),
|
|
52
|
+
.linkedFramework("PassKit"),
|
|
53
|
+
.linkedFramework("AuthenticationServices"),
|
|
54
|
+
.linkedFramework("MapKit"),
|
|
55
|
+
]
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
cxxLanguageStandard: .cxx20
|
|
59
|
+
)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import MapKit
|
|
3
|
+
|
|
4
|
+
/// Street suggestions from MapKit, on-device and with no API key or third-party SDK, where the web
|
|
5
|
+
/// AddressElement asks Google Places.
|
|
6
|
+
///
|
|
7
|
+
/// This is the Turbo Module port of `MapKitAddressSuggestionProvider` in WhopUI, and it keeps that
|
|
8
|
+
/// implementation's hard-won details rather than reinventing them:
|
|
9
|
+
///
|
|
10
|
+
/// - Listing and resolving are separate, because that is what MapKit offers.
|
|
11
|
+
/// `MKLocalSearchCompleter` answers a fragment with many candidates; `MKLocalSearch` resolves
|
|
12
|
+
/// ONE place and carries the address components. Using the search for the list gives a
|
|
13
|
+
/// one-row list of frequently the wrong place.
|
|
14
|
+
/// - MapKit does not call the delegate again for an UNCHANGED fragment, so a repeat query must
|
|
15
|
+
/// return what the completer already holds or the caller waits forever.
|
|
16
|
+
/// - Rows are keyed by content, not by position, so a refresh reuses a row rather than tearing it
|
|
17
|
+
/// out from under a finger — and a resolve that lands after a newer query still resolves the
|
|
18
|
+
/// row that was tapped.
|
|
19
|
+
///
|
|
20
|
+
/// `@unchecked Sendable` with a real invariant: every mutable field is touched only on the main
|
|
21
|
+
/// queue, where MapKit's delegate callbacks also arrive.
|
|
22
|
+
@objc(WhopAddressSuggestionsImpl)
|
|
23
|
+
public final class WhopAddressSuggestionsImpl: NSObject, @unchecked Sendable {
|
|
24
|
+
private let completer = MKLocalSearchCompleter()
|
|
25
|
+
private var pending: (@Sendable (Any?) -> Void)?
|
|
26
|
+
private var completionsByID: [String: MKLocalSearchCompletion] = [:]
|
|
27
|
+
private var completionOrder: [String] = []
|
|
28
|
+
private var search: MKLocalSearch?
|
|
29
|
+
|
|
30
|
+
override public init() {
|
|
31
|
+
super.init()
|
|
32
|
+
DispatchQueue.main.async { [weak self] in
|
|
33
|
+
guard let self else { return }
|
|
34
|
+
self.completer.resultTypes = .address
|
|
35
|
+
self.completer.delegate = self
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@objc public func isAvailable() -> Bool {
|
|
40
|
+
true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@objc public func suggest(
|
|
44
|
+
_ query: String,
|
|
45
|
+
resolve: @escaping @Sendable (Any?) -> Void,
|
|
46
|
+
reject _: @escaping @Sendable (String, String) -> Void
|
|
47
|
+
) {
|
|
48
|
+
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
49
|
+
guard trimmed.count >= Self.minimumQueryLength else {
|
|
50
|
+
resolve(Self.empty)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
DispatchQueue.main.async { [weak self] in
|
|
54
|
+
guard let self else {
|
|
55
|
+
resolve(Self.empty)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
// An unchanged fragment never fires the delegate again, so answer from what is already
|
|
59
|
+
// there. Without this a repeated query hangs until the caller gives up.
|
|
60
|
+
if self.completer.queryFragment == trimmed {
|
|
61
|
+
resolve(self.rows(from: self.completer.results))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
// Settle the superseded query rather than leaving its promise dangling.
|
|
65
|
+
self.pending?(Self.empty)
|
|
66
|
+
self.pending = resolve
|
|
67
|
+
self.completer.queryFragment = trimmed
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@objc public func details(
|
|
72
|
+
_ suggestionId: String,
|
|
73
|
+
resolve: @escaping @Sendable (Any?) -> Void,
|
|
74
|
+
reject _: @escaping @Sendable (String, String) -> Void
|
|
75
|
+
) {
|
|
76
|
+
DispatchQueue.main.async { [weak self] in
|
|
77
|
+
guard let self, let completion = self.completionsByID[suggestionId] else {
|
|
78
|
+
resolve(Self.emptyObject)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
self.search?.cancel()
|
|
82
|
+
let search = MKLocalSearch(request: MKLocalSearch.Request(completion: completion))
|
|
83
|
+
self.search = search
|
|
84
|
+
search.start { response, _ in
|
|
85
|
+
guard let item = response?.mapItems.first else {
|
|
86
|
+
resolve(Self.emptyObject)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
resolve(Self.components(of: item, subtitle: completion.subtitle))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private func rows(from completions: [MKLocalSearchCompletion]) -> String {
|
|
95
|
+
var rows: [[String: String]] = []
|
|
96
|
+
for completion in completions.prefix(Self.resultLimit) {
|
|
97
|
+
let id = "\(completion.title)\n\(completion.subtitle)"
|
|
98
|
+
if completionsByID.updateValue(completion, forKey: id) == nil {
|
|
99
|
+
completionOrder.append(id)
|
|
100
|
+
} else if let seen = completionOrder.firstIndex(of: id) {
|
|
101
|
+
// Insertion order alone evicts from the front, and a REPEAT hit left its id
|
|
102
|
+
// there — so a row still on screen could be trimmed, and its later tap would
|
|
103
|
+
// resolve to nothing.
|
|
104
|
+
completionOrder.remove(at: seen)
|
|
105
|
+
completionOrder.append(id)
|
|
106
|
+
}
|
|
107
|
+
let label = completion.subtitle.isEmpty
|
|
108
|
+
? completion.title
|
|
109
|
+
: "\(completion.title), \(completion.subtitle)"
|
|
110
|
+
rows.append(["id": id, "label": label])
|
|
111
|
+
}
|
|
112
|
+
trimCompletions()
|
|
113
|
+
guard let data = try? JSONSerialization.data(withJSONObject: rows),
|
|
114
|
+
let json = String(data: data, encoding: .utf8)
|
|
115
|
+
else { return Self.empty }
|
|
116
|
+
return json
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Bounded, so a long typing session does not grow the map without limit — but generous enough
|
|
120
|
+
/// that a resolve arriving after several newer queries still finds its row.
|
|
121
|
+
private func trimCompletions() {
|
|
122
|
+
let overflow = max(0, completionOrder.count - Self.cacheLimit)
|
|
123
|
+
guard overflow > 0 else { return }
|
|
124
|
+
for id in completionOrder.prefix(overflow) {
|
|
125
|
+
completionsByID[id] = nil
|
|
126
|
+
}
|
|
127
|
+
completionOrder.removeFirst(overflow)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// MKMapItem.placemark is the only iOS 18 API that hands back address COMPONENTS; the iOS 26
|
|
131
|
+
/// replacements (MKAddress, MKAddressRepresentations) return formatted strings, which a form
|
|
132
|
+
/// that fills city / subdivision / postal-code fields cannot use.
|
|
133
|
+
private static func components(of item: MKMapItem, subtitle: String) -> String {
|
|
134
|
+
let placemark = item.placemark
|
|
135
|
+
var row: [String: String] = [:]
|
|
136
|
+
row["streetNumber"] = placemark.subThoroughfare ?? ""
|
|
137
|
+
row["street"] = placemark.thoroughfare ?? ""
|
|
138
|
+
// A row with no street components at all still names something usable.
|
|
139
|
+
if (placemark.subThoroughfare ?? "").isEmpty, (placemark.thoroughfare ?? "").isEmpty {
|
|
140
|
+
row["line1"] = item.name ?? ""
|
|
141
|
+
}
|
|
142
|
+
row["city"] = city(from: placemark, subtitle: subtitle) ?? ""
|
|
143
|
+
row["state"] = placemark.administrativeArea ?? ""
|
|
144
|
+
row["postalCode"] = placemark.postalCode ?? ""
|
|
145
|
+
row["country"] = placemark.isoCountryCode?.uppercased() ?? ""
|
|
146
|
+
guard let data = try? JSONSerialization.data(withJSONObject: row),
|
|
147
|
+
let json = String(data: data, encoding: .utf8)
|
|
148
|
+
else { return emptyObject }
|
|
149
|
+
return json
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// NYC boroughs all resolve to locality "New York"; a row that never names it names the real city.
|
|
153
|
+
private static func city(from placemark: MKPlacemark, subtitle: String) -> String? {
|
|
154
|
+
let components = subtitle
|
|
155
|
+
.components(separatedBy: ",")
|
|
156
|
+
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
157
|
+
let names = { (name: String) in
|
|
158
|
+
components.contains { $0 == name || $0.hasPrefix("\(name) ") }
|
|
159
|
+
}
|
|
160
|
+
if let locality = placemark.locality, names(locality) { return locality }
|
|
161
|
+
guard let state = placemark.administrativeArea,
|
|
162
|
+
let stateIndex = components.firstIndex(where: { $0 == state || $0.hasPrefix("\(state) ") }),
|
|
163
|
+
stateIndex > 0,
|
|
164
|
+
!components[stateIndex - 1].isEmpty
|
|
165
|
+
else { return placemark.locality }
|
|
166
|
+
return components[stateIndex - 1]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private static let minimumQueryLength = 3
|
|
170
|
+
private static let resultLimit = 5
|
|
171
|
+
private static let cacheLimit = 100
|
|
172
|
+
private static let empty = "[]"
|
|
173
|
+
private static let emptyObject = "{}"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
extension WhopAddressSuggestionsImpl: MKLocalSearchCompleterDelegate {
|
|
177
|
+
public func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
|
|
178
|
+
let settle = pending
|
|
179
|
+
pending = nil
|
|
180
|
+
settle?(rows(from: completer.results))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
public func completer(_: MKLocalSearchCompleter, didFailWithError _: Error) {
|
|
184
|
+
// A failed lookup leaves the buyer typing the address by hand, which is where they started.
|
|
185
|
+
let settle = pending
|
|
186
|
+
pending = nil
|
|
187
|
+
settle?(Self.empty)
|
|
188
|
+
}
|
|
189
|
+
}
|