Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ A cocktail discovery app demonstrating a production-quality [Purchasely](https:/
| Feature | Android | iOS | Placement |
|---------|---------|-----|-----------|
| Onboarding paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `onboarding` |
| Recipe detail paywall | `presentationView` | `presentationController` | `recipe_detail` |
| Favorites paywall | `presentationView` | `presentationController` | `favorites` |
| Filters paywall | `presentationView` | `presentationController` | `filters` |
| Recipe detail paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `recipe_detail` |
| Favorites paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `favorites` |
| Filters paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `filters` |
Comment on lines +10 to +12

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The README.md update correctly reflects the change from presentationView and presentationController to fetchPresentation + display for paywall flows. This is a good clarification for anyone setting up the project.

| User login/logout | `userLogin` / `userLogout` | `userLogin` / `userLogout` | - |
| Restore purchases | `restoreAllProducts` | `restoreAllProducts` | - |
| User attributes | `setUserAttribute` / `incrementUserAttribute` | `setUserAttribute` / `incrementUserAttribute` | - |
Expand Down Expand Up @@ -110,6 +110,16 @@ Shaker/
└── Podfile
```

## Known Constraints

- Purchasely placements and plans must be configured in Console before paywalls can be displayed.
- A public demo Purchasely API key is hardcoded by default for quick startup.
- You can override it with local config files:
- Android: `android/local.properties` (`purchasely.apiKey=...`)
- iOS: `ios/Config.xcconfig` (`PURCHASELY_API_KEY = ...`)
- Paywall flows require `fetchPresentation + display()`. Do not use convenience APIs like `presentationView` or `presentationController`.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This new constraint is important for developers integrating the SDK. It clearly states the recommended approach for paywall flows, avoiding potential misuse of convenience APIs.

- Cocktail catalog browsing works offline from bundled JSON, but paywall fetch requires network access.

## Links

- [Purchasely Documentation](https://docs.purchasely.com/)
Expand Down
3 changes: 1 addition & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ android {
buildConfigField(
"String",
"PURCHASELY_API_KEY",
"\"6cda6b92-d63c-4444-bd55-5a164c989bd4\""
//"\"${localProperties.getProperty("purchasely.apiKey", "")}\""
"\"${localProperties.getProperty("purchasely.apiKey", "6cda6b92-d63c-4444-bd55-5a164c989bd4")}\""

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The change to use localProperties.getProperty with a default value is a good improvement. It allows for easier local configuration while providing a fallback API key for quick startup, as mentioned in the README.md.

"${localProperties.getProperty("purchasely.apiKey", "6cda6b92-d63c-4444-bd55-5a164c989bd4")}"

)
}

Expand Down
26 changes: 23 additions & 3 deletions android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import android.app.Application
import android.content.Intent
import android.net.Uri
import android.util.Log
import com.purchasely.shaker.data.PurchaselySdkMode
import com.purchasely.shaker.data.PremiumManager
import com.purchasely.shaker.di.appModule
import io.purchasely.ext.EventListener
import io.purchasely.ext.LogLevel
import io.purchasely.ext.PLYEvent
import io.purchasely.ext.PLYPresentationAction
import io.purchasely.ext.PLYRunningMode

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The import for PLYRunningMode is no longer needed in ShakerApp.kt as PurchaselySdkMode now encapsulates this. Removing unused imports helps keep the code clean.

import io.purchasely.ext.Purchasely
import io.purchasely.google.GoogleStore
import org.koin.android.ext.android.inject
Expand All @@ -31,16 +31,18 @@ class ShakerApp : Application() {
}

private fun initPurchasely() {
val selectedMode = getSdkModeFromStorage()

Purchasely.Builder(this)
.apiKey("6cda6b92-d63c-4444-bd55-5a164c989bd4")
.logLevel(LogLevel.DEBUG)
.readyToOpenDeeplink(true)
.runningMode(PLYRunningMode.Full)
.runningMode(selectedMode.runningMode)
.stores(listOf(GoogleStore()))
.build()
.start { isConfigured, error ->
if (isConfigured) {
Log.d(TAG, "[Shaker] Purchasely SDK configured successfully")
Log.d(TAG, "[Shaker] Purchasely SDK configured successfully (mode: ${selectedMode.label})")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Updating the log message to include the selected SDK mode provides more context during debugging and startup, which is very helpful.

Suggested change
Log.d(TAG, "[Shaker] Purchasely SDK configured successfully (mode: ${selectedMode.label})")
Log.d(TAG, "[Shaker] Purchasely SDK configured successfully (mode: ${selectedMode.label})")

val premiumManager: PremiumManager by inject()
premiumManager.refreshPremiumStatus()
}
Expand Down Expand Up @@ -77,6 +79,24 @@ class ShakerApp : Application() {
}
}

fun restartPurchaselySdk() {
Log.d(TAG, "[Shaker] Restarting Purchasely SDK")
Purchasely.close()
initPurchasely()
}

private fun getSdkModeFromStorage(): PurchaselySdkMode {
val prefs = getSharedPreferences(PurchaselySdkMode.PREFERENCES_NAME, MODE_PRIVATE)
val storedMode = prefs.getString(PurchaselySdkMode.KEY, null)
val resolvedMode = PurchaselySdkMode.fromStorage(storedMode)

if (storedMode != resolvedMode.storageValue) {
prefs.edit().putString(PurchaselySdkMode.KEY, resolvedMode.storageValue).apply()
}
Comment on lines +93 to +95

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block ensures that if the stored mode is different from the resolved mode (e.g., if the stored value was invalid and defaulted), the correct default value is persisted back to storage. This maintains data consistency.

Suggested change
if (storedMode != resolvedMode.storageValue) {
prefs.edit().putString(PurchaselySdkMode.KEY, resolvedMode.storageValue).apply()
}
if (storedMode != resolvedMode.storageValue) {
prefs.edit().putString(PurchaselySdkMode.KEY, resolvedMode.storageValue).apply()
}


return resolvedMode
}

companion object {
private const val TAG = "ShakerApp"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.purchasely.shaker.data

import io.purchasely.ext.PLYRunningMode

enum class PurchaselySdkMode(
val storageValue: String,
val label: String,
val runningMode: PLYRunningMode
) {
PAYWALL_OBSERVER(
storageValue = "paywallObserver",
label = "Paywall Observer",
runningMode = PLYRunningMode.PaywallObserver
),
FULL(
storageValue = "full",
label = "Full",
runningMode = PLYRunningMode.Full
);

companion object {
const val PREFERENCES_NAME = "shaker_settings"
const val KEY = "purchasely_sdk_mode"
val DEFAULT = PAYWALL_OBSERVER

fun fromStorage(value: String?): PurchaselySdkMode {
return values().firstOrNull { it.storageValue == value } ?: DEFAULT
}
}
}
Comment on lines +1 to +30

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The creation of PurchaselySdkMode enum is a good way to encapsulate the different SDK running modes, their storage values, labels, and corresponding PLYRunningMode. This improves readability and maintainability by centralizing mode-related logic.

Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
Expand All @@ -27,6 +27,7 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
Expand All @@ -38,6 +39,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.purchasely.shaker.data.PurchaselySdkMode
import io.purchasely.ext.PLYPresentationType
import io.purchasely.ext.PLYProductViewResult
import io.purchasely.ext.Purchasely
Expand All @@ -51,6 +53,8 @@ fun SettingsScreen(
val isPremium by viewModel.isPremium.collectAsState()
val restoreMessage by viewModel.restoreMessage.collectAsState()
val themeMode by viewModel.themeMode.collectAsState()
val sdkMode by viewModel.sdkMode.collectAsState()
val sdkModeChangeAlert by viewModel.sdkModeChangeAlert.collectAsState()
val analyticsConsent by viewModel.analyticsConsent.collectAsState()
val identifiedAnalyticsConsent by viewModel.identifiedAnalyticsConsent.collectAsState()
val personalizationConsent by viewModel.personalizationConsent.collectAsState()
Expand All @@ -67,6 +71,19 @@ fun SettingsScreen(
}
}

if (sdkModeChangeAlert != null) {
AlertDialog(
onDismissRequest = { viewModel.clearSdkModeChangeAlert() },
title = { Text("SDK Restart Required") },
text = { Text(sdkModeChangeAlert ?: "") },
confirmButton = {
TextButton(onClick = { viewModel.clearSdkModeChangeAlert() }) {
Text("OK")
}
}
)
Comment on lines +74 to +84

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The AlertDialog for SDK restart is a good user experience touch. It clearly communicates to the user that a restart is required after changing the SDK mode, which is crucial for the changes to take effect.

AlertDialog(
            onDismissRequest = { viewModel.clearSdkModeChangeAlert() },
            title = { Text("SDK Restart Required") },
            text = { Text(sdkModeChangeAlert ?: "") },
            confirmButton = {
                TextButton(onClick = { viewModel.clearSdkModeChangeAlert() }) {
                    Text("OK")
                }
            }
        )

}

Column(
modifier = Modifier
.fillMaxSize()
Expand Down Expand Up @@ -188,6 +205,38 @@ fun SettingsScreen(
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))

// Purchasely SDK section
Text(
text = "Purchasely SDK",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))

val sdkModes = listOf(PurchaselySdkMode.PAYWALL_OBSERVER, PurchaselySdkMode.FULL)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
sdkModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = sdkMode == mode,
onClick = { viewModel.setSdkMode(mode) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = sdkModes.size)
) {
Text(mode.label)
}
}
}

Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Default mode is Paywall Observer. Changing mode restarts the SDK.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Comment on lines +208 to +234

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new "Purchasely SDK" section with a segmented button for selecting the SDK mode is well-implemented. It provides a clear and intuitive way for users to change this setting. The descriptive text below the segmented button is also helpful.

// Purchasely SDK section
        Text(
            text = "Purchasely SDK",
            style = MaterialTheme.typography.titleMedium,
            color = MaterialTheme.colorScheme.primary
        )
        Spacer(modifier = Modifier.height(12.dp))

        val sdkModes = listOf(PurchaselySdkMode.PAYWALL_OBSERVER, PurchaselySdkMode.FULL)
        SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
            sdkModes.forEachIndexed { index, mode ->
                SegmentedButton(
                    selected = sdkMode == mode,
                    onClick = { viewModel.setSdkMode(mode) },
                    shape = SegmentedButtonDefaults.itemShape(index = index, count = sdkModes.size)
                ) {
                    Text(mode.label)
                }
            }
        }

        Spacer(modifier = Modifier.height(8.dp))
        Text(
            text = "Default mode is Paywall Observer. Changing mode restarts the SDK.",
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )


Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))

// Data Privacy section
Text(
text = "Data Privacy",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.ViewModel
import com.purchasely.shaker.ShakerApp
import com.purchasely.shaker.data.PurchaselySdkMode
import com.purchasely.shaker.data.PremiumManager
import io.purchasely.ext.PLYDataProcessingPurpose
import io.purchasely.ext.Purchasely
Expand All @@ -17,7 +19,7 @@ class SettingsViewModel(
) : ViewModel() {

private val prefs: SharedPreferences =
context.getSharedPreferences("shaker_settings", Context.MODE_PRIVATE)
context.getSharedPreferences(PurchaselySdkMode.PREFERENCES_NAME, Context.MODE_PRIVATE)
Comment on lines 21 to +22

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using PurchaselySdkMode.PREFERENCES_NAME for the shared preferences name is a good practice. It centralizes the constant and makes it less prone to errors or inconsistencies.

context.getSharedPreferences(PurchaselySdkMode.PREFERENCES_NAME, Context.MODE_PRIVATE)


private val _userId = MutableStateFlow(prefs.getString(KEY_USER_ID, null))
val userId: StateFlow<String?> = _userId.asStateFlow()
Expand All @@ -30,6 +32,16 @@ class SettingsViewModel(
private val _themeMode = MutableStateFlow(prefs.getString(KEY_THEME, "system") ?: "system")
val themeMode: StateFlow<String> = _themeMode.asStateFlow()

private val _sdkMode = MutableStateFlow(
PurchaselySdkMode.fromStorage(
prefs.getString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue)
)
)
val sdkMode: StateFlow<PurchaselySdkMode> = _sdkMode.asStateFlow()

private val _sdkModeChangeAlert = MutableStateFlow<String?>(null)
val sdkModeChangeAlert: StateFlow<String?> = _sdkModeChangeAlert.asStateFlow()

// Data privacy consent toggles (default: true = consent given)
private val _analyticsConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_ANALYTICS, true))
val analyticsConsent: StateFlow<Boolean> = _analyticsConsent.asStateFlow()
Expand All @@ -47,6 +59,9 @@ class SettingsViewModel(
val thirdPartyConsent: StateFlow<Boolean> = _thirdPartyConsent.asStateFlow()

init {
if (!prefs.contains(PurchaselySdkMode.KEY)) {
prefs.edit().putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue).apply()
}
Comment on lines +62 to +64

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This init block ensures that the default SDK mode is set in preferences if it's not already present. This is important for consistent behavior on first launch or after an update.

if (!prefs.contains(PurchaselySdkMode.KEY)) {
            prefs.edit().putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue).apply()
        }

applyConsentPreferences()
}

Expand Down Expand Up @@ -103,6 +118,18 @@ class SettingsViewModel(
Purchasely.setUserAttribute("app_theme", mode)
}

fun setSdkMode(mode: PurchaselySdkMode) {
if (_sdkMode.value == mode) return

_sdkMode.value = mode
prefs.edit().putString(PurchaselySdkMode.KEY, mode.storageValue).apply()
restartPurchaselySdk(mode)
}

fun clearSdkModeChangeAlert() {
_sdkModeChangeAlert.value = null
}

fun setAnalyticsConsent(enabled: Boolean) {
_analyticsConsent.value = enabled
prefs.edit().putBoolean(KEY_CONSENT_ANALYTICS, enabled).apply()
Expand Down Expand Up @@ -133,6 +160,20 @@ class SettingsViewModel(
applyConsentPreferences()
}

private fun restartPurchaselySdk(mode: PurchaselySdkMode) {
val app = context.applicationContext as? ShakerApp
if (app == null) {
Log.e(TAG, "[Shaker] Could not restart SDK: application context is not ShakerApp")
_sdkModeChangeAlert.value = "Mode saved (${mode.label}). Please kill and relaunch the app."
return
}

app.restartPurchaselySdk()
_sdkModeChangeAlert.value =
"Purchasely SDK switched to ${mode.label}. Please kill and relaunch the app."
Log.d(TAG, "[Shaker] SDK mode updated to ${mode.storageValue}")
Comment on lines +163 to +174

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The restartPurchaselySdk function handles the logic for restarting the SDK and displaying an alert to the user. The check for ShakerApp context is a good defensive programming practice. The alert message is clear and informs the user about the necessary action.

private fun restartPurchaselySdk(mode: PurchaselySdkMode) {
        val app = context.applicationContext as? ShakerApp
        if (app == null) {
            Log.e(TAG, "[Shaker] Could not restart SDK: application context is not ShakerApp")
            _sdkModeChangeAlert.value = "Mode saved (${mode.label}). Please kill and relaunch the app."
            return
        }

        app.restartPurchaselySdk()
        _sdkModeChangeAlert.value =
            "Purchasely SDK switched to ${mode.label}. Please kill and relaunch the app."
        Log.d(TAG, "[Shaker] SDK mode updated to ${mode.storageValue}")
    }

}

private fun applyConsentPreferences() {
val revoked = mutableSetOf<PLYDataProcessingPurpose>()
if (!_analyticsConsent.value) revoked.add(PLYDataProcessingPurpose.Analytics)
Expand Down
Loading
Loading