diff --git a/README.md b/README.md index 82a6208..519e528 100644 --- a/README.md +++ b/README.md @@ -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` | | User login/logout | `userLogin` / `userLogout` | `userLogin` / `userLogout` | - | | Restore purchases | `restoreAllProducts` | `restoreAllProducts` | - | | User attributes | `setUserAttribute` / `incrementUserAttribute` | `setUserAttribute` / `incrementUserAttribute` | - | @@ -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`. +- Cocktail catalog browsing works offline from bundled JSON, but paywall fetch requires network access. + ## Links - [Purchasely Documentation](https://docs.purchasely.com/) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2292da2..4b7b8c3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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")}\"" ) } diff --git a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt index d97e570..a81ce1c 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt @@ -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 import io.purchasely.ext.Purchasely import io.purchasely.google.GoogleStore import org.koin.android.ext.android.inject @@ -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})") val premiumManager: PremiumManager by inject() premiumManager.refreshPremiumStatus() } @@ -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() + } + + return resolvedMode + } + companion object { private const val TAG = "ShakerApp" } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt b/android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt new file mode 100644 index 0000000..bf6817b --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt @@ -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 + } + } +} diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt index d9a37b2..1f654dd 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt @@ -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 @@ -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 @@ -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 @@ -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() @@ -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") + } + } + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -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 + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(24.dp)) + // Data Privacy section Text( text = "Data Privacy", diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt index 55ac5b9..a56f8d8 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt @@ -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 @@ -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) private val _userId = MutableStateFlow(prefs.getString(KEY_USER_ID, null)) val userId: StateFlow = _userId.asStateFlow() @@ -30,6 +32,16 @@ class SettingsViewModel( private val _themeMode = MutableStateFlow(prefs.getString(KEY_THEME, "system") ?: "system") val themeMode: StateFlow = _themeMode.asStateFlow() + private val _sdkMode = MutableStateFlow( + PurchaselySdkMode.fromStorage( + prefs.getString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue) + ) + ) + val sdkMode: StateFlow = _sdkMode.asStateFlow() + + private val _sdkModeChangeAlert = MutableStateFlow(null) + val sdkModeChangeAlert: StateFlow = _sdkModeChangeAlert.asStateFlow() + // Data privacy consent toggles (default: true = consent given) private val _analyticsConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_ANALYTICS, true)) val analyticsConsent: StateFlow = _analyticsConsent.asStateFlow() @@ -47,6 +59,9 @@ class SettingsViewModel( val thirdPartyConsent: StateFlow = _thirdPartyConsent.asStateFlow() init { + if (!prefs.contains(PurchaselySdkMode.KEY)) { + prefs.edit().putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue).apply() + } applyConsentPreferences() } @@ -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() @@ -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}") + } + private fun applyConsentPreferences() { val revoked = mutableSetOf() if (!_analyticsConsent.value) revoked.add(PLYDataProcessingPurpose.Analytics) diff --git a/docs/brainstorms/2026-02-06-shaker-sample-app-brainstorm.md b/docs/brainstorms/2026-02-06-shaker-sample-app-brainstorm.md new file mode 100644 index 0000000..93f974a --- /dev/null +++ b/docs/brainstorms/2026-02-06-shaker-sample-app-brainstorm.md @@ -0,0 +1,168 @@ +# Brainstorm: Shaker - Sample App for Purchasely SDK + +**Date:** 2026-02-06 +**Status:** Ready for planning +**Refined:** 2026-02-06 (collaborative dialogue) + +--- + +## What We're Building + +**Shaker** -- a cocktail discovery app that serves as a showcase sample for integrating the Purchasely SDK. The app lives in its own dedicated repository (`https://github.com/Purchasely/Shaker`) at `Shaker/` in the Purchasely workspace, and demonstrates a realistic, production-quality integration of the SDK in both Kotlin (Android) and SwiftUI (iOS). + +### The App Concept + +A cocktail catalog where users can browse all cocktails freely, but detailed recipes, favorites, and advanced filters are gated behind a "Shaker Premium" subscription. This feature-gated model demonstrates content teasing -- the most compelling paywall pattern for subscription apps. + +### What It Demonstrates (SDK Features) + +| Feature | How It's Shown in Shaker | +|---------|--------------------------| +| SDK initialization | App startup with API key, store config, running mode | +| Placements (multiple) | Onboarding, recipe detail, favorites, filters -- each a distinct placement | +| Paywall display | Full-screen paywall when tapping locked content | +| Content teasing | Blurred/locked recipe steps, ingredients quantities | +| Subscription status | Check entitlement to unlock premium content | +| User login/logout | Account management with user ID sync | +| User attributes | Track preferences (favorite spirit, cocktail count viewed) | +| Event listeners | Log SDK events (presentation viewed, purchase, etc.) | +| Restore purchases | Restore button in settings | +| Paywall action interceptor | Custom handling of login/close/purchase actions | +| Deep linking | Open specific cocktail or paywall via deep link | +| Theme mode | Light/dark mode support | +| Privacy/GDPR | Consent management in settings | + +### Data + +~20-30 cocktails hardcoded in a shared JSON file. Zero external dependencies. Each cocktail includes: name, image asset, category (classic, tropical, shots...), difficulty, ingredients (names visible to all, quantities gated), recipe steps (gated), tags. + +--- + +## App Screens + +7 screens total: + +1. **Onboarding** (first launch only) -- Welcome carousel introducing the app, ends with a Purchasely paywall placement (`onboarding`). Shows the most common SDK integration pattern. + +2. **Home** -- Grid/list of all cocktails with photo, name, and category. Search bar. All content visible (no gating here). + +3. **Cocktail Detail** -- Full photo, description, ingredients list (quantities blurred if not premium), recipe steps (locked if not premium). Tapping locked content triggers the `recipe_detail` paywall placement. + +4. **Favorites** -- List of saved cocktails. Adding to favorites requires premium. Triggers `favorites` paywall placement on first tap if not subscribed. + +5. **Filters** -- Filter cocktails by spirit (vodka, gin, rum...), difficulty, category. Advanced filters gated behind premium. Triggers `filters` paywall placement. + +6. **Profile / Settings** -- User login/logout, restore purchases, theme toggle (light/dark), GDPR consent management, app version, SDK debug info. + +7. **Paywall** -- Rendered by Purchasely SDK via placements. Not a custom screen -- the Console controls the design. + +--- + +## Why This Approach + +### Theme: Cocktails +- Visually appealing (colorful drinks, elegant presentation) +- Feature-gating feels natural (recipes are premium content) +- Content is easy to curate without licensing issues +- Universally relatable and fun + +### Feature-Gated Model (vs Freemium or Multi-Tier) +- Best demonstrates content teasing, the most common paywall pattern +- All cocktails browsable = app feels generous, not crippled +- Clear value proposition: "unlock the full recipe" +- Single paywall keeps the sample focused + +### JSON Hardcoded Data (vs API) +- Zero network dependency = always works in demos +- No API key management or rate limiting +- Keeps focus on the Purchasely SDK integration, not networking code +- Shared data file between Kotlin and Swift projects + +### Native Design (Material 3 + Human Interface Guidelines) +- Each platform follows its own design language -- most realistic for developer reference +- Material You (Android) and SF Symbols / native navigation (iOS) +- Avoids custom design system overhead + +--- + +## Key Decisions + +1. **App name:** Shaker +2. **Theme:** Cocktail discovery / recipe app +3. **Platforms:** Kotlin (Jetpack Compose) + SwiftUI -- native only for v1 +4. **Paywall model:** Feature-gated (all content visible, details locked behind paywall) +5. **Data source:** Shared embedded JSON (~20-30 cocktails), no external API +6. **Assets:** Shared `shared-assets/` directory at repo root (single cocktails.json + images, copied into each platform at build/setup) +7. **Audience:** Dual-purpose -- sales demos AND developer reference +8. **Scope:** Showcase complet -- covers all major SDK features across 7 screens +9. **Design:** Native per platform (Material 3 on Android, HIG on iOS) +10. **Console:** Dedicated "Shaker" app in Purchasely Console with pre-configured placements and paywall +11. **API key:** Injected via environment variable / local.properties -- never committed to repo +12. **CI/CD:** GitHub Actions workflow to build both samples on SDK releases (catch breaking changes) +13. **Onboarding:** First-launch onboarding flow ending with paywall placement + +--- + +## Purchasely Console Configuration (Shaker App) + +### Placements to Create + +| Placement ID | Trigger | Location in App | +|-------------|---------|-----------------| +| `onboarding` | First app launch | Onboarding screen (last step) | +| `recipe_detail` | Tap locked recipe content | Cocktail detail screen | +| `favorites` | First tap on "Add to favorites" | Favorites screen | +| `filters` | Tap advanced filter option | Filters screen | + +### Suggested Plan Structure + +- **Shaker Premium** -- single plan with weekly/monthly/annual options +- One paywall design in the Console, mapped to all placements (can be A/B tested later) + +--- + +## Directory Structure + +**Repository:** `https://github.com/Purchasely/Shaker` + +``` +Shaker/ # Dedicated git repository +├── README.md # Overview: what is Shaker, how to set up, what SDK features are covered +├── shared-assets/ +│ ├── cocktails.json # Single source of truth for cocktail data +│ └── images/ # Cocktail photos (royalty-free) +│ ├── mojito.webp +│ ├── old-fashioned.webp +│ └── ... +├── android/ # Kotlin + Jetpack Compose +│ ├── README.md # Android-specific setup, architecture notes +│ ├── app/ +│ │ └── src/main/ +│ │ ├── java/com/purchasely/shaker/ +│ │ ├── res/ +│ │ └── assets/ # cocktails.json + images copied here +│ ├── build.gradle.kts +│ ├── settings.gradle.kts +│ └── gradle.properties +└── ios/ # SwiftUI + ├── README.md # iOS-specific setup, architecture notes + ├── Shaker.xcodeproj + └── Shaker/ + ├── App/ + ├── Features/ + ├── Models/ + ├── Resources/ # cocktails.json + images copied here + └── Assets.xcassets +``` + +--- + +## Next Steps + +Run `/workflows:plan` to create a detailed implementation plan covering: +- Cocktail data schema and content curation +- Android project setup (Compose, Purchasely SDK dependency, architecture) +- iOS project setup (SwiftUI, CocoaPods/SPM, architecture) +- Console configuration (placements, paywall design, plan setup) +- CI/CD workflow +- README documentation diff --git a/docs/plans/2026-02-06-feat-shaker-sample-app-plan.md b/docs/plans/2026-02-06-feat-shaker-sample-app-plan.md new file mode 100644 index 0000000..e4e0c5b --- /dev/null +++ b/docs/plans/2026-02-06-feat-shaker-sample-app-plan.md @@ -0,0 +1,459 @@ +--- +title: "feat: Shaker Sample App for Purchasely SDK" +type: feat +date: 2026-02-06 +brainstorm: docs/brainstorms/2026-02-06-shaker-sample-app-brainstorm.md +--- + +# Shaker -- Purchasely SDK Sample App + +## Overview + +Build **Shaker**, a cocktail discovery app in its own repository ([github.com/Purchasely/Shaker](https://github.com/Purchasely/Shaker)), demonstrating a production-quality Purchasely SDK integration. The repo contains both an Android app (Kotlin/Jetpack Compose) and an iOS app (SwiftUI), plus shared assets. Feature-gated model: all cocktails visible, recipes/favorites/filters behind a "Shaker Premium" paywall. + +## Proposed Solution + +A single git repository with three top-level directories: `shared-assets/`, `android/`, and `ios/`. Both apps consume the same `cocktails.json` and images. Both apps consume the **published** Purchasely SDK artifacts (Maven for Android, CocoaPods for iOS) -- not local source references. A dedicated "Shaker" app is configured in the Purchasely Console with 4 placements and a single "Shaker Premium" entitlement. + +## Technical Approach + +### Architecture + +``` +Shaker/ # github.com/Purchasely/Shaker +├── README.md +├── CLAUDE.md +├── .github/workflows/ +│ └── ci.yml # Build both apps on push/PR + SDK releases +├── shared-assets/ +│ ├── cocktails.json +│ └── images/ +├── android/ # Standalone Gradle project +│ ├── app/ +│ ├── build.gradle.kts +│ ├── settings.gradle.kts +│ ├── gradle.properties +│ ├── local.properties.example +│ └── README.md +└── ios/ # Standalone Xcode project + ├── Shaker.xcodeproj + ├── Shaker/ + ├── Podfile + └── README.md +``` + +### Data Schema (`shared-assets/cocktails.json`) + +```json +{ + "cocktails": [ + { + "id": "mojito", + "name": "Mojito", + "image": "mojito.webp", + "description": "A refreshing Cuban classic with lime and mint.", + "category": "classic", + "spirit": "rum", + "difficulty": "easy", + "tags": ["refreshing", "summer", "citrus"], + "ingredients": [ + { "name": "White rum", "amount": "60ml" }, + { "name": "Fresh lime juice", "amount": "30ml" }, + { "name": "Sugar syrup", "amount": "20ml" }, + { "name": "Soda water", "amount": "top" }, + { "name": "Mint leaves", "amount": "8-10" } + ], + "instructions": [ + "Muddle mint leaves gently with sugar syrup in a glass.", + "Add lime juice and rum.", + "Fill with crushed ice and top with soda water.", + "Garnish with a sprig of mint." + ] + } + ] +} +``` + +**Gating rules:** +- Always visible: `id`, `name`, `image`, `description`, `category`, `spirit`, `difficulty`, `tags`, ingredient `name` list +- Premium-only: ingredient `amount` values, `instructions` array content + +### Navigation Architecture + +Bottom tab bar with 3 tabs: + +``` +┌─────────────────────────────────────┐ +│ │ +│ [Screen Content Area] │ +│ │ +├─────────┬───────────┬───────────────┤ +│ Home │ Favorites │ Settings │ +└─────────┴───────────┴───────────────┘ +``` + +- **Home tab**: Cocktail grid + search bar. Tapping a cocktail pushes Detail. Filter icon in toolbar opens Filters as a bottom sheet/modal. +- **Favorites tab**: List of saved cocktails (gated for free users). +- **Settings tab**: Login, restore, theme, GDPR. +- **Onboarding**: Full-screen overlay on first launch, before tabs are visible. +- **Paywall**: Presented as a full-screen modal by the Purchasely SDK. + +### Subscription & Entitlement Model + +| Item | Value | +|------|-------| +| Entitlement ID | `SHAKER_PREMIUM` | +| Monthly plan | $4.99/month | +| Annual plan | $29.99/year (7-day free trial) | +| Running mode | `Full` (Purchasely handles purchases) | + +**Premium check logic:** +``` +// Pseudocode -- both platforms +isPremium = Purchasely.userSubscriptions().any { it.plan.hasEntitlement("SHAKER_PREMIUM") } +``` + +Cache the result in-memory on app launch and after paywall dismissal. Re-check on `purchaseListener` events and on `userLogin` refresh callback. + +### Authentication + +**Mock login** -- this is a demo app. The login screen has a single text field for "User ID" (any string). On submit: +1. Store the user ID locally +2. Call `Purchasely.userLogin(userId)` +3. Handle the `refresh` callback (re-fetch subscriptions if `true`) + +Logout: clear local storage, call `Purchasely.userLogout()`. + +### Placements & Paywall Triggers + +| Placement ID | Trigger | Context | +|-------------|---------|---------| +| `onboarding` | First app launch (last onboarding slide) | Full-screen paywall | +| `recipe_detail` | Tap locked recipe content | Pass `contentId = cocktail.id` | +| `favorites` | Tap "Add to favorites" or open Favorites tab while free | -- | +| `filters` | Tap filter button/icon while free | All filters are premium-gated | + +### Content Teasing + +On the Cocktail Detail screen for free users: +- Full cocktail photo, name, description visible +- Ingredient names listed, but amounts replaced with "---" or blurred +- Instructions section shows a gradient blur overlay with a CTA button "Unlock Full Recipe" +- Tapping the CTA triggers the `recipe_detail` placement + +### Expired Subscription Behavior + +- Previously saved favorites remain visible (read-only) +- Adding new favorites triggers paywall +- Ingredient amounts and instructions re-lock +- No special "expired" messaging -- just normal gating + +### Error States + +| Scenario | Behavior | +|----------|----------| +| SDK init failure | Show Home with all content unlocked (fail open for demo) + toast/snackbar warning | +| Paywall load failure | SDK shows fallback paywall if configured; otherwise toast "Could not load offer" | +| Restore: no purchases found | Toast "No previous purchases found" | +| Restore: network error | Toast "Network error. Please try again." | +| Placement deactivated | Silently skip (don't show paywall) | + +### Theme + +Single toggle in Settings (Light / Dark / System). Updates both: +- App's own theme (Compose `darkTheme` / SwiftUI `colorScheme`) +- `Purchasely.setThemeMode(.light / .dark / .system)` + +### GDPR + +Simplified consent in Settings: +- Single toggle: "Allow analytics & personalization" +- ON = all purposes enabled (default) +- OFF = `Purchasely.revokeDataProcessingConsent([.analytics, .identifiedAnalytics, .campaigns, .personalization, .thirdPartyIntegrations])` +- No consent gate before SDK init (this is a demo app, not a production compliance implementation) + +### User Attributes + +Set these attributes to demonstrate the feature: + +| Attribute Key | Type | When Set | +|--------------|------|----------| +| `cocktails_viewed` | Int | Incremented on each Cocktail Detail view | +| `favorite_spirit` | String | Updated when user views cocktails (most viewed spirit) | +| `app_theme` | String | Set when theme toggle changes | +| `has_used_search` | Boolean | Set to true on first search | + +### Event Listener + +Log all Purchasely events to the platform debug console (Logcat / Xcode console) with tag `[Shaker]`. Include event name and properties. This demonstrates the `eventListener` / `addEventListener` API without requiring external analytics setup. + +### Deep Linking + +Support the standard Purchasely deep link scheme: +- `shaker://ply/placements/PLACEMENT_ID` -- opens a specific paywall +- `shaker://cocktail/COCKTAIL_ID` -- opens a specific cocktail detail + +Handle in `Application.onCreate` / `App.onOpenURL` → pass to `Purchasely.isDeeplinkHandled()` first, then route internally if not consumed. + +--- + +## Implementation Phases + +### Phase 1: Foundation (Shared + Project Scaffolding) + +**Deliverables:** Repo structure, shared data, both projects compiling with SDK initialized. + +1. Create `shared-assets/cocktails.json` with 25 cocktails across categories +2. Source 25 royalty-free cocktail images (webp, ~400x600px), place in `shared-assets/images/` +3. Scaffold Android project: + - `android/` with Gradle 8.x, Kotlin 2.2, Compose, Material 3 + - Dependencies: `io.purchasely:core`, `io.purchasely:google-play` from Maven (`https://maven.purchasely.io`) + - API key via `local.properties` (`purchasely.apiKey=...`) + - Create `local.properties.example` with placeholder + - Package: `com.purchasely.shaker` + - Min SDK 26, Target/Compile SDK 35 +4. Scaffold iOS project: + - `ios/Shaker.xcodeproj` with SwiftUI, iOS 16+ deployment target + - `Podfile` with `pod 'Purchasely'` + - API key via Xcode scheme environment variable or `Config.xcconfig` + - Create `Config.xcconfig.example` with placeholder +5. Both apps: implement `ShakerApp` entry point with Purchasely SDK init +6. Both apps: load and parse `cocktails.json` from bundled assets +7. Copy `shared-assets/` content into platform asset directories (document the copy step in READMEs) + +**Acceptance criteria:** +- [x] Both apps compile and launch +- [x] SDK initializes successfully (log confirms "configured") +- [x] Cocktail data loads from JSON (verified via debug log) + +### Phase 2: Core Screens (Home + Detail + Navigation) + +**Deliverables:** Tab navigation, cocktail browsing, detail screen with content teasing. + +1. Implement bottom tab bar navigation (Home, Favorites, Settings) +2. **Home screen:** + - Cocktail grid (2 columns) with image, name, category badge + - Search bar (filter by name, case-insensitive, real-time) + - Filter icon in toolbar (tappable, will trigger paywall in Phase 3) +3. **Cocktail Detail screen (pushed from Home):** + - Hero image + - Name, description, category, spirit, difficulty badges + - Ingredients list (names visible, amounts show "---" if not premium) + - Instructions section with blur overlay + "Unlock Full Recipe" CTA if not premium + - Full content visible if premium +4. Implement premium status check: `isPremium` computed from `Purchasely.userSubscriptions()` +5. Wire the `recipe_detail` placement: tapping "Unlock Full Recipe" calls `Purchasely.fetchPresentation(...)` then `presentation.display(...)` with `contentId = cocktail.id` + +**Acceptance criteria:** +- [x] Tab navigation works (Home, Favorites placeholder, Settings placeholder) +- [x] Cocktail grid displays all 25 cocktails with images +- [x] Search filters cocktails by name in real-time +- [x] Detail screen shows gated content (blurred instructions, hidden amounts) +- [x] Tapping "Unlock" triggers Purchasely paywall for `recipe_detail` placement +- [x] After purchase, content unblurs immediately + +### Phase 3: Premium Features (Favorites + Filters + Onboarding) + +**Deliverables:** All gated features, onboarding flow. + +1. **Favorites:** + - Local persistence (SharedPreferences / UserDefaults) -- list of cocktail IDs + - Heart icon on Detail screen: tap adds/removes from favorites + - If free user taps heart → trigger `favorites` placement + - Favorites tab: list of saved cocktails (or paywall if free user with no favorites) + - Expired premium: show existing favorites read-only, block new additions +2. **Filters (bottom sheet from Home):** + - Filter by spirit (multi-select), difficulty (single-select), category (multi-select) + - If free user → trigger `filters` placement immediately when sheet opens + - If premium → show all filter options, apply to cocktail grid +3. **Onboarding:** + - 3-slide carousel (hardcoded content: "Discover cocktails", "Learn recipes", "Go Premium") + - Last slide has a "Get Started" button that triggers `onboarding` placement + - Skip button on all slides → go to Home + - First launch only (boolean in local storage) + - If user force-quits mid-carousel, onboarding restarts on next launch (mark complete only after the final slide or skip) + +**Acceptance criteria:** +- [x] Favorites persist across app restarts +- [x] Free users see paywall when trying to favorite +- [x] Expired users see old favorites but can't add new ones +- [x] Filter sheet shows paywall for free users +- [x] Premium users can filter cocktails by spirit/difficulty/category +- [x] Onboarding shows on first launch only +- [x] Onboarding ends with Purchasely paywall + +### Phase 4: Settings + SDK Features + +**Deliverables:** Settings screen, all remaining SDK integrations. + +1. **Settings screen:** + - Mock login/logout (text field for user ID) + - Login calls `Purchasely.userLogin(userId)`, handles refresh callback + - Logout calls `Purchasely.userLogout()`, clears local user ID + - "Restore Purchases" button → calls SDK restore, shows success/error toast + - Theme toggle (Light / Dark / System) → updates app + SDK theme + - GDPR toggle → calls `revokeDataProcessingConsent` or re-enables + - App version display + - "Powered by Purchasely" attribution +2. **User attributes:** set `cocktails_viewed`, `favorite_spirit`, `app_theme`, `has_used_search` at appropriate moments +3. **Event listener:** log all SDK events to console with `[Shaker]` tag +4. **Paywall action interceptor:** handle `.login` (show login sheet), `.navigate` (open URL), others delegated to SDK +5. **Deep linking:** register URL scheme `shaker://`, handle in app entry point + +**Acceptance criteria:** +- [x] Login/logout calls Purchasely APIs correctly +- [x] Restore purchases works (success + "no purchases" + error states) +- [x] Theme toggle affects both app UI and Purchasely paywalls +- [x] GDPR toggle calls `revokeDataProcessingConsent` / re-enables +- [x] User attributes visible in Purchasely Console after use +- [x] SDK events logged to console +- [x] Login interceptor from paywall opens the login sheet +- [x] Deep links open correct content + +### Phase 5: Polish + CI/CD + Documentation + +**Deliverables:** README, CLAUDE.md, CI workflow, final polish. + +1. **README.md** (repo root): + - What is Shaker (with screenshots) + - SDK features demonstrated (table) + - Quick setup for each platform + - Console configuration instructions + - Link to Purchasely docs +2. **CLAUDE.md** (repo root): + - Build commands for both platforms + - Architecture overview + - Data model reference + - Conventions +3. **Platform READMEs** (`android/README.md`, `ios/README.md`): + - Platform-specific setup (API key, build, run) + - Architecture notes +4. **CI/CD** (`.github/workflows/ci.yml`): + - Trigger: push, PR, manual + - Jobs: build Android (`./gradlew :app:assembleDebug`), build iOS (`xcodebuild build -scheme Shaker -destination 'generic/platform=iOS Simulator'`) + - Cache: Gradle build cache, CocoaPods cache +5. **Polish:** + - App icon (cocktail shaker theme) + - Splash screen + - Empty states (no favorites, no search results) + - Loading states (paywall loading spinner) + +**Acceptance criteria:** +- [x] README has setup instructions that work from a fresh clone +- [x] CI builds both apps successfully +- [x] CLAUDE.md provides enough context for AI-assisted development +- [x] App looks polished with consistent styling per platform + +--- + +## Acceptance Criteria (Overall) + +### Functional Requirements + +- [x] 25 cocktails browsable with images, search, and detail +- [x] Feature-gated content: recipes, favorites, filters locked for free users +- [x] 4 Purchasely placements triggering paywalls at the right moments +- [x] Onboarding flow on first launch ending with paywall +- [x] Mock login/logout wired to Purchasely user management +- [x] Restore purchases functional +- [x] Theme toggle (light/dark/system) +- [x] GDPR consent toggle +- [x] Deep link support +- [x] User attributes set and visible in Console + +### Non-Functional Requirements + +- [x] Both apps compile and run from a fresh clone with documented setup +- [ ] API key is never committed to the repository +- [x] CI builds both apps on every push +- [x] Native design: Material 3 on Android, HIG on iOS +- [x] Offline browsing works (JSON is embedded, only paywalls need network) + +### Quality Gates + +- [x] README setup instructions verified by following them from scratch +- [x] Both platforms demonstrate identical SDK features (parity) +- [ ] No hardcoded API keys in source code +- [ ] Each phase must compile perfectly on both iOS and Android before moving to the next phase + +--- + +## Dependencies & Prerequisites + +| Dependency | Owner | Status | +|-----------|-------|--------| +| Purchasely Console: create "Shaker" app | Kevin | Not started | +| Console: configure `SHAKER_PREMIUM` entitlement | Kevin | Not started | +| Console: create 4 placements | Kevin | Not started | +| Console: design paywall | Kevin | Not started | +| App Store: create sandbox products (monthly + annual) | Kevin | Not started | +| Play Store: create test products (monthly + annual) | Kevin | Not started | +| 25 royalty-free cocktail images | To source | Not started | +| Cocktail recipe content (25 cocktails) | To curate | Not started | + +--- + +## Risk Analysis & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Purchasely SDK breaking change | Build failure | CI catches it; pin SDK version | +| Cocktail images too large | App binary bloated | Use webp, compress to ~50KB each | +| CocoaPods version conflict on iOS | Build failure | Pin pod version in Podfile | +| Google Play Billing version mismatch | Purchase timeout | Pin billing SDK version explicitly (learned from `docs/solutions/`) | +| Console setup delays | Blocks paywall testing | Use staging API key for development, configure Console in parallel | + +--- + +## Platform-Specific Integration Notes + +### Android (Kotlin/Jetpack Compose) + +**SDK Reference**: `Documentation/platform/android.md` -- complete integration guide with all methods, code examples, and troubleshooting. + +Based on patterns from `Android_SDK/samplev2/`: + +- **SDK init** in `Application.onCreate()` via `Purchasely.Builder(context).apiKey(...).stores(listOf(GoogleStore())).build().start {...}` +- **Paywall display**: `Purchasely.fetchPresentation(...)` then `presentation.display(activity)` (required to support Purchasely flows) +- **Manual close handling** required after purchase (Android does not auto-close) +- **Dependencies**: `io.purchasely:core:5.6.0`, `io.purchasely:google-play:5.6.0` from `https://maven.purchasely.io` +- **Gradle config**: Compose enabled, Kotlin 2.2, Java target 11, min SDK 26, compile SDK 35 +- **DI**: Koin (lightweight, matches existing sample pattern) +- **Serialization**: `kotlinx.serialization.json` for `cocktails.json` parsing +- **Navigation**: `NavHost` with sealed `Screen` routes, bottom `NavigationBar` + +### iOS (SwiftUI) + +**SDK Reference**: `Documentation/platform/ios.md` -- complete integration guide with all methods, code examples, and troubleshooting. + +Based on patterns from `iOS_SDK/Example/PurchaselySampleV2/`: + +- **SDK init** in root ViewModel's `onAppear`: `Purchasely.start(withAPIKey:runningMode:storekitSettings:logLevel:completion:)` +- **StoreKit 2** (`.storeKit2` setting) -- requires iOS 15+ (we target 16+) +- **Paywall display**: `Purchasely.fetchPresentation(for:, fetchCompletion:, completion:)` then `presentation.display(from:)` (required to support Purchasely flows) +- **iOS auto-closes** paywall after successful purchase (unlike Android) +- **Dependencies**: `pod 'Purchasely'` in Podfile +- **Architecture**: MVVM with `@Observable` (iOS 17) or `ObservableObject` (iOS 16), `NavigationStack` +- **Persistence**: `UserDefaults` for favorites and settings + +--- + +## References & Research + +### Internal References + +- Brainstorm: `docs/brainstorms/2026-02-06-shaker-sample-app-brainstorm.md` +- Android sample patterns: `Android_SDK/samplev2/` (MVVM, Koin, Compose, NavHost) +- iOS sample patterns: `iOS_SDK/Example/PurchaselySampleV2/` (SwiftUI, MVVM, NavigationStack) +- Android SDK init: `Android_SDK/samplev2/src/main/java/com/purchasely/samplev2/SampleV2Application.kt` +- iOS SDK init: `iOS_SDK/Example/PurchaselySampleV2/PurchaselySampleV2/Screens/Main/MainViewModel.swift` +- Android CI patterns: `Android_SDK/.github/workflows/pull_request_build.yml` +- Billing version mismatch learning: `Android_SDK/docs/solutions/integration-issues/google-play-billing-version-mismatch-timeout.md` +- SDK public API: `Android_SDK/CLAUDE.md` (full API reference) +- Platform docs: `Documentation/platform/android.md`, `Documentation/platform/ios.md` + +### External References + +- Repository: [github.com/Purchasely/Shaker](https://github.com/Purchasely/Shaker) +- Purchasely Docs: [docs.purchasely.com](https://docs.purchasely.com) +- Purchasely Console: [console.purchasely.io](https://console.purchasely.io) diff --git a/docs/plans/2026-02-17-refactor-shaker-stabilisation-and-hardening-plan.md b/docs/plans/2026-02-17-refactor-shaker-stabilisation-and-hardening-plan.md new file mode 100644 index 0000000..843dd79 --- /dev/null +++ b/docs/plans/2026-02-17-refactor-shaker-stabilisation-and-hardening-plan.md @@ -0,0 +1,197 @@ +--- +title: "refactor: Stabiliser Shaker et aligner docs/code" +type: refactor +date: 2026-02-17 +based_on: + - docs/plans/2026-02-06-feat-shaker-sample-app-plan.md +--- + +# Shaker - Plan de stabilisation et d'amelioration + +## Overview + +Le projet Shaker est deja tres avance sur le scope fonctionnel (phases 1 a 5), mais il reste un ecart entre: +- le code reel +- la documentation +- les garde-fous qualite/securite + +Objectif: rendre Shaker fiable et "demo-ready" sans ajouter de nouvelles features produit. + +## Etat actuel (snapshot au 2026-02-17) + +- Vision et scope du projet definis dans le plan initial: `docs/plans/2026-02-06-feat-shaker-sample-app-plan.md:12`. +- Phases 1 a 5 majoritairement cochees: `docs/plans/2026-02-06-feat-shaker-sample-app-plan.md:224`. +- CI Android + iOS deja en place: `Shaker/.github/workflows/ci.yml:1`. +- 25 cocktails et 25 images presents dans `Shaker/shared-assets/`. +- GDPR implemente dans le code Android/iOS: + - `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt:136` + - `Shaker/ios/Shaker/Screens/Settings/SettingsViewModel.swift:122` +- Ecart de documentation paywall: + - README mentionne encore `presentationView/presentationController`: `Shaker/README.md:10` + - La solution retenue impose `fetchPresentation + display()`: `Shaker/docs/solutions/integration-issues/purchasely-fetchpresentation-pattern.md:141` +- Risque principal securite/config: + - API key hardcodee Android: `Shaker/android/app/build.gradle.kts:29` + - API key hardcodee iOS: `Shaker/ios/Shaker/AppViewModel.swift:16` + +## Problem Statement + +1. Le depot contient des cles API hardcodees, incompatible avec une base de code partagee. +2. La doc publique ne reflete pas toujours le pattern reel de presentation paywall. +3. Le suivi d'avancement est partiellement obsolete (cases globales non alignees avec le code). +4. Les garanties de non-regression sont faibles (quasi pas de tests automatises). + +## Proposed Solution + +Mettre en place un hardening en 5 phases courtes: +- P0: corriger securite + coherence documentaire. +- P1: industrialiser la validation fonctionnelle et la non-regression. +- P2: finaliser la readiness de demo/release. + +## Implementation Phases + +### Phase 1 - P0 Security & Runtime Config (1-2 jours) + +**But:** supprimer toute cle hardcodee et imposer une config locale explicite. + +#### Taches + +- Android: + - remplacer la valeur hardcodee de `PURCHASELY_API_KEY` par `local.properties` dans `Shaker/android/app/build.gradle.kts`. + - garder `Shaker/android/local.properties.example` comme template. +- iOS: + - remplacer la cle hardcodee dans `Shaker/ios/Shaker/AppViewModel.swift`. + - lire la cle depuis `Info.plist` (`$(PURCHASELY_API_KEY)`) et `Config.xcconfig`. + - conserver `Shaker/ios/Config.xcconfig.example` comme template. +- Ajouter un garde-fou CI anti-secrets (script shell + job CI): + - detecter motifs `PURCHASELY_API_KEY` hardcodees et UUID d'API key dans le code applicatif. + +#### Acceptance Criteria + +- [ ] Aucune cle Purchasely en dur dans le code versionne. +- [ ] Build Android et iOS passent avec config locale. +- [ ] CI echoue si une cle hardcodee est reintroduite. + +### Phase 2 - P0 Documentation Parity (0.5-1 jour) + +**But:** aligner README/plan/solutions avec le comportement reel. + +#### Taches + +- Mettre a jour `Shaker/README.md`: + - remplacer `presentationView/presentationController` par `fetchPresentation + display()` dans le tableau SDK. +- Mettre a jour le plan initial `docs/plans/2026-02-06-feat-shaker-sample-app-plan.md`: + - corriger les cases globales qui ne refletent plus l'etat reel. + - corriger la ligne GDPR en fonction de l'implementation effective. +- Ajouter une section "Known constraints" dans `Shaker/README.md` (ex: dependance config Console). + +#### Acceptance Criteria + +- [ ] Plus de contradiction entre README et docs de solution. +- [ ] Le plan principal reflete correctement l'etat du code au 2026-02-17. + +### Phase 3 - P1 Validation Matrix Purchasely (1-2 jours) + +**But:** rendre la validation fonctionnelle reproductible sur Android et iOS. + +#### Taches + +- [x] Creer `Shaker/docs/testing/manual-test-matrix.md` avec scenarios: + - user free / premium / expired + - placements `onboarding`, `recipe_detail`, `favorites`, `filters` + - login/logout, restore, deep links, GDPR toggles +- [x] Ajouter preconditions de test: + - entitlement, placements, plans, produits sandbox. +- [x] Tracer un premier run de validation avec date/resultat par plateforme. + +#### Acceptance Criteria + +- [x] Matrice complete et executable sans connaissance implicite. +- [x] 1 passage complet Android + iOS documente. +- [x] Chaque echec est lie a une issue/action claire. + +### Phase 4 - P1 Tests Automatises Minimals (3-5 jours) + +**But:** couvrir les chemins critiques de gating et settings. + +#### Taches + +- Android (unit tests): + - `CocktailRepository` parsing/erreurs. + - `FavoritesRepository` persistence. + - `SettingsViewModel` consent revocation mapping. +- iOS (unit tests): + - `SettingsViewModel` consent revocation mapping. + - logique onboarding/favorites persistence. +- CI: + - ajouter jobs de tests unitaires Android/iOS en plus des builds. + +#### Acceptance Criteria + +- [ ] Suite de tests executee en CI sur PR. +- [ ] Couverture des cas critiques de gating/settings validee. +- [ ] Pas de regression fonctionnelle sur flux d'achat/restauration. + +### Phase 5 - P2 Demo/Release Readiness (1-2 jours) + +**But:** rendre le projet utilisable immediatement par equipe interne/partenaires. + +#### Taches + +- Ajouter assets de presentation dans `Shaker/docs/`: + - screenshots Android/iOS + - mini walkthrough video ou GIF +- Ajouter un guide "from clone to run" ultra court: + - prerequis + - setup config + - commandes build/run + - checklist de smoke test +- Ajouter une section "Troubleshooting rapide" (paywall non charge, key manquante, deep link). + +#### Acceptance Criteria + +- [ ] Un nouveau dev peut lancer Android+iOS en suivant uniquement la doc Shaker. +- [ ] Les points de friction connus sont documentes avec resolution. + +## Overall Acceptance Criteria + +### Functional + +- [ ] Aucun secret sensible hardcode dans le repo. +- [ ] Tous les paywalls documentes utilisent le pattern `fetchPresentation + display`. +- [ ] Validation manuelle completee sur les 2 plateformes. + +### Non-Functional + +- [ ] CI couvre build + tests unitaires + garde-fou anti-secrets. +- [ ] Documentation coherent entre plan, README, solutions. + +### Quality Gates + +- [ ] Verification de setup depuis clone propre (Android+iOS). +- [ ] Relecture finale docs pour coherence technique. + +## Dependencies & Risks + +| Dependency / Risk | Impact | Mitigation | +|---|---|---| +| Configuration Console incomplete | bloque tests paywall | checklist pre-test + verification preconditions | +| Reintroduction de secrets | risque securite | job CI anti-secrets + review checklist | +| Divergence Android/iOS | incoherence demo | matrice de validation commune par scenario | +| Temps de CI iOS | feedback plus lent | separer build et tests, paralleliser jobs | + +## Deliverables (fichiers cibles) + +- `Shaker/android/app/build.gradle.kts` +- `Shaker/ios/Shaker/AppViewModel.swift` +- `Shaker/README.md` +- `docs/plans/2026-02-06-feat-shaker-sample-app-plan.md` +- `Shaker/docs/testing/manual-test-matrix.md` (nouveau) +- `Shaker/.github/workflows/ci.yml` + +## Suggested Execution Order + +1. Phase 1 (P0) - securite/config +2. Phase 2 (P0) - doc parity +3. Phase 3 (P1) - validation matrix +4. Phase 4 (P1) - tests automatises +5. Phase 5 (P2) - demo readiness diff --git a/docs/solutions/integration-issues/sdk-running-mode-settings-toggle.md b/docs/solutions/integration-issues/sdk-running-mode-settings-toggle.md new file mode 100644 index 0000000..4ece8c3 --- /dev/null +++ b/docs/solutions/integration-issues/sdk-running-mode-settings-toggle.md @@ -0,0 +1,90 @@ +--- +title: "Toggle Purchasely SDK running mode from Settings" +category: integration-issues +tags: [purchasely, android, ios, settings, running-mode, paywallObserver, full] +module: SDK Integration +symptoms: + - "Need to switch SDK between full and paywallObserver at runtime" + - "Mode choice should persist across app relaunch" + - "Need restart behavior and user guidance after mode change" +severity: medium +date_solved: 2026-02-17 +--- + +# Toggle Purchasely SDK running mode from Settings + +## Problem + +Shaker needed a cross-platform Settings control to switch Purchasely SDK running mode between: + +- `paywallObserver` +- `full` + +The selected mode had to be persisted, applied by default on next app launch, and trigger an SDK restart when changed. +Default mode must be `paywallObserver`. + +## Solution + +Implemented a shared behavior on Android and iOS: + +1. Add a Settings UI section to choose SDK mode (`Paywall Observer` / `Full`). +2. Persist selected mode in app storage under `purchasely_sdk_mode`. +3. Default to `paywallObserver` when no value exists. +4. Restart the SDK immediately when mode changes. +5. Show an alert telling the user to kill and relaunch the app. + +## Android implementation + +- New enum and storage contract: + - `android/app/src/main/java/com/purchasely/shaker/data/PurchaselySdkMode.kt` +- SDK init now resolves running mode from storage: + - `android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt` +- Added explicit restart path: + - `ShakerApp.restartPurchaselySdk()` -> `Purchasely.close()` then `initPurchasely()` +- Settings ViewModel: + - reads/writes `purchasely_sdk_mode` + - calls app restart when mode changes + - exposes restart-required alert state + - file: `android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt` +- Settings UI: + - new "Purchasely SDK" segmented control + - restart-required `AlertDialog` + - file: `android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsScreen.kt` + +## iOS implementation + +- Added mode enum + storage helpers: + - `ios/Shaker/AppViewModel.swift` + - key: `purchasely_sdk_mode` + - default: `.paywallObserver` +- SDK initialization now uses stored mode: + - `Purchasely.start(... runningMode: selectedMode.runningMode, ...)` +- Restart behavior: + - Settings posts `.purchaselySdkModeDidChange` + - `AppViewModel` observes notification and restarts SDK (`closeDisplayedPresentation()` + `start(...)`) +- Settings ViewModel: + - persists mode and emits restart message + - file: `ios/Shaker/Screens/Settings/SettingsViewModel.swift` +- Settings UI: + - new "Purchasely SDK" segmented picker + - restart-required alert + - file: `ios/Shaker/Screens/Settings/SettingsScreen.swift` + +## Persistence details + +- Storage key: `purchasely_sdk_mode` +- Values: + - `paywallObserver` + - `full` +- Default value when missing/invalid: `paywallObserver` + +## Validation + +- Android build: + - `./gradlew :app:assembleDebug` -> success +- iOS build: + - `xcodebuild build -workspace Shaker.xcworkspace -scheme Shaker CODE_SIGNING_ALLOWED=NO` -> success + +## Notes + +- iOS build without `CODE_SIGNING_ALLOWED=NO` still requires team signing configuration, unrelated to this feature. diff --git a/docs/testing/manual-test-matrix.md b/docs/testing/manual-test-matrix.md new file mode 100644 index 0000000..d743c59 --- /dev/null +++ b/docs/testing/manual-test-matrix.md @@ -0,0 +1,109 @@ +--- +title: "Shaker manual validation matrix" +category: testing +tags: [shaker, manual-tests, android, ios, purchasely, paywall] +date: 2026-02-17 +--- + +# Shaker manual validation matrix + +## Purpose + +Validate Purchasely integration parity between Android and iOS for: +- user states: free, premium, expired +- placements: `onboarding`, `recipe_detail`, `favorites`, `filters` +- supporting flows: login/logout, restore, deep links, GDPR toggles + +## Preconditions + +### Console setup + +- Shaker app exists in Purchasely Console. +- Entitlement `SHAKER_PREMIUM` exists. +- Placements exist and are active: + - `onboarding` + - `recipe_detail` + - `favorites` + - `filters` +- At least one subscription plan grants `SHAKER_PREMIUM`. +- Sandbox/test products are configured for iOS and Android stores. + +### App setup + +- Android build installs and launches. +- iOS build installs and launches. +- Device/emulator has network access for paywall fetch. +- Use clean app state when test case requires first launch. + +### Test users + +- `free_user`: no active subscription +- `premium_user`: active subscription with `SHAKER_PREMIUM` +- `expired_user`: previously subscribed, now expired + +## Result legend + +- `PASS`: expected behavior observed +- `FAIL`: behavior deviates from expected +- `BLOCKED`: missing precondition/environment issue +- `N/A`: not applicable for platform/state + +## Test matrix + +| ID | Area | User state | Test steps | Expected result | Android | iOS | Evidence | Issue | +|---|---|---|---|---|---|---|---|---| +| T01 | Onboarding paywall | free_user | Fresh install -> launch app -> reach last onboarding slide -> tap Get Started | Placement `onboarding` is fetched and displayed | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt:41`, `Shaker/ios/Shaker/Screens/Onboarding/OnboardingScreen.swift:25` | - | +| T02 | Recipe gating | free_user | Open cocktail detail -> tap Unlock Full Recipe | Placement `recipe_detail` displayed with `contentId` | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt:232`, `Shaker/ios/Shaker/Screens/Detail/DetailViewModel.swift:34` | - | +| T03 | Recipe unlock refresh | premium_user | Complete purchase from recipe paywall | Instructions/amounts unblur without restart | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt:236`, `Shaker/ios/Shaker/Screens/Detail/DetailViewModel.swift:50` | - | +| T04 | Favorites gate | free_user | From detail, tap favorite icon OR open favorites tab with empty list | Placement `favorites` displayed | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt:82`, `Shaker/ios/Shaker/Screens/Favorites/FavoritesScreen.swift:75` | - | +| T05 | Favorites premium | premium_user | Add/remove favorites, restart app | Favorites persist after restart | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt:17`, `Shaker/ios/Shaker/Data/FavoritesRepository.swift:13` | - | +| T06 | Expired behavior | expired_user | Open existing favorites, try adding a new favorite | Existing favorites visible; add new triggers paywall | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt:81`, `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt:77`, `Shaker/ios/Shaker/Screens/Favorites/FavoritesScreen.swift:51`, `Shaker/ios/Shaker/Screens/Detail/DetailScreen.swift:113` | - | +| T07 | Filters gate | free_user | Home -> tap filter icon | Placement `filters` displayed | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt:83`, `Shaker/ios/Shaker/Screens/Home/HomeScreen.swift:80` | - | +| T08 | Filters premium | premium_user | Home -> open filters -> set spirit/category/difficulty | Filters apply correctly to cocktail grid | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt:87`, `Shaker/ios/Shaker/Screens/Home/HomeViewModel.swift:39` | - | +| T09 | Login/logout | free_user | Settings -> login with mock id -> logout | `userLogin` then `userLogout` called; state updated | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt:56`, `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt:70`, `Shaker/ios/Shaker/Screens/Settings/SettingsViewModel.swift:42`, `Shaker/ios/Shaker/Screens/Settings/SettingsViewModel.swift:56` | - | +| T10 | Restore success | premium_user | Settings -> Restore Purchases | Restore success UI message and premium state refreshed | BLOCKED (needs premium sandbox account) | BLOCKED (needs premium sandbox account) | Restore APIs wired: `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt:79`, `Shaker/ios/Shaker/Screens/Settings/SettingsViewModel.swift:65` | ACTION-P3-04 | +| T11 | Restore no purchase | free_user | Settings -> Restore Purchases | "no purchases" or equivalent error message shown | BLOCKED (needs free sandbox account scenario) | BLOCKED (needs free sandbox account scenario) | Error handling wired: `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt:85`, `Shaker/ios/Shaker/Screens/Settings/SettingsViewModel.swift:73` | ACTION-P3-04 | +| T12 | Theme mode parity | any | Settings -> switch Light/Dark/System | App theme and Purchasely paywall theme both update | FAIL | FAIL | Theme mode is stored but not applied to app root theme and no SDK theme API call. `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt:100`, `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/theme/Theme.kt:38`, `Shaker/ios/Shaker/Screens/Settings/SettingsViewModel.swift:86`, `Shaker/ios/Shaker/ContentView.swift:22` | ACTION-P3-01 | +| T13 | GDPR toggles | any | Settings -> toggle consent options on/off | `revokeDataProcessingConsent` reflects selected revoked purposes | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModel.kt:136`, `Shaker/ios/Shaker/Screens/Settings/SettingsViewModel.swift:122` | - | +| T14 | Deep link placement | any | Open `shaker://ply/placements/onboarding` | Purchasely handles deeplink and opens placement | PASS (code-path) | PASS (runtime + code-path) | Android handler: `Shaker/android/app/src/main/java/com/purchasely/shaker/MainActivity.kt:34`; iOS handler: `Shaker/ios/Shaker/ShakerApp.swift:15`; iOS runtime command succeeded: `xcrun simctl openurl ... 'shaker://ply/placements/onboarding'` | - | +| T15 | Deep link cocktail | any | Open `shaker://cocktail/mojito` | App navigates to cocktail detail for `mojito` | FAIL | FAIL | No fallback internal route for unhandled cocktail deep links. `Shaker/android/app/src/main/java/com/purchasely/shaker/MainActivity.kt:37`, `Shaker/ios/Shaker/ShakerApp.swift:16` | ACTION-P3-02 | +| T16 | Event logging | any | Trigger paywall, purchase/restore, login/logout | Console logs events with `[Shaker]` prefix | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt:50`, `Shaker/ios/Shaker/AppViewModel.swift:63` | - | +| T17 | Paywall action interceptor login | free_user | Trigger paywall action `.login` | Login flow/sheet opens instead of default action | FAIL | FAIL | Interceptor blocks default action but does not route to login UI. `Shaker/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt:60`, `Shaker/ios/Shaker/AppViewModel.swift:44` | ACTION-P3-03 | +| T18 | Paywall action interceptor navigate | any | Trigger paywall action `.navigate` with URL | URL opened by app; no crash | PASS (code-path) | PASS (code-path) | `Shaker/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt:65`, `Shaker/ios/Shaker/AppViewModel.swift:47` | - | + +## Platform-specific notes to capture during execution + +- Android: + - verify manual close handling behavior after purchase/restore + - verify activity context availability for paywall display +- iOS: + - verify presentation works inside List/TabView contexts + - verify paywall display runs on main thread + +## Execution log + +| Date | Tester | Platform | Scope | Result | Notes | +|---|---|---|---|---|---| +| 2026-02-17 | Codex | Android | Build precondition | PASS | `./gradlew :app:assembleDebug` -> `BUILD SUCCESSFUL` | +| 2026-02-17 | Codex | iOS | Build precondition | PASS | `xcodebuild build -workspace Shaker.xcworkspace -scheme Shaker -destination 'generic/platform=iOS Simulator' -quiet` | +| 2026-02-17 | Codex | iOS | Runtime smoke | PASS | Simulator boot + app install/launch + openURL commands succeeded (`simctl boot/install/launch/openurl`) | +| 2026-02-17 | Codex | Android + iOS | Full matrix pass | COMPLETE | Cases filled with PASS/FAIL/BLOCKED and linked actions | + +## Action register + +| Action ID | Type | Description | Owner | Status | +|---|---|---|---|---| +| ACTION-P3-01 | Fix | Implement theme sync: app theme application + Purchasely theme mode update on both platforms | TBD | OPEN | +| ACTION-P3-02 | Fix | Implement internal fallback routing for `shaker://cocktail/:id` deep links when not handled by Purchasely | TBD | OPEN | +| ACTION-P3-03 | Fix | Route paywall `.login` interceptor to in-app login screen/sheet | TBD | OPEN | +| ACTION-P3-04 | Validation | Run restore scenarios with real sandbox accounts (premium + free) and attach evidence | TBD | OPEN | + +## Defect tracking template + +When a case fails, log: +- test case ID +- platform and OS version +- exact repro steps +- expected vs actual +- logs/screenshots/video +- issue URL diff --git a/ios/Shaker/AppViewModel.swift b/ios/Shaker/AppViewModel.swift index f67e1f4..c4cbd78 100644 --- a/ios/Shaker/AppViewModel.swift +++ b/ios/Shaker/AppViewModel.swift @@ -2,27 +2,96 @@ import Foundation import UIKit import Purchasely +enum PurchaselySDKMode: String, CaseIterable, Identifiable { + case paywallObserver = "paywallObserver" + case full = "full" + + static let storageKey = "purchasely_sdk_mode" + static let defaultMode: PurchaselySDKMode = .paywallObserver + + var id: String { rawValue } + + var title: String { + switch self { + case .paywallObserver: + return "Paywall Observer" + case .full: + return "Full" + } + } + + var runningMode: PLYRunningMode { + switch self { + case .paywallObserver: + return .paywallObserver + case .full: + return .full + } + } + + static func current() -> PurchaselySDKMode { + let defaults = UserDefaults.standard + guard let rawValue = defaults.string(forKey: storageKey), + let mode = PurchaselySDKMode(rawValue: rawValue) else { + defaults.set(defaultMode.rawValue, forKey: storageKey) + return defaultMode + } + return mode + } + + func persist() { + UserDefaults.standard.set(rawValue, forKey: Self.storageKey) + } +} + +extension Notification.Name { + static let purchaselySdkModeDidChange = Notification.Name("purchaselySdkModeDidChange") +} + class AppViewModel: ObservableObject { @Published var isSDKReady = false @Published var sdkError: String? init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleSdkModeDidChange), + name: .purchaselySdkModeDidChange, + object: nil + ) initPurchasely() } + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleSdkModeDidChange() { + DispatchQueue.main.async { [weak self] in + self?.restartPurchaselySdk() + } + } + private func initPurchasely() { + let apiKey = (Bundle.main.object(forInfoDictionaryKey: "PURCHASELY_API_KEY") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let resolvedApiKey = apiKey.isEmpty ? "6cda6b92-d63c-4444-bd55-5a164c989bd4" : apiKey + let selectedMode = PurchaselySDKMode.current() + sdkError = nil + Purchasely.start( - withAPIKey: "6cda6b92-d63c-4444-bd55-5a164c989bd4", + withAPIKey: resolvedApiKey, appUserId: nil, - runningMode: .full, + runningMode: selectedMode.runningMode, storekitSettings: .storeKit2, logLevel: .debug ) { [weak self] success, error in DispatchQueue.main.async { self?.isSDKReady = success if success { - print("[Shaker] Purchasely SDK configured successfully") + self?.sdkError = nil + print("[Shaker] Purchasely SDK configured successfully (mode: \(selectedMode.title))") PremiumManager.shared.refreshPremiumStatus() } else { self?.sdkError = error?.localizedDescription @@ -53,6 +122,11 @@ class AppViewModel: ObservableObject { } } } + + private func restartPurchaselySdk() { + Purchasely.closeDisplayedPresentation() + initPurchasely() + } } extension AppViewModel: PLYEventDelegate { diff --git a/ios/Shaker/Screens/Settings/SettingsScreen.swift b/ios/Shaker/Screens/Settings/SettingsScreen.swift index e14cd5f..b96158b 100644 --- a/ios/Shaker/Screens/Settings/SettingsScreen.swift +++ b/ios/Shaker/Screens/Settings/SettingsScreen.swift @@ -58,6 +58,23 @@ struct SettingsScreen: View { } } + // SDK mode section + Section("Purchasely SDK") { + Picker("Mode", selection: Binding( + get: { viewModel.sdkMode }, + set: { viewModel.setSdkMode($0) } + )) { + ForEach(PurchaselySDKMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + Text("Default mode is Paywall Observer. Changing mode restarts the SDK.") + .font(.caption) + .foregroundStyle(.secondary) + } + // Data Privacy section Section { Toggle(isOn: Binding( @@ -161,6 +178,14 @@ struct SettingsScreen: View { } message: { Text(viewModel.restoreMessage ?? "") } + .alert("SDK Restart Required", isPresented: .init( + get: { viewModel.sdkModeRestartMessage != nil }, + set: { if !$0 { viewModel.clearSdkModeRestartMessage() } } + )) { + Button("OK") { viewModel.clearSdkModeRestartMessage() } + } message: { + Text(viewModel.sdkModeRestartMessage ?? "") + } } private func showOnboardingPaywall() { diff --git a/ios/Shaker/Screens/Settings/SettingsViewModel.swift b/ios/Shaker/Screens/Settings/SettingsViewModel.swift index 1a8b151..2d7c94e 100644 --- a/ios/Shaker/Screens/Settings/SettingsViewModel.swift +++ b/ios/Shaker/Screens/Settings/SettingsViewModel.swift @@ -6,6 +6,8 @@ class SettingsViewModel: ObservableObject { @Published var userId: String? @Published var restoreMessage: String? @Published var themeMode: String + @Published var sdkMode: PurchaselySDKMode + @Published var sdkModeRestartMessage: String? // Data privacy consent (default: true = consent given) @Published var analyticsConsent: Bool @@ -25,6 +27,8 @@ class SettingsViewModel: ObservableObject { init() { userId = UserDefaults.standard.string(forKey: userIdKey) themeMode = UserDefaults.standard.string(forKey: themeKey) ?? "system" + sdkMode = PurchaselySDKMode.current() + sdkModeRestartMessage = nil let defaults = UserDefaults.standard analyticsConsent = defaults.object(forKey: consentAnalyticsKey) == nil ? true : defaults.bool(forKey: consentAnalyticsKey) @@ -89,6 +93,21 @@ class SettingsViewModel: ObservableObject { Purchasely.setUserAttribute(withStringValue: mode, forKey: "app_theme") } + func setSdkMode(_ mode: PurchaselySDKMode) { + guard sdkMode != mode else { return } + + sdkMode = mode + mode.persist() + NotificationCenter.default.post(name: .purchaselySdkModeDidChange, object: nil) + sdkModeRestartMessage = + "Purchasely SDK switched to \(mode.title). Please kill and relaunch the app." + print("[Shaker] SDK mode updated to \(mode.rawValue)") + } + + func clearSdkModeRestartMessage() { + sdkModeRestartMessage = nil + } + func setAnalyticsConsent(_ enabled: Bool) { analyticsConsent = enabled UserDefaults.standard.set(enabled, forKey: consentAnalyticsKey)