Skip to content

Commit 18dc3b1

Browse files
kherembourgclaude
andauthored
feat: add missing SDK features (anonymous ID, display modes, promo code) (#4)
* feat(android): add anonymous ID display and session tracking attributes Show Purchasely anonymous user ID in Settings with a one-tap copy button, and track last_open_date (Date) + session_count (increment) on each SDK init. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ios): add anonymous ID display and session tracking attributes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ios): add default presentation result handler Sets setDefaultPresentationResultHandler at SDK init to capture results from deeplink-triggered paywalls and refresh premium status. Adds comment clarifying that user attribute changes are handled via PLYEventDelegate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(android): add default presentation result handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(ios): handle promo code action in paywall interceptor * feat(android): add paywall display mode picker in Settings --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ad86997 commit 18dc3b1

6 files changed

Lines changed: 134 additions & 1 deletion

File tree

android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ class ShakerApp : Application() {
5353
Log.d(TAG, "[Shaker] Purchasely SDK configured successfully (${currentMode.name})")
5454
val premiumManager: PremiumManager by inject()
5555
premiumManager.refreshPremiumStatus()
56+
// Track additional user attributes for demo
57+
Purchasely.setUserAttribute("last_open_date", java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).format(java.util.Date()))
58+
Purchasely.incrementUserAttribute("session_count")
5659
}
5760
error?.let {
5861
Log.e(TAG, "[Shaker] Purchasely configuration error: ${it.message}")
@@ -65,6 +68,13 @@ class ShakerApp : Application() {
6568
}
6669
}
6770

71+
// Handle results from deeplink-triggered paywalls
72+
Purchasely.setDefaultPresentationResultHandler { result, plan ->
73+
Log.d(TAG, "[Shaker] Default presentation result: $result | Plan: ${plan?.name}")
74+
val premiumManager: PremiumManager by inject()
75+
premiumManager.refreshPremiumStatus()
76+
}
77+
6878
setupInterceptor()
6979

7080
// Synchronize on launch when in Observer mode to catch external transactions

android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.fillMaxSize
1010
import androidx.compose.foundation.layout.fillMaxWidth
1111
import androidx.compose.foundation.layout.height
1212
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.layout.size
1314
import androidx.compose.foundation.layout.width
1415
import androidx.compose.foundation.rememberScrollState
1516
import androidx.compose.foundation.verticalScroll
1617
import androidx.compose.material.icons.Icons
18+
import androidx.compose.material.icons.filled.ContentCopy
1719
import androidx.compose.material.icons.filled.Person
1820
import androidx.compose.material3.Button
1921
import androidx.compose.material3.ButtonDefaults
22+
import androidx.compose.material3.IconButton
2023
import androidx.compose.material3.HorizontalDivider
2124
import androidx.compose.material3.Icon
2225
import androidx.compose.material3.MaterialTheme
@@ -36,7 +39,10 @@ import androidx.compose.runtime.remember
3639
import androidx.compose.runtime.setValue
3740
import androidx.compose.ui.Alignment
3841
import androidx.compose.ui.Modifier
42+
import androidx.compose.ui.platform.ClipboardManager
43+
import androidx.compose.ui.platform.LocalClipboardManager
3944
import androidx.compose.ui.platform.LocalContext
45+
import androidx.compose.ui.text.AnnotatedString
4046
import androidx.compose.ui.unit.dp
4147
import io.purchasely.ext.PLYPresentationType
4248
import io.purchasely.ext.PLYProductViewResult
@@ -57,6 +63,9 @@ fun SettingsScreen(
5763
val campaignsConsent by viewModel.campaignsConsent.collectAsState()
5864
val thirdPartyConsent by viewModel.thirdPartyConsent.collectAsState()
5965
val runningMode by viewModel.runningMode.collectAsState()
66+
val anonymousId by viewModel.anonymousId.collectAsState()
67+
val displayMode by viewModel.displayMode.collectAsState()
68+
val clipboardManager: ClipboardManager = LocalClipboardManager.current
6069
val context = LocalContext.current
6170
var loginInput by remember { mutableStateOf("") }
6271

@@ -141,6 +150,23 @@ fun SettingsScreen(
141150
color = if (isPremium) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
142151
)
143152
}
153+
Spacer(modifier = Modifier.height(8.dp))
154+
Row(verticalAlignment = Alignment.CenterVertically) {
155+
Column(modifier = Modifier.weight(1f)) {
156+
Text("Anonymous ID", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
157+
Text(
158+
text = anonymousId,
159+
style = MaterialTheme.typography.bodySmall,
160+
maxLines = 1
161+
)
162+
}
163+
IconButton(onClick = {
164+
clipboardManager.setText(AnnotatedString(anonymousId))
165+
Toast.makeText(context, "Copied!", Toast.LENGTH_SHORT).show()
166+
}) {
167+
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
168+
}
169+
}
144170

145171
Spacer(modifier = Modifier.height(24.dp))
146172
HorizontalDivider()
@@ -300,6 +326,38 @@ fun SettingsScreen(
300326
HorizontalDivider()
301327
Spacer(modifier = Modifier.height(24.dp))
302328

329+
// Display Mode section (Android only)
330+
Text(
331+
text = "Paywall Display Mode",
332+
style = MaterialTheme.typography.titleMedium,
333+
color = MaterialTheme.colorScheme.primary
334+
)
335+
Spacer(modifier = Modifier.height(4.dp))
336+
Text(
337+
text = "How paywalls are presented on screen",
338+
style = MaterialTheme.typography.bodySmall,
339+
color = MaterialTheme.colorScheme.onSurfaceVariant
340+
)
341+
Spacer(modifier = Modifier.height(12.dp))
342+
343+
val displayModes = listOf("fullscreen", "modal", "drawer", "popin")
344+
val displayLabels = listOf("Full", "Modal", "Drawer", "Popin")
345+
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
346+
displayModes.forEachIndexed { index, mode ->
347+
SegmentedButton(
348+
selected = displayMode == mode,
349+
onClick = { viewModel.setDisplayMode(mode) },
350+
shape = SegmentedButtonDefaults.itemShape(index = index, count = displayModes.size)
351+
) {
352+
Text(displayLabels[index], style = MaterialTheme.typography.labelSmall)
353+
}
354+
}
355+
}
356+
357+
Spacer(modifier = Modifier.height(24.dp))
358+
HorizontalDivider()
359+
Spacer(modifier = Modifier.height(24.dp))
360+
303361
// About section
304362
Text(
305363
text = "About",

android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,20 @@ class SettingsViewModel(
5555
)
5656
val runningMode: StateFlow<String> = _runningMode.asStateFlow()
5757

58+
private val _anonymousId = MutableStateFlow(Purchasely.anonymousUserId)
59+
val anonymousId: StateFlow<String> = _anonymousId.asStateFlow()
60+
61+
private val _displayMode = MutableStateFlow(prefs.getString(KEY_DISPLAY_MODE, "fullscreen") ?: "fullscreen")
62+
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
63+
5864
init {
5965
applyConsentPreferences()
6066
}
6167

68+
fun refreshAnonymousId() {
69+
_anonymousId.value = Purchasely.anonymousUserId
70+
}
71+
6272
fun login(userId: String) {
6373
if (userId.isBlank()) return
6474

@@ -106,6 +116,12 @@ class SettingsViewModel(
106116
premiumManager.refreshPremiumStatus()
107117
}
108118

119+
fun setDisplayMode(mode: String) {
120+
_displayMode.value = mode
121+
prefs.edit().putString(KEY_DISPLAY_MODE, mode).apply()
122+
Log.d(TAG, "[Shaker] Display mode changed to: $mode")
123+
}
124+
109125
fun setThemeMode(mode: String) {
110126
_themeMode.value = mode
111127
prefs.edit().putString(KEY_THEME, mode).apply()
@@ -173,5 +189,6 @@ class SettingsViewModel(
173189
private const val KEY_CONSENT_PERSONALIZATION = "consent_personalization"
174190
private const val KEY_CONSENT_CAMPAIGNS = "consent_campaigns"
175191
private const val KEY_CONSENT_THIRD_PARTY = "consent_third_party"
192+
private const val KEY_DISPLAY_MODE = "display_mode"
176193
}
177194
}

ios/Shaker/AppViewModel.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Foundation
22
import UIKit
3-
import Purchasely
43
import StoreKit
4+
import Purchasely
55

66
class AppViewModel: ObservableObject {
77

@@ -31,6 +31,10 @@ class AppViewModel: ObservableObject {
3131
if success {
3232
print("[Shaker] Purchasely SDK configured successfully (\(modeName))")
3333
PremiumManager.shared.refreshPremiumStatus()
34+
// Track additional user attributes for demo
35+
let dateFormatter = ISO8601DateFormatter()
36+
Purchasely.setUserAttribute(withStringValue: dateFormatter.string(from: Date()), forKey: "last_open_date")
37+
Purchasely.incrementUserAttribute(withKey: "session_count")
3438
} else {
3539
self?.sdkError = error?.localizedDescription
3640
print("[Shaker] Purchasely configuration error: \(error?.localizedDescription ?? "unknown")")
@@ -42,6 +46,15 @@ class AppViewModel: ObservableObject {
4246

4347
Purchasely.setEventDelegate(self)
4448

49+
// Handle results from deeplink-triggered paywalls
50+
Purchasely.setDefaultPresentationResultHandler { result, plan in
51+
print("[Shaker] Default presentation result: \(result) | Plan: \(plan?.name ?? "none")")
52+
PremiumManager.shared.refreshPremiumStatus()
53+
}
54+
55+
// Note: User attribute changes from in-paywall surveys are captured via PLYEventDelegate
56+
// (no separate setUserAttributeListener API exists in the iOS SDK)
57+
4558
setupInterceptor()
4659

4760
// Synchronize on launch when in Observer mode to catch external transactions
@@ -144,6 +157,17 @@ class AppViewModel: ObservableObject {
144157
proceed(true)
145158
}
146159

160+
case .promoCode:
161+
print("[Shaker] Promo code action intercepted")
162+
DispatchQueue.main.async {
163+
if let windowScene = UIApplication.shared.connectedScenes
164+
.compactMap({ $0 as? UIWindowScene })
165+
.first {
166+
SKPaymentQueue.default().presentCodeRedemptionSheet()
167+
}
168+
}
169+
proceed(false)
170+
147171
default:
148172
proceed(true)
149173
}

ios/Shaker/Screens/Settings/SettingsScreen.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ struct SettingsScreen: View {
4747
Text(premiumManager.isPremium ? "Active" : "Free")
4848
.foregroundStyle(premiumManager.isPremium ? .orange : .secondary)
4949
}
50+
51+
HStack {
52+
VStack(alignment: .leading, spacing: 2) {
53+
Text("Anonymous ID")
54+
.font(.caption)
55+
.foregroundStyle(.secondary)
56+
Text(viewModel.anonymousId)
57+
.font(.caption2)
58+
.lineLimit(1)
59+
}
60+
Spacer()
61+
Button {
62+
UIPasteboard.general.string = viewModel.anonymousId
63+
} label: {
64+
Image(systemName: "doc.on.doc")
65+
.font(.caption)
66+
}
67+
}
5068
}
5169

5270
// Purchases section

ios/Shaker/Screens/Settings/SettingsViewModel.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class SettingsViewModel: ObservableObject {
1414
@Published var campaignsConsent: Bool
1515
@Published var thirdPartyConsent: Bool
1616
@Published var runningMode: String
17+
@Published var anonymousId: String = ""
1718

1819
private let userIdKey = "user_id"
1920
private let themeKey = "theme_mode"
@@ -36,6 +37,11 @@ class SettingsViewModel: ObservableObject {
3637
runningMode = RunningModeRepository.shared.isObserverMode ? "observer" : "full"
3738

3839
applyConsentPreferences()
40+
anonymousId = Purchasely.anonymousUserId ?? ""
41+
}
42+
43+
func refreshAnonymousId() {
44+
anonymousId = Purchasely.anonymousUserId ?? ""
3945
}
4046

4147
func login(userId: String) {

0 commit comments

Comments
 (0)