@loyalytics/swan-react-native-sdk 2.1.3-beta.0 → 2.1.3-beta.2

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 (91) hide show
  1. package/android/build.gradle +66 -0
  2. package/android/src/main/AndroidManifest.xml +10 -0
  3. package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationModule.kt +43 -0
  4. package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationPackage.kt +16 -0
  5. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationActionReceiver.kt +49 -0
  6. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationTemplate.kt +20 -0
  7. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanTemplateRegistry.kt +47 -0
  8. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselAutoRemoteViews.kt +103 -0
  9. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselFilmstripRemoteViews.kt +132 -0
  10. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselRemoteViews.kt +129 -0
  11. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplate.kt +412 -0
  12. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationBitmapCache.kt +70 -0
  13. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationImageLoader.kt +97 -0
  14. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationStateManager.kt +85 -0
  15. package/android/src/main/res/anim/swan_fade_in.xml +6 -0
  16. package/android/src/main/res/anim/swan_fade_out.xml +6 -0
  17. package/android/src/main/res/anim/swan_slide_in_right.xml +8 -0
  18. package/android/src/main/res/anim/swan_slide_out_left.xml +8 -0
  19. package/android/src/main/res/drawable/swan_ic_chevron_left.xml +11 -0
  20. package/android/src/main/res/drawable/swan_ic_chevron_right.xml +11 -0
  21. package/android/src/main/res/layout/swan_carousel_auto_expanded.xml +51 -0
  22. package/android/src/main/res/layout/swan_carousel_collapsed.xml +31 -0
  23. package/android/src/main/res/layout/swan_carousel_expanded.xml +96 -0
  24. package/android/src/main/res/layout/swan_carousel_filmstrip_expanded.xml +115 -0
  25. package/android/src/main/res/layout/swan_carousel_flipper_item.xml +7 -0
  26. package/android/src/test/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplateTest.kt +125 -0
  27. package/docs/SDK_INDUSTRY_REVIEW_REPORT.md +347 -0
  28. package/docs/Swan_Push_Notifications.postman_collection.json +330 -0
  29. package/docs/deep-link-attribution.md +281 -0
  30. package/ios/SwanNotificationContentExtension/Info.plist +40 -0
  31. package/ios/SwanNotificationContentExtension/MainInterface.storyboard +19 -0
  32. package/ios/SwanNotificationContentExtension/NotificationViewController.swift +190 -0
  33. package/ios/SwanNotificationContentExtension/SwanNotificationContentExtension.entitlements +10 -0
  34. package/ios/SwanNotificationContentExtension/common/ImageDownloader.swift +32 -0
  35. package/ios/SwanNotificationContentExtension/templates/CarouselView.swift +336 -0
  36. package/lib/commonjs/constants/ApiUrls.js.map +1 -1
  37. package/lib/commonjs/index.js +150 -35
  38. package/lib/commonjs/index.js.map +1 -1
  39. package/lib/commonjs/providers/NullPushProvider.js.map +1 -1
  40. package/lib/commonjs/services/DeviceRegistrationService.js.map +1 -1
  41. package/lib/commonjs/state/AuthStateMachine.js.map +1 -1
  42. package/lib/commonjs/state/DeviceStateMachine.js.map +1 -1
  43. package/lib/commonjs/state/PushStateMachine.js.map +1 -1
  44. package/lib/commonjs/utils/FirebaseNotificationManager.js.map +1 -1
  45. package/lib/commonjs/utils/Logger.js.map +1 -1
  46. package/lib/commonjs/utils/SharedCredentialsManager.js +28 -0
  47. package/lib/commonjs/utils/SharedCredentialsManager.js.map +1 -1
  48. package/lib/commonjs/version.js +1 -1
  49. package/lib/module/index.js +150 -35
  50. package/lib/module/index.js.map +1 -1
  51. package/lib/module/providers/NullPushProvider.js.map +1 -1
  52. package/lib/module/services/DeviceRegistrationService.js.map +1 -1
  53. package/lib/module/state/AuthStateMachine.js.map +1 -1
  54. package/lib/module/state/DeviceStateMachine.js.map +1 -1
  55. package/lib/module/state/PushStateMachine.js.map +1 -1
  56. package/lib/module/utils/FirebaseNotificationManager.js.map +1 -1
  57. package/lib/module/utils/Logger.js.map +1 -1
  58. package/lib/module/utils/SharedCredentialsManager.js +28 -0
  59. package/lib/module/utils/SharedCredentialsManager.js.map +1 -1
  60. package/lib/module/version.js +1 -1
  61. package/lib/typescript/commonjs/src/constants/ApiUrls.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/src/index.d.ts +24 -7
  63. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/src/services/DeviceRegistrationService.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/src/state/AuthStateMachine.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/src/state/DeviceStateMachine.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/src/state/PushStateMachine.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/src/utils/Logger.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts +13 -0
  72. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/version.d.ts +1 -1
  74. package/lib/typescript/module/src/constants/ApiUrls.d.ts.map +1 -1
  75. package/lib/typescript/module/src/index.d.ts +24 -7
  76. package/lib/typescript/module/src/index.d.ts.map +1 -1
  77. package/lib/typescript/module/src/providers/NullPushProvider.d.ts.map +1 -1
  78. package/lib/typescript/module/src/services/DeviceRegistrationService.d.ts.map +1 -1
  79. package/lib/typescript/module/src/state/AuthStateMachine.d.ts.map +1 -1
  80. package/lib/typescript/module/src/state/DeviceStateMachine.d.ts.map +1 -1
  81. package/lib/typescript/module/src/state/PushStateMachine.d.ts.map +1 -1
  82. package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  83. package/lib/typescript/module/src/utils/Logger.d.ts.map +1 -1
  84. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts +13 -0
  85. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  86. package/lib/typescript/module/src/version.d.ts +1 -1
  87. package/package.json +7 -10
  88. package/react-native.config.json +12 -0
  89. package/scripts/setup-ios-extension.js +100 -20
  90. package/scripts/test-carousel-push.js +266 -0
  91. package/swan-react-native-sdk.podspec +18 -0
@@ -0,0 +1,66 @@
1
+ buildscript {
2
+ ext.safeExtGet = {prop, fallback ->
3
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
4
+ }
5
+ repositories {
6
+ google()
7
+ mavenCentral()
8
+ }
9
+ dependencies {
10
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet('kotlinVersion', '1.9.22')}")
11
+ }
12
+ }
13
+
14
+ apply plugin: 'com.android.library'
15
+ apply plugin: 'kotlin-android'
16
+
17
+ android {
18
+ namespace 'com.loyalytics.swan'
19
+ compileSdkVersion safeExtGet('compileSdkVersion', 34)
20
+
21
+ defaultConfig {
22
+ minSdkVersion safeExtGet('minSdkVersion', 21)
23
+ targetSdkVersion safeExtGet('targetSdkVersion', 34)
24
+ }
25
+
26
+ buildTypes {
27
+ release {
28
+ minifyEnabled false
29
+ }
30
+ }
31
+
32
+ def javaVersion = safeExtGet('jvmTargetVersion', '17')
33
+
34
+ compileOptions {
35
+ sourceCompatibility JavaVersion.toVersion(javaVersion)
36
+ targetCompatibility JavaVersion.toVersion(javaVersion)
37
+ }
38
+
39
+ kotlinOptions {
40
+ jvmTarget = javaVersion
41
+ }
42
+
43
+ sourceSets {
44
+ main {
45
+ java.srcDirs += 'src/main/kotlin'
46
+ }
47
+ test {
48
+ java.srcDirs += 'src/test/kotlin'
49
+ }
50
+ }
51
+ }
52
+
53
+ repositories {
54
+ google()
55
+ mavenCentral()
56
+ }
57
+
58
+ dependencies {
59
+ implementation "com.facebook.react:react-native:+"
60
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '1.9.22')}"
61
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
62
+ implementation "androidx.core:core-ktx:1.12.0"
63
+
64
+ testImplementation "junit:junit:4.13.2"
65
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
66
+ }
@@ -0,0 +1,10 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ package="com.loyalytics.swan">
3
+
4
+ <application>
5
+ <receiver
6
+ android:name=".templates.SwanNotificationActionReceiver"
7
+ android:exported="false" />
8
+ </application>
9
+
10
+ </manifest>
@@ -0,0 +1,43 @@
1
+ package com.loyalytics.swan
2
+
3
+ import com.facebook.react.bridge.*
4
+ import com.loyalytics.swan.templates.SwanTemplateRegistry
5
+ import com.loyalytics.swan.templates.carousel.CarouselTemplate
6
+ import kotlinx.coroutines.CoroutineScope
7
+ import kotlinx.coroutines.Dispatchers
8
+ import kotlinx.coroutines.launch
9
+
10
+ class SwanNotificationModule(reactContext: ReactApplicationContext) :
11
+ ReactContextBaseJavaModule(reactContext) {
12
+
13
+ override fun getName(): String = "SwanNotificationModule"
14
+
15
+ init {
16
+ // Register built-in templates
17
+ SwanTemplateRegistry.register(CarouselTemplate())
18
+ }
19
+
20
+ @ReactMethod
21
+ fun displayTemplateNotification(config: ReadableMap, promise: Promise) {
22
+ val type = config.getString("notificationType")
23
+ if (type == null) {
24
+ promise.reject("MISSING_TYPE", "notificationType is required")
25
+ return
26
+ }
27
+
28
+ val template = SwanTemplateRegistry.findTemplate(type)
29
+ if (template == null) {
30
+ promise.reject("UNSUPPORTED_TYPE", "No template registered for: $type")
31
+ return
32
+ }
33
+
34
+ CoroutineScope(Dispatchers.IO).launch {
35
+ try {
36
+ template.display(reactApplicationContext, config)
37
+ promise.resolve(true)
38
+ } catch (e: Exception) {
39
+ promise.reject("DISPLAY_ERROR", "Template display failed: ${e.message}", e)
40
+ }
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,16 @@
1
+ package com.loyalytics.swan
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class SwanNotificationPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10
+ return listOf(SwanNotificationModule(reactContext))
11
+ }
12
+
13
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
14
+ return emptyList()
15
+ }
16
+ }
@@ -0,0 +1,49 @@
1
+ package com.loyalytics.swan.templates
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.os.Bundle
7
+ import android.util.Log
8
+
9
+ /**
10
+ * Single BroadcastReceiver for all template notification actions.
11
+ * Routes actions to the correct template based on templateType extra.
12
+ *
13
+ * Intent extras:
14
+ * - templateType: String — which template to route to (e.g., "carousel")
15
+ * - templateAction: String — the action to perform (e.g., "NEXT", "PREV", "CLICK")
16
+ * - notificationId: Int — the notification ID for updating in-place
17
+ * - Additional extras specific to each template
18
+ */
19
+ class SwanNotificationActionReceiver : BroadcastReceiver() {
20
+ companion object {
21
+ private const val TAG = "SwanActionReceiver"
22
+ const val EXTRA_TEMPLATE_TYPE = "templateType"
23
+ const val EXTRA_TEMPLATE_ACTION = "templateAction"
24
+ const val EXTRA_NOTIFICATION_ID = "notificationId"
25
+ }
26
+
27
+ override fun onReceive(context: Context, intent: Intent) {
28
+ val templateType = intent.getStringExtra(EXTRA_TEMPLATE_TYPE)
29
+ val action = intent.getStringExtra(EXTRA_TEMPLATE_ACTION)
30
+
31
+ if (templateType == null || action == null) {
32
+ Log.w(TAG, "Missing templateType or templateAction in intent")
33
+ return
34
+ }
35
+
36
+ Log.d(TAG, "Received action: $action for template: $templateType")
37
+
38
+ // Ensure templates are registered (covers process restart scenario)
39
+ SwanTemplateRegistry.ensureDefaults()
40
+
41
+ val template = SwanTemplateRegistry.findTemplate(templateType)
42
+ if (template == null) {
43
+ Log.w(TAG, "No template registered for type: $templateType")
44
+ return
45
+ }
46
+
47
+ template.handleAction(context, action, intent.extras ?: Bundle.EMPTY)
48
+ }
49
+ }
@@ -0,0 +1,20 @@
1
+ package com.loyalytics.swan.templates
2
+
3
+ import android.content.Context
4
+ import android.os.Bundle
5
+ import com.facebook.react.bridge.ReadableMap
6
+
7
+ /**
8
+ * Interface for notification template handlers.
9
+ * Each notification type (carousel, timer, CTA) implements this.
10
+ */
11
+ interface SwanNotificationTemplate {
12
+ /** Whether this template handles the given notificationType */
13
+ fun canHandle(notificationType: String): Boolean
14
+
15
+ /** Display the notification using custom RemoteViews */
16
+ suspend fun display(context: Context, config: ReadableMap)
17
+
18
+ /** Handle a user action (button press, swipe, etc.) routed from BroadcastReceiver */
19
+ fun handleAction(context: Context, action: String, extras: Bundle)
20
+ }
@@ -0,0 +1,47 @@
1
+ package com.loyalytics.swan.templates
2
+
3
+ import com.loyalytics.swan.templates.carousel.CarouselTemplate
4
+
5
+ /**
6
+ * Registry of notification template handlers.
7
+ * Templates register once at module init time.
8
+ * The BroadcastReceiver and bridge module look up templates here.
9
+ *
10
+ * ensureDefaults() is called by the BroadcastReceiver to guarantee
11
+ * templates are available even after a process restart (when the
12
+ * React Native module init may not have run yet).
13
+ */
14
+ object SwanTemplateRegistry {
15
+ private val templates = mutableListOf<SwanNotificationTemplate>()
16
+ @Volatile
17
+ private var defaultsRegistered = false
18
+
19
+ fun register(template: SwanNotificationTemplate) {
20
+ // Remove existing handler for the same type to allow override
21
+ templates.removeAll { it::class == template::class }
22
+ templates.add(template)
23
+ }
24
+
25
+ /**
26
+ * Ensure built-in templates are registered.
27
+ * Safe to call multiple times — only registers once.
28
+ */
29
+ fun ensureDefaults() {
30
+ if (!defaultsRegistered) {
31
+ if (templates.none { it.canHandle("carousel") }) {
32
+ templates.add(CarouselTemplate())
33
+ }
34
+ defaultsRegistered = true
35
+ }
36
+ }
37
+
38
+ fun findTemplate(notificationType: String): SwanNotificationTemplate? {
39
+ return templates.find { it.canHandle(notificationType) }
40
+ }
41
+
42
+ /** Visible for testing — clears all registered templates */
43
+ internal fun clear() {
44
+ templates.clear()
45
+ defaultsRegistered = false
46
+ }
47
+ }
@@ -0,0 +1,103 @@
1
+ package com.loyalytics.swan.templates.carousel
2
+
3
+ import android.app.PendingIntent
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.graphics.Bitmap
7
+ import android.os.Build
8
+ import android.os.Bundle
9
+ import android.widget.RemoteViews
10
+ import com.loyalytics.swan.R
11
+ import com.loyalytics.swan.templates.SwanNotificationActionReceiver
12
+
13
+ /**
14
+ * Builds RemoteViews for auto-carousel using ViewFlipper.
15
+ *
16
+ * All images are added as children to the ViewFlipper which handles
17
+ * cycling natively with slide animations. No AlarmManager needed.
18
+ *
19
+ * The entire flipper area gets a single click action (auto-carousel
20
+ * uses one deep link for the whole notification, same as CleverTap).
21
+ *
22
+ * Capped at MAX_FLIPPER_IMAGES to stay within RemoteViews ~1MB limit.
23
+ */
24
+ object CarouselAutoRemoteViews {
25
+
26
+ private const val MAX_FLIPPER_IMAGES = 5
27
+
28
+ data class AutoCarouselViews(
29
+ val expanded: RemoteViews,
30
+ val collapsed: RemoteViews
31
+ )
32
+
33
+ fun build(
34
+ context: Context,
35
+ notificationId: Int,
36
+ title: String,
37
+ body: String,
38
+ bitmaps: List<Bitmap?>,
39
+ intervalMs: Int,
40
+ messageId: String,
41
+ defaultRoute: String
42
+ ): AutoCarouselViews {
43
+ val packageName = context.packageName
44
+
45
+ val expanded = RemoteViews(packageName, R.layout.swan_carousel_auto_expanded).apply {
46
+ setTextViewText(R.id.swan_carousel_auto_title, title)
47
+ setTextViewText(R.id.swan_carousel_auto_body, body)
48
+
49
+ // autoStart and flipInterval are set in XML (RemoteViews doesn't allow setAutoStart)
50
+
51
+ // Add each bitmap as a child of the ViewFlipper
52
+ removeAllViews(R.id.swan_carousel_flipper)
53
+ val validBitmaps = bitmaps.filterNotNull().take(MAX_FLIPPER_IMAGES)
54
+ for (bitmap in validBitmaps) {
55
+ val childView = RemoteViews(packageName, R.layout.swan_carousel_flipper_item)
56
+ childView.setImageViewBitmap(R.id.swan_carousel_flipper_image, bitmap)
57
+ addView(R.id.swan_carousel_flipper, childView)
58
+ }
59
+
60
+ // Single click action for the whole flipper
61
+ setOnClickPendingIntent(
62
+ R.id.swan_carousel_flipper,
63
+ createClickPendingIntent(
64
+ context, notificationId, messageId, defaultRoute
65
+ )
66
+ )
67
+ }
68
+
69
+ val collapsed = RemoteViews(packageName, R.layout.swan_carousel_collapsed).apply {
70
+ setTextViewText(R.id.swan_carousel_collapsed_title, title)
71
+ setTextViewText(R.id.swan_carousel_collapsed_body, body)
72
+ }
73
+
74
+ return AutoCarouselViews(expanded, collapsed)
75
+ }
76
+
77
+ private fun createClickPendingIntent(
78
+ context: Context,
79
+ notificationId: Int,
80
+ messageId: String,
81
+ route: String
82
+ ): PendingIntent {
83
+ val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
84
+ action = "com.loyalytics.swan.${CarouselTemplate.ACTION_CLICK}_$notificationId"
85
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, CarouselTemplate.TYPE)
86
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_ACTION, CarouselTemplate.ACTION_CLICK)
87
+ putExtra(SwanNotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
88
+ putExtra(CarouselTemplate.EXTRA_MESSAGE_ID, messageId)
89
+ putExtra(CarouselTemplate.EXTRA_ITEM_INDEX, 0)
90
+ putExtra(CarouselTemplate.EXTRA_ITEM_ROUTE, route)
91
+ }
92
+
93
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
94
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
95
+ } else {
96
+ PendingIntent.FLAG_UPDATE_CURRENT
97
+ }
98
+
99
+ return PendingIntent.getBroadcast(
100
+ context, "${notificationId}AUTO_CLICK".hashCode(), intent, flags
101
+ )
102
+ }
103
+ }
@@ -0,0 +1,132 @@
1
+ package com.loyalytics.swan.templates.carousel
2
+
3
+ import android.app.PendingIntent
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.graphics.Bitmap
7
+ import android.os.Build
8
+ import android.view.View
9
+ import android.widget.RemoteViews
10
+ import com.loyalytics.swan.R
11
+ import com.loyalytics.swan.templates.SwanNotificationActionReceiver
12
+
13
+ /**
14
+ * Builds RemoteViews for filmstrip carousel variant.
15
+ *
16
+ * Shows 3 images horizontally: left preview (dimmed) | center (full) | right preview (dimmed).
17
+ * Clicking side images navigates prev/next. Center click opens deep link.
18
+ */
19
+ object CarouselFilmstripRemoteViews {
20
+
21
+ data class FilmstripViews(
22
+ val expanded: RemoteViews,
23
+ val collapsed: RemoteViews
24
+ )
25
+
26
+ fun build(
27
+ context: Context,
28
+ notificationId: Int,
29
+ title: String,
30
+ itemTitle: String,
31
+ itemBody: String,
32
+ leftBitmap: Bitmap?,
33
+ centerBitmap: Bitmap?,
34
+ rightBitmap: Bitmap?,
35
+ currentIndex: Int,
36
+ totalItems: Int,
37
+ messageId: String,
38
+ itemRoute: String
39
+ ): FilmstripViews {
40
+ val packageName = context.packageName
41
+
42
+ val expanded = RemoteViews(packageName, R.layout.swan_carousel_filmstrip_expanded).apply {
43
+ setTextViewText(R.id.swan_filmstrip_title, title)
44
+ setTextViewText(R.id.swan_filmstrip_item_title, itemTitle)
45
+ setTextViewText(R.id.swan_filmstrip_item_body, itemBody)
46
+ setTextViewText(R.id.swan_filmstrip_counter, "${currentIndex + 1} / $totalItems")
47
+
48
+ // Left preview image
49
+ if (leftBitmap != null) {
50
+ setImageViewBitmap(R.id.swan_filmstrip_left, leftBitmap)
51
+ setViewVisibility(R.id.swan_filmstrip_left, View.VISIBLE)
52
+ setOnClickPendingIntent(
53
+ R.id.swan_filmstrip_left,
54
+ createCarouselActionIntent(
55
+ context, notificationId, CarouselTemplate.ACTION_PREV,
56
+ messageId, currentIndex, itemRoute
57
+ )
58
+ )
59
+ } else {
60
+ setViewVisibility(R.id.swan_filmstrip_left, View.INVISIBLE)
61
+ }
62
+
63
+ // Center image
64
+ if (centerBitmap != null) {
65
+ setImageViewBitmap(R.id.swan_filmstrip_center, centerBitmap)
66
+ setViewVisibility(R.id.swan_filmstrip_center, View.VISIBLE)
67
+ } else {
68
+ setViewVisibility(R.id.swan_filmstrip_center, View.GONE)
69
+ }
70
+
71
+ // Center click → open deep link
72
+ setOnClickPendingIntent(
73
+ R.id.swan_filmstrip_center,
74
+ createCarouselActionIntent(
75
+ context, notificationId, CarouselTemplate.ACTION_CLICK,
76
+ messageId, currentIndex, itemRoute
77
+ )
78
+ )
79
+
80
+ // Right preview image
81
+ if (rightBitmap != null) {
82
+ setImageViewBitmap(R.id.swan_filmstrip_right, rightBitmap)
83
+ setViewVisibility(R.id.swan_filmstrip_right, View.VISIBLE)
84
+ setOnClickPendingIntent(
85
+ R.id.swan_filmstrip_right,
86
+ createCarouselActionIntent(
87
+ context, notificationId, CarouselTemplate.ACTION_NEXT,
88
+ messageId, currentIndex, itemRoute
89
+ )
90
+ )
91
+ } else {
92
+ setViewVisibility(R.id.swan_filmstrip_right, View.INVISIBLE)
93
+ }
94
+ }
95
+
96
+ val collapsed = RemoteViews(packageName, R.layout.swan_carousel_collapsed).apply {
97
+ setTextViewText(R.id.swan_carousel_collapsed_title, title)
98
+ setTextViewText(R.id.swan_carousel_collapsed_body, itemTitle)
99
+ }
100
+
101
+ return FilmstripViews(expanded, collapsed)
102
+ }
103
+
104
+ private fun createCarouselActionIntent(
105
+ context: Context,
106
+ notificationId: Int,
107
+ action: String,
108
+ messageId: String,
109
+ itemIndex: Int,
110
+ itemRoute: String
111
+ ): PendingIntent {
112
+ val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
113
+ this.action = "com.loyalytics.swan.${action}_$notificationId"
114
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, CarouselTemplate.TYPE)
115
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_ACTION, action)
116
+ putExtra(SwanNotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
117
+ putExtra(CarouselTemplate.EXTRA_MESSAGE_ID, messageId)
118
+ putExtra(CarouselTemplate.EXTRA_ITEM_INDEX, itemIndex)
119
+ putExtra(CarouselTemplate.EXTRA_ITEM_ROUTE, itemRoute)
120
+ }
121
+
122
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
123
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
124
+ } else {
125
+ PendingIntent.FLAG_UPDATE_CURRENT
126
+ }
127
+
128
+ return PendingIntent.getBroadcast(
129
+ context, "$notificationId$action$itemIndex".hashCode(), intent, flags
130
+ )
131
+ }
132
+ }
@@ -0,0 +1,129 @@
1
+ package com.loyalytics.swan.templates.carousel
2
+
3
+ import android.app.PendingIntent
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.graphics.Bitmap
7
+ import android.os.Build
8
+ import android.view.View
9
+ import android.widget.RemoteViews
10
+ import com.loyalytics.swan.R
11
+ import com.loyalytics.swan.templates.SwanNotificationActionReceiver
12
+
13
+ /**
14
+ * Builds RemoteViews for the standard manual carousel.
15
+ *
16
+ * Uses a single ViewFlipper with all images pre-loaded.
17
+ * Navigation uses showNext()/showPrevious() with fade animation.
18
+ */
19
+ object CarouselRemoteViews {
20
+
21
+ data class CarouselViews(
22
+ val expanded: RemoteViews,
23
+ val collapsed: RemoteViews
24
+ )
25
+
26
+ fun build(
27
+ context: Context,
28
+ notificationId: Int,
29
+ title: String,
30
+ body: String,
31
+ bitmaps: List<Bitmap?>,
32
+ currentIndex: Int,
33
+ totalItems: Int,
34
+ messageId: String,
35
+ itemRoute: String
36
+ ): CarouselViews {
37
+ val packageName = context.packageName
38
+
39
+ val expanded = RemoteViews(packageName, R.layout.swan_carousel_expanded).apply {
40
+ setTextViewText(R.id.swan_carousel_title, title)
41
+ setTextViewText(R.id.swan_carousel_body, body)
42
+ setTextViewText(R.id.swan_carousel_counter, "${currentIndex + 1} / $totalItems")
43
+
44
+ // Populate ViewFlipper with all images
45
+ removeAllViews(R.id.swan_carousel_center)
46
+ for (bitmap in bitmaps) {
47
+ val child = RemoteViews(packageName, R.layout.swan_carousel_flipper_item)
48
+ if (bitmap != null) {
49
+ child.setImageViewBitmap(R.id.swan_carousel_flipper_image, bitmap)
50
+ }
51
+ addView(R.id.swan_carousel_center, child)
52
+ }
53
+
54
+ // Advance ViewFlipper to currentIndex
55
+ for (i in 0 until currentIndex) {
56
+ showNext(R.id.swan_carousel_center)
57
+ }
58
+
59
+ // Show/hide arrows
60
+ if (totalItems > 1) {
61
+ setViewVisibility(R.id.swan_carousel_btn_prev, View.VISIBLE)
62
+ setViewVisibility(R.id.swan_carousel_btn_next, View.VISIBLE)
63
+
64
+ setOnClickPendingIntent(
65
+ R.id.swan_carousel_btn_prev,
66
+ createActionIntent(
67
+ context, notificationId, CarouselTemplate.ACTION_PREV,
68
+ messageId, currentIndex, itemRoute
69
+ )
70
+ )
71
+ setOnClickPendingIntent(
72
+ R.id.swan_carousel_btn_next,
73
+ createActionIntent(
74
+ context, notificationId, CarouselTemplate.ACTION_NEXT,
75
+ messageId, currentIndex, itemRoute
76
+ )
77
+ )
78
+ } else {
79
+ setViewVisibility(R.id.swan_carousel_btn_prev, View.GONE)
80
+ setViewVisibility(R.id.swan_carousel_btn_next, View.GONE)
81
+ }
82
+
83
+ // Image click → open deep link
84
+ setOnClickPendingIntent(
85
+ R.id.swan_carousel_center,
86
+ createActionIntent(
87
+ context, notificationId, CarouselTemplate.ACTION_CLICK,
88
+ messageId, currentIndex, itemRoute
89
+ )
90
+ )
91
+ }
92
+
93
+ val collapsed = RemoteViews(packageName, R.layout.swan_carousel_collapsed).apply {
94
+ setTextViewText(R.id.swan_carousel_collapsed_title, title)
95
+ setTextViewText(R.id.swan_carousel_collapsed_body, body)
96
+ }
97
+
98
+ return CarouselViews(expanded, collapsed)
99
+ }
100
+
101
+ private fun createActionIntent(
102
+ context: Context,
103
+ notificationId: Int,
104
+ action: String,
105
+ messageId: String,
106
+ itemIndex: Int,
107
+ itemRoute: String
108
+ ): PendingIntent {
109
+ val intent = Intent(context, SwanNotificationActionReceiver::class.java).apply {
110
+ this.action = "com.loyalytics.swan.${action}_$notificationId"
111
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_TYPE, CarouselTemplate.TYPE)
112
+ putExtra(SwanNotificationActionReceiver.EXTRA_TEMPLATE_ACTION, action)
113
+ putExtra(SwanNotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
114
+ putExtra(CarouselTemplate.EXTRA_MESSAGE_ID, messageId)
115
+ putExtra(CarouselTemplate.EXTRA_ITEM_INDEX, itemIndex)
116
+ putExtra(CarouselTemplate.EXTRA_ITEM_ROUTE, itemRoute)
117
+ }
118
+
119
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
120
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
121
+ } else {
122
+ PendingIntent.FLAG_UPDATE_CURRENT
123
+ }
124
+
125
+ return PendingIntent.getBroadcast(
126
+ context, "$notificationId$action$itemIndex".hashCode(), intent, flags
127
+ )
128
+ }
129
+ }