From 9c6fd9dad6fec18af7b5e3ab223c7c6a988c14ad Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 10:23:29 +0200 Subject: [PATCH 01/15] refactor(android): wrap PLYPresentation in opaque PresentationHandle Introduce PresentationHandle inline value class to decouple ViewModels from io.purchasely.ext.PLYPresentation. Update FetchResult to use PresentationHandle and extract height at creation time, replace PLYError with String? in FetchResult.Error. All 4 ViewModels and OnboardingScreen now reference PresentationHandle instead of PLYPresentation directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/purchasely/EmbeddedScreenBanner.kt | 2 +- .../shaker/purchasely/FetchResult.kt | 11 +++-------- .../shaker/purchasely/PresentationHandle.kt | 8 ++++++++ .../shaker/purchasely/PurchaselyWrapper.kt | 16 ++++++++-------- .../shaker/ui/screen/detail/DetailViewModel.kt | 18 +++++++++--------- .../ui/screen/favorites/FavoritesViewModel.kt | 12 ++++++------ .../shaker/ui/screen/home/HomeViewModel.kt | 10 +++++----- .../ui/screen/onboarding/OnboardingScreen.kt | 4 ++-- .../ui/screen/settings/SettingsViewModel.kt | 12 ++++++------ .../shaker/purchasely/PurchaselyWrapperTest.kt | 10 +++++----- 10 files changed, 53 insertions(+), 50 deletions(-) create mode 100644 android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/EmbeddedScreenBanner.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/EmbeddedScreenBanner.kt index af09f6c..4741e3e 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/EmbeddedScreenBanner.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/EmbeddedScreenBanner.kt @@ -17,7 +17,7 @@ fun EmbeddedScreenBanner( AndroidView( factory = { context -> wrapper.getView( - presentation = fetchResult.presentation, + handle = fetchResult.handle, context = context, onResult = onResult ) ?: FrameLayout(context) diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/FetchResult.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/FetchResult.kt index 76d204f..8b62dd7 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/FetchResult.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/FetchResult.kt @@ -1,13 +1,8 @@ package com.purchasely.shaker.purchasely -import io.purchasely.ext.PLYPresentation -import io.purchasely.models.PLYError - sealed class FetchResult { - data class Success(val presentation: PLYPresentation) : FetchResult() { - val height: Int get() = presentation.height - } - data class Client(val presentation: PLYPresentation) : FetchResult() + data class Success(val handle: PresentationHandle, val height: Int) : FetchResult() + data class Client(val handle: PresentationHandle) : FetchResult() data object Deactivated : FetchResult() - data class Error(val error: PLYError?) : FetchResult() + data class Error(val message: String?) : FetchResult() } diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt new file mode 100644 index 0000000..f06e2e7 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/PresentationHandle.kt @@ -0,0 +1,8 @@ +package com.purchasely.shaker.purchasely + +import io.purchasely.ext.PLYPresentation + +@JvmInline +value class PresentationHandle internal constructor( + internal val presentation: PLYPresentation +) diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt index 6b9f299..65a03d2 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt @@ -14,7 +14,6 @@ import com.purchasely.shaker.data.purchase.RestoreRequest import com.purchasely.shaker.data.purchase.TransactionResult import io.purchasely.ext.EventListener import io.purchasely.ext.LogLevel -import io.purchasely.ext.PLYPresentation import io.purchasely.ext.PLYPresentationAction import io.purchasely.ext.PLYPresentationActionParameters import io.purchasely.ext.PLYPresentationInfo @@ -241,23 +240,24 @@ class PurchaselyWrapper( } else { Purchasely.fetchPresentation(placementId = placementId) } + val handle = PresentationHandle(presentation) when (presentation.type) { PLYPresentationType.DEACTIVATED -> FetchResult.Deactivated - PLYPresentationType.CLIENT -> FetchResult.Client(presentation) - else -> FetchResult.Success(presentation) + PLYPresentationType.CLIENT -> FetchResult.Client(handle) + else -> FetchResult.Success(handle, presentation.height) } } catch (e: Exception) { - FetchResult.Error(e as? PLYError) + FetchResult.Error(e.message) } } // MARK: - Modal Display suspend fun display( - presentation: PLYPresentation, + handle: PresentationHandle, activity: Activity ): DisplayResult = suspendCoroutine { continuation -> - presentation.display(activity) { result: PLYProductViewResult, plan: PLYPlan? -> + handle.presentation.display(activity) { result: PLYProductViewResult, plan: PLYPlan? -> when (result) { PLYProductViewResult.PURCHASED -> continuation.resume(DisplayResult.Purchased(plan?.name)) PLYProductViewResult.RESTORED -> continuation.resume(DisplayResult.Restored(plan?.name)) @@ -269,11 +269,11 @@ class PurchaselyWrapper( // MARK: - Embedded View fun getView( - presentation: PLYPresentation, + handle: PresentationHandle, context: Context, onResult: (DisplayResult) -> Unit ): View? { - return presentation.buildView( + return handle.presentation.buildView( context = context, callback = { result: PLYProductViewResult, plan: PLYPlan? -> when (result) { diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt index 1d1a0c0..1b3b75c 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt @@ -10,8 +10,8 @@ import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper -import io.purchasely.ext.PLYPresentation import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -36,12 +36,12 @@ class DetailViewModel( val favoriteIds: StateFlow> = favoritesRepository.favoriteIds // Signal Screen to display recipe paywall - private var pendingRecipePresentation: PLYPresentation? = null + private var pendingRecipePresentation: PresentationHandle? = null private val _requestRecipePaywall = MutableSharedFlow() val requestRecipePaywall: SharedFlow = _requestRecipePaywall.asSharedFlow() // Signal Screen to display favorites paywall - private var pendingFavoritesPresentation: PLYPresentation? = null + private var pendingFavoritesPresentation: PresentationHandle? = null private val _requestFavoritesPaywall = MutableSharedFlow() val requestFavoritesPaywall: SharedFlow = _requestFavoritesPaywall.asSharedFlow() @@ -76,7 +76,7 @@ class DetailViewModel( ) when (result) { is FetchResult.Success -> { - pendingRecipePresentation = result.presentation + pendingRecipePresentation = result.handle _requestRecipePaywall.emit(Unit) } is FetchResult.Client -> { @@ -88,9 +88,9 @@ class DetailViewModel( } suspend fun displayPendingRecipePaywall(activity: Activity) { - val presentation = pendingRecipePresentation ?: return + val handle = pendingRecipePresentation ?: return pendingRecipePresentation = null - val result = purchaselyWrapper.display(presentation, activity) + val result = purchaselyWrapper.display(handle, activity) when (result) { is DisplayResult.Purchased -> { Log.d("DetailViewModel", "[Shaker] Purchased: ${result.planName}") @@ -111,7 +111,7 @@ class DetailViewModel( val result = purchaselyWrapper.loadPresentation(placementId = "favorites") when (result) { is FetchResult.Success -> { - pendingFavoritesPresentation = result.presentation + pendingFavoritesPresentation = result.handle _requestFavoritesPaywall.emit(Unit) } is FetchResult.Client -> { @@ -123,9 +123,9 @@ class DetailViewModel( } suspend fun displayPendingFavoritesPaywall(activity: Activity) { - val presentation = pendingFavoritesPresentation ?: return + val handle = pendingFavoritesPresentation ?: return pendingFavoritesPresentation = null - val result = purchaselyWrapper.display(presentation, activity) + val result = purchaselyWrapper.display(handle, activity) when (result) { is DisplayResult.Purchased, is DisplayResult.Restored -> { Log.d("DetailViewModel", "[Shaker] Purchased/Restored from favorites: ${(result as? DisplayResult.Purchased)?.planName ?: (result as? DisplayResult.Restored)?.planName}") diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt index b3c53d3..5651a7e 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt @@ -10,8 +10,8 @@ import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper -import io.purchasely.ext.PLYPresentation import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -30,7 +30,7 @@ class FavoritesViewModel( val isPremium: StateFlow = premiumManager.isPremium // Signal Screen to display favorites paywall - private var pendingPresentation: PLYPresentation? = null + private var pendingPresentation: PresentationHandle? = null private val _requestPaywallDisplay = MutableSharedFlow() val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() @@ -47,7 +47,7 @@ class FavoritesViewModel( viewModelScope.launch { when (val result = purchaselyWrapper.loadPresentation("favorites")) { is FetchResult.Success -> { - pendingPresentation = result.presentation + pendingPresentation = result.handle _requestPaywallDisplay.emit(Unit) } is FetchResult.Client -> { @@ -57,16 +57,16 @@ class FavoritesViewModel( Log.d(TAG, "[Shaker] Favorites placement is deactivated") } is FetchResult.Error -> { - Log.e(TAG, "[Shaker] Error fetching favorites: ${result.error?.message}") + Log.e(TAG, "[Shaker] Error fetching favorites: ${result.message}") } } } } suspend fun displayPendingPaywall(activity: Activity) { - val presentation = pendingPresentation ?: return + val handle = pendingPresentation ?: return pendingPresentation = null - val result = purchaselyWrapper.display(presentation, activity) + val result = purchaselyWrapper.display(handle, activity) when (result) { is DisplayResult.Purchased, is DisplayResult.Restored -> { Log.d(TAG, "[Shaker] Purchased/Restored from favorites") diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt index f25d4b9..c06e115 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt @@ -9,8 +9,8 @@ import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper -import io.purchasely.ext.PLYPresentation import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -64,7 +64,7 @@ class HomeViewModel( val isFiltersLoading: StateFlow = _isFiltersLoading.asStateFlow() // Signal Screen to display filters paywall - private var pendingFiltersPresentation: PLYPresentation? = null + private var pendingFiltersPresentation: PresentationHandle? = null private val _requestPaywallDisplay = MutableSharedFlow() val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() @@ -97,15 +97,15 @@ class HomeViewModel( if (isPremium.value) return val result = _filtersPresentation.value if (result is FetchResult.Success) { - pendingFiltersPresentation = result.presentation + pendingFiltersPresentation = result.handle viewModelScope.launch { _requestPaywallDisplay.emit(Unit) } } } suspend fun displayPendingPaywall(activity: Activity) { - val presentation = pendingFiltersPresentation ?: return + val handle = pendingFiltersPresentation ?: return pendingFiltersPresentation = null - val result = purchaselyWrapper.display(presentation, activity) + val result = purchaselyWrapper.display(handle, activity) when (result) { is DisplayResult.Purchased, is DisplayResult.Restored -> { Log.d("HomeViewModel", "[Shaker] Purchased/Restored from filters") diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt index 3f81288..bdbaa3b 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt @@ -50,7 +50,7 @@ fun OnboardingScreen( when (val result = purchaselyWrapper.loadPresentation("onboarding")) { is FetchResult.Success -> { - val displayResult = purchaselyWrapper.display(result.presentation, activity) + val displayResult = purchaselyWrapper.display(result.handle, activity) when (displayResult) { is DisplayResult.Purchased, is DisplayResult.Restored -> { @@ -72,7 +72,7 @@ fun OnboardingScreen( onComplete() } is FetchResult.Error -> { - Log.e(TAG, "[Shaker] Error fetching onboarding: ${result.error?.message}") + Log.e(TAG, "[Shaker] Error fetching onboarding: ${result.message}") onComplete() } } 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 25966b7..e48d0a2 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 @@ -11,9 +11,9 @@ import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.purchasely.ext.PLYDataProcessingPurpose -import io.purchasely.ext.PLYPresentation import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -78,7 +78,7 @@ class SettingsViewModel( val displayMode: StateFlow = _displayMode.asStateFlow() // Signal Screen to display onboarding paywall - private var pendingOnboardingPresentation: PLYPresentation? = null + private var pendingOnboardingPresentation: PresentationHandle? = null private val _requestPaywallDisplay = MutableSharedFlow() val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() @@ -156,7 +156,7 @@ class SettingsViewModel( viewModelScope.launch { when (val result = purchaselyWrapper.loadPresentation("onboarding")) { is FetchResult.Success -> { - pendingOnboardingPresentation = result.presentation + pendingOnboardingPresentation = result.handle _requestPaywallDisplay.emit(Unit) } is FetchResult.Client -> { @@ -166,16 +166,16 @@ class SettingsViewModel( Log.d(TAG, "[Shaker] Onboarding presentation is deactivated") } is FetchResult.Error -> { - Log.d(TAG, "[Shaker] Onboarding presentation not available: ${result.error?.message}") + Log.d(TAG, "[Shaker] Onboarding presentation not available: ${result.message}") } } } } suspend fun displayPendingPaywall(activity: Activity) { - val presentation = pendingOnboardingPresentation ?: return + val handle = pendingOnboardingPresentation ?: return pendingOnboardingPresentation = null - val result = purchaselyWrapper.display(presentation, activity) + val result = purchaselyWrapper.display(handle, activity) when (result) { is DisplayResult.Purchased, is DisplayResult.Restored -> { Log.d(TAG, "[Shaker] Purchased/Restored from onboarding") diff --git a/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt index 513bcc1..87ec538 100644 --- a/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt @@ -208,17 +208,17 @@ class PurchaselyWrapperTest { fun `loadPresentation returns FetchResult via mocked wrapper`() = runTest { val mockedWrapper = mockk(relaxed = true) val mockPresentation = mockk() - io.mockk.coEvery { mockedWrapper.loadPresentation("filters", null) } returns FetchResult.Success(mockPresentation) + val handle = PresentationHandle(mockPresentation) + io.mockk.coEvery { mockedWrapper.loadPresentation("filters", null) } returns FetchResult.Success(handle, 300) val result = mockedWrapper.loadPresentation("filters") assertTrue(result is FetchResult.Success) } @Test fun `FetchResult Success exposes height`() { - val presentation = mockk { - every { height } returns 400 - } - val result = FetchResult.Success(presentation) + val presentation = mockk() + val handle = PresentationHandle(presentation) + val result = FetchResult.Success(handle, 400) assertEquals(400, result.height) } From 910625e15985aa792a2c5e78b9c8d71b53d998ea Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 10:36:04 +0200 Subject: [PATCH 02/15] refactor(android): route PremiumManager through PurchaselyWrapper PremiumManager now receives PurchaselyWrapper via constructor instead of calling Purchasely.userSubscriptions() directly. Circular dependency between PurchaselyWrapper and PremiumManager is broken by replacing the direct premiumManager reference with an onTransactionCompleted callback and an onConfigured parameter on initialize(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/purchasely/shaker/ShakerApp.kt | 5 ++++- .../purchasely/shaker/data/PremiumManager.kt | 6 +++--- .../java/com/purchasely/shaker/di/AppModule.kt | 7 +++++-- .../shaker/purchasely/PurchaselyWrapper.kt | 18 +++++++++++++----- .../shaker/purchasely/PurchaselyWrapperTest.kt | 14 +++++++------- 5 files changed, 32 insertions(+), 18 deletions(-) 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 86ea2cf..ee11481 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt @@ -1,6 +1,7 @@ package com.purchasely.shaker import android.app.Application +import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.di.appModule import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.purchasely.ext.LogLevel @@ -11,6 +12,7 @@ import org.koin.core.context.startKoin class ShakerApp : Application() { private val purchaselyWrapper: PurchaselyWrapper by inject() + private val premiumManager: PremiumManager by inject() override fun onCreate() { super.onCreate() @@ -23,7 +25,8 @@ class ShakerApp : Application() { purchaselyWrapper.initialize( application = this, apiKey = BuildConfig.PURCHASELY_API_KEY, - logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN + logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN, + onConfigured = { premiumManager.refreshPremiumStatus() } ) } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/PremiumManager.kt b/android/app/src/main/java/com/purchasely/shaker/data/PremiumManager.kt index 6a7b92a..3106b04 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/PremiumManager.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/PremiumManager.kt @@ -1,14 +1,14 @@ package com.purchasely.shaker.data import android.util.Log -import io.purchasely.ext.Purchasely +import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.purchasely.ext.SubscriptionsListener import io.purchasely.models.PLYSubscriptionData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -class PremiumManager { +class PremiumManager(private val wrapper: PurchaselyWrapper) { private val _isPremium = MutableStateFlow(false) val isPremium: StateFlow = _isPremium.asStateFlow() @@ -17,7 +17,7 @@ class PremiumManager { // PURCHASELY: Fetch the current user's active subscriptions to determine premium access // Pass false to use cached data; true forces a network refresh // Docs: https://docs.purchasely.com/advanced-features/subscription-status - Purchasely.userSubscriptions(false, object : SubscriptionsListener { + wrapper.userSubscriptions(false, object : SubscriptionsListener { override fun onSuccess(subscriptions: List) { val premium = subscriptions.any { subscriptionData -> subscriptionData.data.subscriptionStatus?.isExpired() == false diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index abf1bf8..e449cf0 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -29,7 +29,6 @@ val appModule = module { single { FavoritesRepository(androidContext()) } single { OnboardingRepository(androidContext()) } single { RunningModeRepository(androidContext()) } - single { PremiumManager() } // Reactive flows for purchase orchestration single(named("purchaseRequests")) { MutableSharedFlow() } single(named("restoreRequests")) { MutableSharedFlow() } @@ -53,7 +52,6 @@ val appModule = module { } single { PurchaselyWrapper( - premiumManager = get(), runningModeRepo = get(), purchaseRequests = get(named("purchaseRequests")), restoreRequests = get(named("restoreRequests")), @@ -61,6 +59,11 @@ val appModule = module { scope = get(named("appScope")) ) } + single { + PremiumManager(wrapper = get()).also { pm -> + get().onTransactionCompleted = { pm.refreshPremiumStatus() } + } + } viewModel { HomeViewModel(get(), get(), get()) } viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { FavoritesViewModel(get(), get(), get(), get()) } diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt index 65a03d2..160ad88 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt @@ -7,7 +7,6 @@ import android.content.Intent import android.net.Uri import android.util.Log import android.view.View -import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest @@ -19,6 +18,7 @@ import io.purchasely.ext.PLYPresentationActionParameters import io.purchasely.ext.PLYPresentationInfo import io.purchasely.ext.PLYPresentationProperties import io.purchasely.ext.PLYPresentationType +import io.purchasely.ext.SubscriptionsListener import io.purchasely.ext.PLYProductViewResult import io.purchasely.ext.Purchasely import io.purchasely.ext.fetchPresentation @@ -34,7 +34,6 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine class PurchaselyWrapper( - private val premiumManager: PremiumManager, private val runningModeRepo: RunningModeRepository, private val purchaseRequests: MutableSharedFlow, private val restoreRequests: MutableSharedFlow, @@ -42,6 +41,8 @@ class PurchaselyWrapper( private val scope: CoroutineScope ) { + var onTransactionCompleted: (() -> Unit)? = null + private var application: Application? = null private var apiKey: String = "" private var logLevel: LogLevel = LogLevel.DEBUG @@ -66,7 +67,8 @@ class PurchaselyWrapper( fun initialize( application: Application, apiKey: String, - logLevel: LogLevel = LogLevel.DEBUG + logLevel: LogLevel = LogLevel.DEBUG, + onConfigured: (() -> Unit)? = null ) { this.application = application this.apiKey = apiKey @@ -84,7 +86,7 @@ class PurchaselyWrapper( .start { isConfigured, error -> if (isConfigured) { Log.d(TAG, "[Shaker] Purchasely SDK configured successfully") - premiumManager.refreshPremiumStatus() + onConfigured?.invoke() } error?.let { Log.e(TAG, "[Shaker] Purchasely configuration error: ${it.message}") @@ -185,7 +187,7 @@ class PurchaselyWrapper( synchronize() pendingProcessAction?.invoke(false) pendingProcessAction = null - premiumManager.refreshPremiumStatus() + onTransactionCompleted?.invoke() Log.d(TAG, "[Shaker] Transaction success — synchronized and refreshed") } is TransactionResult.Cancelled -> { @@ -320,6 +322,12 @@ class PurchaselyWrapper( Purchasely.incrementUserAttribute(key) } + // MARK: - Subscriptions + + fun userSubscriptions(invalidateCache: Boolean, listener: SubscriptionsListener) { + Purchasely.userSubscriptions(invalidateCache, listener) + } + // MARK: - Restore fun restoreAllProducts( diff --git a/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt index 87ec538..d5a69d5 100644 --- a/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt @@ -1,7 +1,6 @@ package com.purchasely.shaker.purchasely import android.app.Activity -import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest @@ -37,7 +36,7 @@ class PurchaselyWrapperTest { private val testDispatcher = UnconfinedTestDispatcher() private val testScope = TestScope(testDispatcher) - private lateinit var premiumManager: PremiumManager + private lateinit var onTransactionCompletedCallback: (() -> Unit) private lateinit var runningModeRepo: RunningModeRepository private lateinit var purchaseRequests: MutableSharedFlow private lateinit var restoreRequests: MutableSharedFlow @@ -47,7 +46,7 @@ class PurchaselyWrapperTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - premiumManager = mockk(relaxed = true) + onTransactionCompletedCallback = mockk(relaxed = true) runningModeRepo = mockk { every { runningMode } returns PLYRunningMode.PaywallObserver every { isObserverMode } returns true @@ -56,13 +55,14 @@ class PurchaselyWrapperTest { restoreRequests = MutableSharedFlow() transactionResult = MutableSharedFlow() wrapper = PurchaselyWrapper( - premiumManager = premiumManager, runningModeRepo = runningModeRepo, purchaseRequests = purchaseRequests, restoreRequests = restoreRequests, transactionResult = transactionResult, scope = testScope - ) + ).also { + it.onTransactionCompleted = onTransactionCompletedCallback + } } @After @@ -159,11 +159,11 @@ class PurchaselyWrapperTest { // --- TransactionResult observation --- @Test - fun `TransactionResult Success triggers premium refresh`() = runTest { + fun `TransactionResult Success triggers onTransactionCompleted callback`() = runTest { wrapper.handlePaywallAction(null, PLYPresentationAction.RESTORE, null) {} transactionResult.emit(TransactionResult.Success) testScope.testScheduler.advanceUntilIdle() - verify { premiumManager.refreshPremiumStatus() } + verify { onTransactionCompletedCallback.invoke() } } @Test From e345a03be9a22a21992fb752283f4b9f4239f557 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 10:39:34 +0200 Subject: [PATCH 03/15] refactor(android): extract SharedPreferences behind KeyValueStore interface Decouple FavoritesRepository, OnboardingRepository, and RunningModeRepository from android.content.Context/SharedPreferences by introducing a KeyValueStore interface. Tests now use InMemoryKeyValueStore instead of mocked SharedPreferences, making them simpler and framework-independent. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/data/FavoritesRepository.kt | 16 +++---- .../shaker/data/OnboardingRepository.kt | 12 ++--- .../shaker/data/RunningModeRepository.kt | 12 ++--- .../shaker/data/storage/KeyValueStore.kt | 12 +++++ .../storage/SharedPreferencesKeyValueStore.kt | 16 +++++++ .../com/purchasely/shaker/di/AppModule.kt | 24 ++++++++-- .../shaker/data/FavoritesRepositoryTest.kt | 46 +++++------------- .../shaker/data/OnboardingRepositoryTest.kt | 42 +++++----------- .../shaker/data/RunningModeRepositoryTest.kt | 48 ++++++------------- .../data/storage/InMemoryKeyValueStore.kt | 18 +++++++ 10 files changed, 119 insertions(+), 127 deletions(-) create mode 100644 android/app/src/main/java/com/purchasely/shaker/data/storage/KeyValueStore.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/data/storage/SharedPreferencesKeyValueStore.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/storage/InMemoryKeyValueStore.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt index a4966bc..b967723 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt @@ -1,21 +1,17 @@ package com.purchasely.shaker.data -import android.content.Context -import android.content.SharedPreferences +import com.purchasely.shaker.data.storage.KeyValueStore import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -class FavoritesRepository(context: Context) { - - private val prefs: SharedPreferences = - context.getSharedPreferences("shaker_favorites", Context.MODE_PRIVATE) +class FavoritesRepository(private val store: KeyValueStore) { private val _favoriteIds = MutableStateFlow>(emptySet()) val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() init { - _favoriteIds.value = prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet() + _favoriteIds.value = store.getStringSet(KEY_FAVORITES) } fun isFavorite(cocktailId: String): Boolean = _favoriteIds.value.contains(cocktailId) @@ -28,21 +24,21 @@ class FavoritesRepository(context: Context) { current.add(cocktailId) } _favoriteIds.value = current - prefs.edit().putStringSet(KEY_FAVORITES, current).apply() + store.putStringSet(KEY_FAVORITES, current) } fun addFavorite(cocktailId: String) { val current = _favoriteIds.value.toMutableSet() current.add(cocktailId) _favoriteIds.value = current - prefs.edit().putStringSet(KEY_FAVORITES, current).apply() + store.putStringSet(KEY_FAVORITES, current) } fun removeFavorite(cocktailId: String) { val current = _favoriteIds.value.toMutableSet() current.remove(cocktailId) _favoriteIds.value = current - prefs.edit().putStringSet(KEY_FAVORITES, current).apply() + store.putStringSet(KEY_FAVORITES, current) } companion object { diff --git a/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt index efd5b6c..027fa4e 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt @@ -1,16 +1,12 @@ package com.purchasely.shaker.data -import android.content.Context -import android.content.SharedPreferences +import com.purchasely.shaker.data.storage.KeyValueStore -class OnboardingRepository(context: Context) { - - private val prefs: SharedPreferences = - context.getSharedPreferences("shaker_onboarding", Context.MODE_PRIVATE) +class OnboardingRepository(private val store: KeyValueStore) { var isOnboardingCompleted: Boolean - get() = prefs.getBoolean(KEY_COMPLETED, false) - set(value) { prefs.edit().putBoolean(KEY_COMPLETED, value).apply() } + get() = store.getBoolean(KEY_COMPLETED) + set(value) { store.putBoolean(KEY_COMPLETED, value) } companion object { private const val KEY_COMPLETED = "onboarding_completed" diff --git a/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt index 7fca671..8ae050b 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt @@ -1,22 +1,18 @@ package com.purchasely.shaker.data -import android.content.Context -import android.content.SharedPreferences +import com.purchasely.shaker.data.storage.KeyValueStore import io.purchasely.ext.PLYRunningMode -class RunningModeRepository(context: Context) { - - private val prefs: SharedPreferences = - context.getSharedPreferences("shaker_settings", Context.MODE_PRIVATE) +class RunningModeRepository(private val store: KeyValueStore) { var runningMode: PLYRunningMode get() { - val stored = prefs.getString(KEY_RUNNING_MODE, "full") + val stored = store.getString(KEY_RUNNING_MODE, "full") return if (stored == "observer") PLYRunningMode.PaywallObserver else PLYRunningMode.Full } set(value) { val str = if (value == PLYRunningMode.PaywallObserver) "observer" else "full" - prefs.edit().putString(KEY_RUNNING_MODE, str).apply() + store.putString(KEY_RUNNING_MODE, str) } val isObserverMode: Boolean diff --git a/android/app/src/main/java/com/purchasely/shaker/data/storage/KeyValueStore.kt b/android/app/src/main/java/com/purchasely/shaker/data/storage/KeyValueStore.kt new file mode 100644 index 0000000..93a4d46 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/storage/KeyValueStore.kt @@ -0,0 +1,12 @@ +package com.purchasely.shaker.data.storage + +interface KeyValueStore { + fun getString(key: String, default: String? = null): String? + fun putString(key: String, value: String?) + fun getBoolean(key: String, default: Boolean = false): Boolean + fun putBoolean(key: String, value: Boolean) + fun getStringSet(key: String, default: Set = emptySet()): Set + fun putStringSet(key: String, value: Set) + fun contains(key: String): Boolean + fun remove(key: String) +} diff --git a/android/app/src/main/java/com/purchasely/shaker/data/storage/SharedPreferencesKeyValueStore.kt b/android/app/src/main/java/com/purchasely/shaker/data/storage/SharedPreferencesKeyValueStore.kt new file mode 100644 index 0000000..59b0aa0 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/storage/SharedPreferencesKeyValueStore.kt @@ -0,0 +1,16 @@ +package com.purchasely.shaker.data.storage + +import android.content.SharedPreferences + +class SharedPreferencesKeyValueStore( + private val prefs: SharedPreferences +) : KeyValueStore { + override fun getString(key: String, default: String?): String? = prefs.getString(key, default) + override fun putString(key: String, value: String?) { prefs.edit().putString(key, value).apply() } + override fun getBoolean(key: String, default: Boolean): Boolean = prefs.getBoolean(key, default) + override fun putBoolean(key: String, value: Boolean) { prefs.edit().putBoolean(key, value).apply() } + override fun getStringSet(key: String, default: Set): Set = prefs.getStringSet(key, default) ?: default + override fun putStringSet(key: String, value: Set) { prefs.edit().putStringSet(key, value).apply() } + override fun contains(key: String): Boolean = prefs.contains(key) + override fun remove(key: String) { prefs.edit().remove(key).apply() } +} diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index e449cf0..8d7f9a8 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -1,5 +1,6 @@ package com.purchasely.shaker.di +import android.content.Context import com.android.billingclient.api.BillingClient import com.android.billingclient.api.PendingPurchasesParams import com.purchasely.shaker.data.CocktailRepository @@ -10,6 +11,8 @@ import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.purchase.PurchaseManager import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest +import com.purchasely.shaker.data.storage.KeyValueStore +import com.purchasely.shaker.data.storage.SharedPreferencesKeyValueStore import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.screen.home.HomeViewModel import com.purchasely.shaker.ui.screen.detail.DetailViewModel @@ -26,9 +29,24 @@ import org.koin.dsl.module val appModule = module { single { CocktailRepository(androidContext()) } - single { FavoritesRepository(androidContext()) } - single { OnboardingRepository(androidContext()) } - single { RunningModeRepository(androidContext()) } + single(named("favorites")) { + SharedPreferencesKeyValueStore( + androidContext().getSharedPreferences("shaker_favorites", Context.MODE_PRIVATE) + ) as KeyValueStore + } + single(named("onboarding")) { + SharedPreferencesKeyValueStore( + androidContext().getSharedPreferences("shaker_onboarding", Context.MODE_PRIVATE) + ) as KeyValueStore + } + single(named("settings")) { + SharedPreferencesKeyValueStore( + androidContext().getSharedPreferences("shaker_settings", Context.MODE_PRIVATE) + ) as KeyValueStore + } + single { FavoritesRepository(get(named("favorites"))) } + single { OnboardingRepository(get(named("onboarding"))) } + single { RunningModeRepository(get(named("settings"))) } // Reactive flows for purchase orchestration single(named("purchaseRequests")) { MutableSharedFlow() } single(named("restoreRequests")) { MutableSharedFlow() } diff --git a/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt index a62b514..d29cdbe 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt @@ -1,10 +1,6 @@ package com.purchasely.shaker.data -import android.content.Context -import android.content.SharedPreferences -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify +import com.purchasely.shaker.data.storage.InMemoryKeyValueStore import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -13,30 +9,14 @@ import org.junit.Test class FavoritesRepositoryTest { - private lateinit var prefs: SharedPreferences - private lateinit var editor: SharedPreferences.Editor - private lateinit var context: Context - private var storedSet: MutableSet = mutableSetOf() + private lateinit var store: InMemoryKeyValueStore @Before fun setUp() { - storedSet = mutableSetOf() - editor = mockk(relaxed = true) { - every { putStringSet(any(), any()) } answers { - storedSet = (secondArg() as Set).toMutableSet() - this@mockk - } - } - prefs = mockk { - every { getStringSet(any(), any()) } answers { storedSet.toSet() } - every { edit() } returns editor - } - context = mockk { - every { getSharedPreferences(any(), any()) } returns prefs - } + store = InMemoryKeyValueStore() } - private fun createRepository(): FavoritesRepository = FavoritesRepository(context) + private fun createRepository(): FavoritesRepository = FavoritesRepository(store) @Test fun `initial state is empty when no stored favorites`() { @@ -46,7 +26,7 @@ class FavoritesRepositoryTest { @Test fun `initial state loads stored favorites`() { - storedSet = mutableSetOf("cocktail1", "cocktail2") + store.putStringSet("favorite_cocktail_ids", setOf("cocktail1", "cocktail2")) val repo = createRepository() assertEquals(setOf("cocktail1", "cocktail2"), repo.favoriteIds.value) } @@ -59,26 +39,26 @@ class FavoritesRepositoryTest { } @Test - fun `addFavorite persists to SharedPreferences`() { + fun `addFavorite persists to store`() { val repo = createRepository() repo.addFavorite("cocktail1") - verify { editor.putStringSet(any(), match { it.contains("cocktail1") }) } + assertTrue(store.getStringSet("favorite_cocktail_ids").contains("cocktail1")) } @Test fun `removeFavorite removes cocktail id`() { - storedSet = mutableSetOf("cocktail1") + store.putStringSet("favorite_cocktail_ids", setOf("cocktail1")) val repo = createRepository() repo.removeFavorite("cocktail1") assertFalse(repo.favoriteIds.value.contains("cocktail1")) } @Test - fun `removeFavorite persists to SharedPreferences`() { - storedSet = mutableSetOf("cocktail1") + fun `removeFavorite persists to store`() { + store.putStringSet("favorite_cocktail_ids", setOf("cocktail1")) val repo = createRepository() repo.removeFavorite("cocktail1") - verify { editor.putStringSet(any(), match { !it.contains("cocktail1") }) } + assertFalse(store.getStringSet("favorite_cocktail_ids").contains("cocktail1")) } @Test @@ -90,7 +70,7 @@ class FavoritesRepositoryTest { @Test fun `toggleFavorite removes when already present`() { - storedSet = mutableSetOf("cocktail1") + store.putStringSet("favorite_cocktail_ids", setOf("cocktail1")) val repo = createRepository() repo.toggleFavorite("cocktail1") assertFalse(repo.favoriteIds.value.contains("cocktail1")) @@ -98,7 +78,7 @@ class FavoritesRepositoryTest { @Test fun `isFavorite returns true for existing favorite`() { - storedSet = mutableSetOf("cocktail1") + store.putStringSet("favorite_cocktail_ids", setOf("cocktail1")) val repo = createRepository() assertTrue(repo.isFavorite("cocktail1")) } diff --git a/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt index 96a689e..62c0446 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt @@ -1,10 +1,6 @@ package com.purchasely.shaker.data -import android.content.Context -import android.content.SharedPreferences -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify +import com.purchasely.shaker.data.storage.InMemoryKeyValueStore import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -12,56 +8,40 @@ import org.junit.Test class OnboardingRepositoryTest { - private lateinit var prefs: SharedPreferences - private lateinit var editor: SharedPreferences.Editor - private lateinit var context: Context - private var storedBoolean: Boolean = false + private lateinit var store: InMemoryKeyValueStore @Before fun setUp() { - storedBoolean = false - editor = mockk(relaxed = true) { - every { putBoolean(any(), any()) } answers { - storedBoolean = secondArg() - this@mockk - } - } - prefs = mockk { - every { getBoolean(any(), any()) } answers { storedBoolean } - every { edit() } returns editor - } - context = mockk { - every { getSharedPreferences(any(), any()) } returns prefs - } + store = InMemoryKeyValueStore() } @Test fun `initial state is false`() { - val repo = OnboardingRepository(context) + val repo = OnboardingRepository(store) assertFalse(repo.isOnboardingCompleted) } @Test fun `setting to true persists`() { - val repo = OnboardingRepository(context) + val repo = OnboardingRepository(store) repo.isOnboardingCompleted = true assertTrue(repo.isOnboardingCompleted) - verify { editor.putBoolean("onboarding_completed", true) } + assertTrue(store.getBoolean("onboarding_completed")) } @Test fun `setting to false persists`() { - storedBoolean = true - val repo = OnboardingRepository(context) + store.putBoolean("onboarding_completed", true) + val repo = OnboardingRepository(store) repo.isOnboardingCompleted = false assertFalse(repo.isOnboardingCompleted) - verify { editor.putBoolean("onboarding_completed", false) } + assertFalse(store.getBoolean("onboarding_completed")) } @Test fun `reads stored value on creation`() { - storedBoolean = true - val repo = OnboardingRepository(context) + store.putBoolean("onboarding_completed", true) + val repo = OnboardingRepository(store) assertTrue(repo.isOnboardingCompleted) } } diff --git a/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt index dc00e25..ca9cff7 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt @@ -1,10 +1,6 @@ package com.purchasely.shaker.data -import android.content.Context -import android.content.SharedPreferences -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify +import com.purchasely.shaker.data.storage.InMemoryKeyValueStore import io.purchasely.ext.PLYRunningMode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -14,68 +10,52 @@ import org.junit.Test class RunningModeRepositoryTest { - private lateinit var prefs: SharedPreferences - private lateinit var editor: SharedPreferences.Editor - private lateinit var context: Context - private var storedString: String? = "full" + private lateinit var store: InMemoryKeyValueStore @Before fun setUp() { - storedString = "full" - editor = mockk(relaxed = true) { - every { putString(any(), any()) } answers { - storedString = secondArg() - this@mockk - } - } - prefs = mockk { - every { getString(any(), any()) } answers { storedString ?: secondArg() } - every { edit() } returns editor - } - context = mockk { - every { getSharedPreferences(any(), any()) } returns prefs - } + store = InMemoryKeyValueStore() } @Test fun `default mode is Full`() { - val repo = RunningModeRepository(context) + val repo = RunningModeRepository(store) assertEquals(PLYRunningMode.Full, repo.runningMode) } @Test fun `isObserverMode is false when Full`() { - val repo = RunningModeRepository(context) + val repo = RunningModeRepository(store) assertFalse(repo.isObserverMode) } @Test fun `setting to PaywallObserver persists observer string`() { - val repo = RunningModeRepository(context) + val repo = RunningModeRepository(store) repo.runningMode = PLYRunningMode.PaywallObserver - verify { editor.putString("running_mode", "observer") } + assertEquals("observer", store.getString("running_mode")) } @Test fun `reading PaywallObserver from storage`() { - storedString = "observer" - val repo = RunningModeRepository(context) + store.putString("running_mode", "observer") + val repo = RunningModeRepository(store) assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) assertTrue(repo.isObserverMode) } @Test fun `setting to Full persists full string`() { - storedString = "observer" - val repo = RunningModeRepository(context) + store.putString("running_mode", "observer") + val repo = RunningModeRepository(store) repo.runningMode = PLYRunningMode.Full - verify { editor.putString("running_mode", "full") } + assertEquals("full", store.getString("running_mode")) } @Test fun `unknown stored value defaults to Full`() { - storedString = "unknown" - val repo = RunningModeRepository(context) + store.putString("running_mode", "unknown") + val repo = RunningModeRepository(store) assertEquals(PLYRunningMode.Full, repo.runningMode) } } diff --git a/android/app/src/test/java/com/purchasely/shaker/data/storage/InMemoryKeyValueStore.kt b/android/app/src/test/java/com/purchasely/shaker/data/storage/InMemoryKeyValueStore.kt new file mode 100644 index 0000000..d0b31d3 --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/storage/InMemoryKeyValueStore.kt @@ -0,0 +1,18 @@ +package com.purchasely.shaker.data.storage + +class InMemoryKeyValueStore : KeyValueStore { + private val store = mutableMapOf() + + override fun getString(key: String, default: String?): String? = store[key] as? String ?: default + override fun putString(key: String, value: String?) { store[key] = value } + override fun getBoolean(key: String, default: Boolean): Boolean = store[key] as? Boolean ?: default + override fun putBoolean(key: String, value: Boolean) { store[key] = value } + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String, default: Set): Set = + (store[key] as? Set) ?: default + + override fun putStringSet(key: String, value: Set) { store[key] = value } + override fun contains(key: String): Boolean = store.containsKey(key) + override fun remove(key: String) { store.remove(key) } +} From 4e1a53d6bcf584a99797a0479b8d8268c55c17b6 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 10:43:43 +0200 Subject: [PATCH 04/15] refactor(android): extract SettingsRepository from SettingsViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move SharedPreferences management for ~10 settings keys (userId, theme, display mode, SDK mode, consent toggles) into a dedicated SettingsRepository backed by KeyValueStore. SettingsViewModel no longer takes Context in its constructor — it depends on SettingsRepository instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/data/SettingsRepository.kt | 62 ++++++++++ .../com/purchasely/shaker/di/AppModule.kt | 4 +- .../ui/screen/settings/SettingsViewModel.kt | 60 ++++------ .../shaker/data/SettingsRepositoryTest.kt | 111 ++++++++++++++++++ .../screen/settings/SettingsViewModelTest.kt | 76 +++++------- 5 files changed, 228 insertions(+), 85 deletions(-) create mode 100644 android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt new file mode 100644 index 0000000..7e4ab08 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt @@ -0,0 +1,62 @@ +package com.purchasely.shaker.data + +import com.purchasely.shaker.data.storage.KeyValueStore + +class SettingsRepository(private val store: KeyValueStore) { + + var userId: String? + get() = store.getString(KEY_USER_ID) + set(value) { + if (value != null) store.putString(KEY_USER_ID, value) else store.remove(KEY_USER_ID) + } + + var themeMode: String + get() = store.getString(KEY_THEME, "system") ?: "system" + set(value) = store.putString(KEY_THEME, value) + + var displayMode: String + get() = store.getString(KEY_DISPLAY_MODE, "fullscreen") ?: "fullscreen" + set(value) = store.putString(KEY_DISPLAY_MODE, value) + + var sdkModeStorage: String + get() = store.getString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue) + ?: PurchaselySdkMode.DEFAULT.storageValue + set(value) = store.putString(PurchaselySdkMode.KEY, value) + + var analyticsConsent: Boolean + get() = store.getBoolean(KEY_CONSENT_ANALYTICS, true) + set(value) = store.putBoolean(KEY_CONSENT_ANALYTICS, value) + + var identifiedAnalyticsConsent: Boolean + get() = store.getBoolean(KEY_CONSENT_IDENTIFIED_ANALYTICS, true) + set(value) = store.putBoolean(KEY_CONSENT_IDENTIFIED_ANALYTICS, value) + + var personalizationConsent: Boolean + get() = store.getBoolean(KEY_CONSENT_PERSONALIZATION, true) + set(value) = store.putBoolean(KEY_CONSENT_PERSONALIZATION, value) + + var campaignsConsent: Boolean + get() = store.getBoolean(KEY_CONSENT_CAMPAIGNS, true) + set(value) = store.putBoolean(KEY_CONSENT_CAMPAIGNS, value) + + var thirdPartyConsent: Boolean + get() = store.getBoolean(KEY_CONSENT_THIRD_PARTY, true) + set(value) = store.putBoolean(KEY_CONSENT_THIRD_PARTY, value) + + fun initSdkModeIfNeeded() { + if (!store.contains(PurchaselySdkMode.KEY)) { + store.putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue) + } + } + + companion object { + private const val KEY_USER_ID = "user_id" + private const val KEY_THEME = "theme_mode" + private const val KEY_DISPLAY_MODE = "display_mode" + private const val KEY_CONSENT_ANALYTICS = "consent_analytics" + private const val KEY_CONSENT_IDENTIFIED_ANALYTICS = "consent_identified_analytics" + private const val KEY_CONSENT_PERSONALIZATION = "consent_personalization" + private const val KEY_CONSENT_CAMPAIGNS = "consent_campaigns" + private const val KEY_CONSENT_THIRD_PARTY = "consent_third_party" + } +} diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index 8d7f9a8..871b267 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -8,6 +8,7 @@ import com.purchasely.shaker.data.FavoritesRepository import com.purchasely.shaker.data.OnboardingRepository import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.SettingsRepository import com.purchasely.shaker.data.purchase.PurchaseManager import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest @@ -47,6 +48,7 @@ val appModule = module { single { FavoritesRepository(get(named("favorites"))) } single { OnboardingRepository(get(named("onboarding"))) } single { RunningModeRepository(get(named("settings"))) } + single { SettingsRepository(get(named("settings"))) } // Reactive flows for purchase orchestration single(named("purchaseRequests")) { MutableSharedFlow() } single(named("restoreRequests")) { MutableSharedFlow() } @@ -85,5 +87,5 @@ val appModule = module { viewModel { HomeViewModel(get(), get(), get()) } viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { FavoritesViewModel(get(), get(), get(), get()) } - viewModel { SettingsViewModel(androidContext(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get()) } } 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 e48d0a2..becb6b6 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 @@ -1,14 +1,13 @@ package com.purchasely.shaker.ui.screen.settings import android.app.Activity -import android.content.Context -import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.purchasely.shaker.data.PurchaselySdkMode import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.SettingsRepository import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle @@ -23,16 +22,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class SettingsViewModel( - private val context: Context, + private val settingsRepo: SettingsRepository, private val premiumManager: PremiumManager, private val runningModeRepo: RunningModeRepository, private val purchaselyWrapper: PurchaselyWrapper ) : ViewModel() { - private val prefs: SharedPreferences = - context.getSharedPreferences(PurchaselySdkMode.PREFERENCES_NAME, Context.MODE_PRIVATE) - - private val _userId = MutableStateFlow(prefs.getString(KEY_USER_ID, null)) + private val _userId = MutableStateFlow(settingsRepo.userId) val userId: StateFlow = _userId.asStateFlow() val isPremium: StateFlow = premiumManager.isPremium @@ -40,30 +36,28 @@ class SettingsViewModel( private val _restoreMessage = MutableStateFlow(null) val restoreMessage: StateFlow = _restoreMessage.asStateFlow() - private val _themeMode = MutableStateFlow(prefs.getString(KEY_THEME, "system") ?: "system") + private val _themeMode = MutableStateFlow(settingsRepo.themeMode) val themeMode: StateFlow = _themeMode.asStateFlow() private val _sdkMode = MutableStateFlow( - PurchaselySdkMode.fromStorage( - prefs.getString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue) - ) + PurchaselySdkMode.fromStorage(settingsRepo.sdkModeStorage) ) val sdkMode: StateFlow = _sdkMode.asStateFlow() // Data privacy consent toggles (default: true = consent given) - private val _analyticsConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_ANALYTICS, true)) + private val _analyticsConsent = MutableStateFlow(settingsRepo.analyticsConsent) val analyticsConsent: StateFlow = _analyticsConsent.asStateFlow() - private val _identifiedAnalyticsConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_IDENTIFIED_ANALYTICS, true)) + private val _identifiedAnalyticsConsent = MutableStateFlow(settingsRepo.identifiedAnalyticsConsent) val identifiedAnalyticsConsent: StateFlow = _identifiedAnalyticsConsent.asStateFlow() - private val _personalizationConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_PERSONALIZATION, true)) + private val _personalizationConsent = MutableStateFlow(settingsRepo.personalizationConsent) val personalizationConsent: StateFlow = _personalizationConsent.asStateFlow() - private val _campaignsConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_CAMPAIGNS, true)) + private val _campaignsConsent = MutableStateFlow(settingsRepo.campaignsConsent) val campaignsConsent: StateFlow = _campaignsConsent.asStateFlow() - private val _thirdPartyConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_THIRD_PARTY, true)) + private val _thirdPartyConsent = MutableStateFlow(settingsRepo.thirdPartyConsent) val thirdPartyConsent: StateFlow = _thirdPartyConsent.asStateFlow() private val _runningMode = MutableStateFlow( @@ -74,7 +68,7 @@ class SettingsViewModel( private val _anonymousId = MutableStateFlow(purchaselyWrapper.anonymousUserId) val anonymousId: StateFlow = _anonymousId.asStateFlow() - private val _displayMode = MutableStateFlow(prefs.getString(KEY_DISPLAY_MODE, "fullscreen") ?: "fullscreen") + private val _displayMode = MutableStateFlow(settingsRepo.displayMode) val displayMode: StateFlow = _displayMode.asStateFlow() // Signal Screen to display onboarding paywall @@ -85,9 +79,7 @@ class SettingsViewModel( val sdkVersion: String get() = purchaselyWrapper.sdkVersion init { - if (!prefs.contains(PurchaselySdkMode.KEY)) { - prefs.edit().putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue).apply() - } + settingsRepo.initSdkModeIfNeeded() applyConsentPreferences() } @@ -109,7 +101,7 @@ class SettingsViewModel( } _userId.value = userId - prefs.edit().putString(KEY_USER_ID, userId).apply() + settingsRepo.userId = userId // PURCHASELY: Store the user ID as a custom attribute for paywall targeting/personalization // Docs: https://docs.purchasely.com/advanced-features/user-attributes @@ -121,7 +113,7 @@ class SettingsViewModel( // Docs: https://docs.purchasely.com/quick-start/sdk-configuration/user-login purchaselyWrapper.userLogout() _userId.value = null - prefs.edit().remove(KEY_USER_ID).apply() + settingsRepo.userId = null premiumManager.refreshPremiumStatus() Log.d(TAG, "[Shaker] Logged out") } @@ -187,13 +179,13 @@ class SettingsViewModel( fun setDisplayMode(mode: String) { _displayMode.value = mode - prefs.edit().putString(KEY_DISPLAY_MODE, mode).apply() + settingsRepo.displayMode = mode Log.d(TAG, "[Shaker] Display mode changed to: $mode") } fun setThemeMode(mode: String) { _themeMode.value = mode - prefs.edit().putString(KEY_THEME, mode).apply() + settingsRepo.themeMode = mode // PURCHASELY: Track the user's preferred theme as a custom attribute for audience segmentation // Docs: https://docs.purchasely.com/advanced-features/user-attributes purchaselyWrapper.setUserAttribute("app_theme", mode) @@ -203,37 +195,37 @@ class SettingsViewModel( if (_sdkMode.value == mode) return _sdkMode.value = mode - prefs.edit().putString(PurchaselySdkMode.KEY, mode.storageValue).apply() + settingsRepo.sdkModeStorage = mode.storageValue restartPurchaselySdk(mode) } fun setAnalyticsConsent(enabled: Boolean) { _analyticsConsent.value = enabled - prefs.edit().putBoolean(KEY_CONSENT_ANALYTICS, enabled).apply() + settingsRepo.analyticsConsent = enabled applyConsentPreferences() } fun setIdentifiedAnalyticsConsent(enabled: Boolean) { _identifiedAnalyticsConsent.value = enabled - prefs.edit().putBoolean(KEY_CONSENT_IDENTIFIED_ANALYTICS, enabled).apply() + settingsRepo.identifiedAnalyticsConsent = enabled applyConsentPreferences() } fun setPersonalizationConsent(enabled: Boolean) { _personalizationConsent.value = enabled - prefs.edit().putBoolean(KEY_CONSENT_PERSONALIZATION, enabled).apply() + settingsRepo.personalizationConsent = enabled applyConsentPreferences() } fun setCampaignsConsent(enabled: Boolean) { _campaignsConsent.value = enabled - prefs.edit().putBoolean(KEY_CONSENT_CAMPAIGNS, enabled).apply() + settingsRepo.campaignsConsent = enabled applyConsentPreferences() } fun setThirdPartyConsent(enabled: Boolean) { _thirdPartyConsent.value = enabled - prefs.edit().putBoolean(KEY_CONSENT_THIRD_PARTY, enabled).apply() + settingsRepo.thirdPartyConsent = enabled applyConsentPreferences() } @@ -258,13 +250,5 @@ class SettingsViewModel( companion object { private const val TAG = "SettingsViewModel" - private const val KEY_USER_ID = "user_id" - private const val KEY_THEME = "theme_mode" - private const val KEY_CONSENT_ANALYTICS = "consent_analytics" - private const val KEY_CONSENT_IDENTIFIED_ANALYTICS = "consent_identified_analytics" - private const val KEY_CONSENT_PERSONALIZATION = "consent_personalization" - private const val KEY_CONSENT_CAMPAIGNS = "consent_campaigns" - private const val KEY_CONSENT_THIRD_PARTY = "consent_third_party" - private const val KEY_DISPLAY_MODE = "display_mode" } } diff --git a/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt new file mode 100644 index 0000000..cfd2aaa --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt @@ -0,0 +1,111 @@ +package com.purchasely.shaker.data + +import com.purchasely.shaker.data.storage.InMemoryKeyValueStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SettingsRepositoryTest { + + private lateinit var store: InMemoryKeyValueStore + private lateinit var repo: SettingsRepository + + @Before + fun setUp() { + store = InMemoryKeyValueStore() + repo = SettingsRepository(store) + } + + @Test + fun `userId defaults to null`() { + assertNull(repo.userId) + } + + @Test + fun `userId round-trips`() { + repo.userId = "kevin" + assertEquals("kevin", repo.userId) + } + + @Test + fun `setting userId to null removes it`() { + repo.userId = "kevin" + repo.userId = null + assertNull(repo.userId) + assertFalse(store.contains("user_id")) + } + + @Test + fun `themeMode defaults to system`() { + assertEquals("system", repo.themeMode) + } + + @Test + fun `themeMode round-trips`() { + repo.themeMode = "dark" + assertEquals("dark", repo.themeMode) + } + + @Test + fun `displayMode defaults to fullscreen`() { + assertEquals("fullscreen", repo.displayMode) + } + + @Test + fun `displayMode round-trips`() { + repo.displayMode = "embedded" + assertEquals("embedded", repo.displayMode) + } + + @Test + fun `sdkModeStorage defaults to DEFAULT storageValue`() { + assertEquals(PurchaselySdkMode.DEFAULT.storageValue, repo.sdkModeStorage) + } + + @Test + fun `sdkModeStorage round-trips`() { + repo.sdkModeStorage = PurchaselySdkMode.FULL.storageValue + assertEquals(PurchaselySdkMode.FULL.storageValue, repo.sdkModeStorage) + } + + @Test + fun `consent booleans default to true`() { + assertTrue(repo.analyticsConsent) + assertTrue(repo.identifiedAnalyticsConsent) + assertTrue(repo.personalizationConsent) + assertTrue(repo.campaignsConsent) + assertTrue(repo.thirdPartyConsent) + } + + @Test + fun `consent booleans round-trip`() { + repo.analyticsConsent = false + repo.identifiedAnalyticsConsent = false + repo.personalizationConsent = false + repo.campaignsConsent = false + repo.thirdPartyConsent = false + + assertFalse(repo.analyticsConsent) + assertFalse(repo.identifiedAnalyticsConsent) + assertFalse(repo.personalizationConsent) + assertFalse(repo.campaignsConsent) + assertFalse(repo.thirdPartyConsent) + } + + @Test + fun `initSdkModeIfNeeded sets default when key missing`() { + repo.initSdkModeIfNeeded() + assertTrue(store.contains(PurchaselySdkMode.KEY)) + assertEquals(PurchaselySdkMode.DEFAULT.storageValue, repo.sdkModeStorage) + } + + @Test + fun `initSdkModeIfNeeded does not overwrite existing value`() { + repo.sdkModeStorage = PurchaselySdkMode.FULL.storageValue + repo.initSdkModeIfNeeded() + assertEquals(PurchaselySdkMode.FULL.storageValue, repo.sdkModeStorage) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt index 2e15a53..b486251 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt @@ -1,10 +1,10 @@ package com.purchasely.shaker.ui.screen.settings -import android.content.Context -import android.content.SharedPreferences import com.purchasely.shaker.data.PurchaselySdkMode import com.purchasely.shaker.data.PremiumManager import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.data.SettingsRepository +import com.purchasely.shaker.data.storage.InMemoryKeyValueStore import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.mockk.coEvery @@ -36,48 +36,18 @@ class SettingsViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var context: Context - private lateinit var prefs: SharedPreferences - private lateinit var editor: SharedPreferences.Editor + private lateinit var store: InMemoryKeyValueStore + private lateinit var settingsRepo: SettingsRepository private lateinit var premiumManager: PremiumManager private lateinit var runningModeRepo: RunningModeRepository private lateinit var wrapper: PurchaselyWrapper - private val storedValues = mutableMapOf() - @Before fun setUp() { Dispatchers.setMain(testDispatcher) - storedValues.clear() - editor = mockk(relaxed = true) { - every { putString(any(), any()) } answers { - storedValues[firstArg()] = secondArg() - this@mockk - } - every { putBoolean(any(), any()) } answers { - storedValues[firstArg()] = secondArg() - this@mockk - } - every { remove(any()) } answers { - storedValues.remove(firstArg()) - this@mockk - } - } - prefs = mockk { - every { getString(any(), any()) } answers { - storedValues[firstArg()] as? String ?: secondArg() - } - every { getBoolean(any(), any()) } answers { - storedValues[firstArg()] as? Boolean ?: secondArg() - } - every { contains(any()) } answers { storedValues.containsKey(firstArg()) } - every { edit() } returns editor - } - context = mockk { - every { getSharedPreferences(any(), any()) } returns prefs - every { applicationContext } returns this - } + store = InMemoryKeyValueStore() + settingsRepo = SettingsRepository(store) premiumManager = mockk { every { isPremium } returns MutableStateFlow(false) every { refreshPremiumStatus() } returns Unit @@ -97,7 +67,7 @@ class SettingsViewModelTest { Dispatchers.resetMain() } - private fun createViewModel() = SettingsViewModel(context, premiumManager, runningModeRepo, wrapper) + private fun createViewModel() = SettingsViewModel(settingsRepo, premiumManager, runningModeRepo, wrapper) @Test fun `initial userId is null when not stored`() { @@ -106,8 +76,8 @@ class SettingsViewModelTest { } @Test - fun `initial userId reads from SharedPreferences`() { - storedValues["user_id"] = "kevin" + fun `initial userId reads from repository`() { + settingsRepo.userId = "kevin" val vm = createViewModel() assertEquals("kevin", vm.userId.value) } @@ -122,10 +92,10 @@ class SettingsViewModelTest { } @Test - fun `login persists userId to SharedPreferences`() { + fun `login persists userId to repository`() { val vm = createViewModel() vm.login("kevin") - verify { editor.putString("user_id", "kevin") } + assertEquals("kevin", settingsRepo.userId) } @Test @@ -157,12 +127,12 @@ class SettingsViewModelTest { @Test fun `logout clears userId and calls wrapper`() { - storedValues["user_id"] = "kevin" + settingsRepo.userId = "kevin" val vm = createViewModel() vm.logout() assertNull(vm.userId.value) verify { wrapper.userLogout() } - verify { editor.remove("user_id") } + assertNull(settingsRepo.userId) verify { premiumManager.refreshPremiumStatus() } } @@ -201,7 +171,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.setThemeMode("dark") assertEquals("dark", vm.themeMode.value) - verify { editor.putString("theme_mode", "dark") } + assertEquals("dark", settingsRepo.themeMode) verify { wrapper.setUserAttribute("app_theme", "dark") } } @@ -216,7 +186,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.setDisplayMode("embedded") assertEquals("embedded", vm.displayMode.value) - verify { editor.putString("display_mode", "embedded") } + assertEquals("embedded", settingsRepo.displayMode) } @Test @@ -334,9 +304,23 @@ class SettingsViewModelTest { @Test fun `setSdkMode calls wrapper restart`() { - storedValues[PurchaselySdkMode.KEY] = PurchaselySdkMode.FULL.storageValue + settingsRepo.sdkModeStorage = PurchaselySdkMode.FULL.storageValue val vm = createViewModel() vm.setSdkMode(PurchaselySdkMode.PAYWALL_OBSERVER) verify { wrapper.restart() } } + + @Test + fun `initSdkModeIfNeeded sets default when not present`() { + // Store starts empty, creating VM triggers initSdkModeIfNeeded + val vm = createViewModel() + assertEquals(PurchaselySdkMode.DEFAULT.storageValue, settingsRepo.sdkModeStorage) + } + + @Test + fun `initSdkModeIfNeeded does not overwrite existing value`() { + settingsRepo.sdkModeStorage = PurchaselySdkMode.FULL.storageValue + val vm = createViewModel() + assertEquals(PurchaselySdkMode.FULL.storageValue, settingsRepo.sdkModeStorage) + } } From 83d2b9a8c0a02b15395528c51ba791231e5af079 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 10:45:45 +0200 Subject: [PATCH 05/15] refactor(android): create OnboardingViewModel Extract business logic (load presentation, display, refresh premium) from OnboardingScreen composable into OnboardingViewModel, following MVVM pattern. The screen now delegates to the ViewModel via koinViewModel() instead of directly injecting PremiumManager and PurchaselyWrapper via koinInject(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/purchasely/shaker/di/AppModule.kt | 2 + .../ui/screen/onboarding/OnboardingScreen.kt | 40 +++------------- .../screen/onboarding/OnboardingViewModel.kt | 46 +++++++++++++++++++ 3 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index 871b267..f774f13 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -18,6 +18,7 @@ import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.screen.home.HomeViewModel import com.purchasely.shaker.ui.screen.detail.DetailViewModel import com.purchasely.shaker.ui.screen.favorites.FavoritesViewModel +import com.purchasely.shaker.ui.screen.onboarding.OnboardingViewModel import com.purchasely.shaker.ui.screen.settings.SettingsViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -84,6 +85,7 @@ val appModule = module { get().onTransactionCompleted = { pm.refreshPremiumStatus() } } } + viewModel { OnboardingViewModel(get(), get()) } viewModel { HomeViewModel(get(), get(), get()) } viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { FavoritesViewModel(get(), get(), get(), get()) } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt index bdbaa3b..1155650 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt @@ -1,7 +1,6 @@ package com.purchasely.shaker.ui.screen.onboarding import android.app.Activity -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,20 +20,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.purchasely.shaker.data.PremiumManager -import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult -import com.purchasely.shaker.purchasely.PurchaselyWrapper -import org.koin.compose.koinInject +import org.koin.androidx.compose.koinViewModel @Composable fun OnboardingScreen( showOnboarding: Boolean, - onComplete: () -> Unit + onComplete: () -> Unit, + viewModel: OnboardingViewModel = koinViewModel() ) { val context = LocalContext.current - val premiumManager: PremiumManager = koinInject() - val purchaselyWrapper: PurchaselyWrapper = koinInject() LaunchedEffect(Unit) { if (!showOnboarding) { @@ -48,33 +43,12 @@ fun OnboardingScreen( return@LaunchedEffect } - when (val result = purchaselyWrapper.loadPresentation("onboarding")) { + when (viewModel.loadOnboarding()) { is FetchResult.Success -> { - val displayResult = purchaselyWrapper.display(result.handle, activity) - when (displayResult) { - is DisplayResult.Purchased, - is DisplayResult.Restored -> { - Log.d(TAG, "[Shaker] Purchased/Restored from onboarding") - premiumManager.refreshPremiumStatus() - } - is DisplayResult.Cancelled -> { - Log.d(TAG, "[Shaker] Onboarding paywall cancelled") - } - } - onComplete() - } - is FetchResult.Client -> { - Log.d(TAG, "[Shaker] CLIENT presentation received for onboarding — build custom UI here") - onComplete() - } - is FetchResult.Deactivated -> { - Log.d(TAG, "[Shaker] Onboarding placement is deactivated") - onComplete() - } - is FetchResult.Error -> { - Log.e(TAG, "[Shaker] Error fetching onboarding: ${result.message}") + viewModel.displayOnboarding(activity) onComplete() } + else -> onComplete() } } @@ -119,5 +93,3 @@ private fun SplashContent() { } } } - -private const val TAG = "OnboardingScreen" diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..ea7fcb9 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt @@ -0,0 +1,46 @@ +package com.purchasely.shaker.ui.screen.onboarding + +import android.app.Activity +import android.util.Log +import androidx.lifecycle.ViewModel +import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.purchasely.DisplayResult +import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PresentationHandle +import com.purchasely.shaker.purchasely.PurchaselyWrapper + +class OnboardingViewModel( + private val purchaselyWrapper: PurchaselyWrapper, + private val premiumManager: PremiumManager +) : ViewModel() { + + private var pendingPresentation: PresentationHandle? = null + + suspend fun loadOnboarding(): FetchResult { + val result = purchaselyWrapper.loadPresentation("onboarding") + if (result is FetchResult.Success) { + pendingPresentation = result.handle + } + return result + } + + suspend fun displayOnboarding(activity: Activity): DisplayResult? { + val handle = pendingPresentation ?: return null + pendingPresentation = null + val result = purchaselyWrapper.display(handle, activity) + when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> { + Log.d(TAG, "[Shaker] Purchased/Restored from onboarding") + premiumManager.refreshPremiumStatus() + } + is DisplayResult.Cancelled -> { + Log.d(TAG, "[Shaker] Onboarding paywall cancelled") + } + } + return result + } + + companion object { + private const val TAG = "OnboardingViewModel" + } +} From a8792abe2c3e62123258d2d71b6ba8f5eabaeef0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 10:48:49 +0200 Subject: [PATCH 06/15] fix(android): fix Compose reactivity bugs - Make hasActiveFilters a StateFlow so Compose can observe filter badge changes - Replace getFavoriteCocktails() with reactive favorites StateFlow that updates when favoriteIds changes - Fix unsafe Activity cast in DetailScreen DisposableEffect with safe cast Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/ui/screen/detail/DetailScreen.kt | 3 ++- .../shaker/ui/screen/favorites/FavoritesScreen.kt | 3 +-- .../ui/screen/favorites/FavoritesViewModel.kt | 13 ++++++++++--- .../purchasely/shaker/ui/screen/home/HomeScreen.kt | 3 ++- .../shaker/ui/screen/home/HomeViewModel.kt | 12 ++++++++++-- .../ui/screen/favorites/FavoritesViewModelTest.kt | 8 ++++---- .../shaker/ui/screen/home/HomeViewModelTest.kt | 8 ++++---- 7 files changed, 33 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt index 6fb09e3..b24a174 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt @@ -64,7 +64,8 @@ fun DetailScreen( // Force light (white) status bar icons over the hero image val view = LocalView.current DisposableEffect(Unit) { - val window = (context as Activity).window + val activity = context as? Activity ?: return@DisposableEffect onDispose {} + val window = activity.window val controller = WindowCompat.getInsetsController(window, view) controller.isAppearanceLightStatusBars = false // white icons onDispose { diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt index dac36b8..7c10d88 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt @@ -45,9 +45,8 @@ fun FavoritesScreen( onCocktailClick: (String) -> Unit, viewModel: FavoritesViewModel = koinViewModel() ) { - val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() - val favorites = viewModel.getFavoriteCocktails() + val favorites by viewModel.favorites.collectAsStateWithLifecycle() val context = LocalContext.current // Collect paywall display requests from ViewModel diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt index 5651a7e..4e4576d 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class FavoritesViewModel( @@ -29,14 +30,20 @@ class FavoritesViewModel( val favoriteIds: StateFlow> = favoritesRepository.favoriteIds val isPremium: StateFlow = premiumManager.isPremium + private val _favorites = MutableStateFlow>(emptyList()) + val favorites: StateFlow> = _favorites.asStateFlow() + // Signal Screen to display favorites paywall private var pendingPresentation: PresentationHandle? = null private val _requestPaywallDisplay = MutableSharedFlow() val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() - fun getFavoriteCocktails(): List { - val ids = favoriteIds.value - return cocktailRepository.loadCocktails().filter { it.id in ids } + init { + viewModelScope.launch { + favoritesRepository.favoriteIds.collect { ids -> + _favorites.value = cocktailRepository.loadCocktails().filter { it.id in ids } + } + } } fun removeFavorite(cocktailId: String) { diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt index 6fa409a..0f20e3b 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt @@ -64,6 +64,7 @@ fun HomeScreen( val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() val isFiltersLoading by viewModel.isFiltersLoading.collectAsStateWithLifecycle() + val hasActiveFilters by viewModel.hasActiveFilters.collectAsStateWithLifecycle() val inlinePresentation by viewModel.inlinePresentation.collectAsStateWithLifecycle() val context = LocalContext.current var showFilterSheet by remember { mutableStateOf(false) } @@ -106,7 +107,7 @@ fun HomeScreen( viewModel.onFilterClick() } }) { - if (viewModel.hasActiveFilters) { + if (hasActiveFilters) { BadgedBox(badge = { Badge() }) { Icon(Icons.Default.Tune, contentDescription = "Filters") } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt index c06e115..f5fb020 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt @@ -47,10 +47,14 @@ class HomeViewModel( val availableCategories: List get() = repository.getCategories() val availableDifficulties: List get() = repository.getDifficulties() - val hasActiveFilters: Boolean - get() = _selectedSpirits.value.isNotEmpty() || + private val _hasActiveFilters = MutableStateFlow(false) + val hasActiveFilters: StateFlow = _hasActiveFilters.asStateFlow() + + private fun updateHasActiveFilters() { + _hasActiveFilters.value = _selectedSpirits.value.isNotEmpty() || _selectedCategories.value.isNotEmpty() || _selectedDifficulty.value != null + } // Prefetched inline presentation private val _inlinePresentation = MutableStateFlow(null) @@ -120,6 +124,7 @@ class HomeViewModel( if (current.contains(spirit)) current.remove(spirit) else current.add(spirit) _selectedSpirits.value = current applyFilters() + updateHasActiveFilters() } fun toggleCategory(category: String) { @@ -127,11 +132,13 @@ class HomeViewModel( if (current.contains(category)) current.remove(category) else current.add(category) _selectedCategories.value = current applyFilters() + updateHasActiveFilters() } fun selectDifficulty(difficulty: String?) { _selectedDifficulty.value = if (_selectedDifficulty.value == difficulty) null else difficulty applyFilters() + updateHasActiveFilters() } fun clearFilters() { @@ -139,6 +146,7 @@ class HomeViewModel( _selectedCategories.value = emptySet() _selectedDifficulty.value = null applyFilters() + updateHasActiveFilters() } fun onPaywallDismissed() { diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt index be9da03..51ecc27 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt @@ -67,19 +67,19 @@ class FavoritesViewModelTest { FavoritesViewModel(cocktailRepository, favoritesRepository, premiumManager, wrapper) @Test - fun `getFavoriteCocktails returns matching cocktails`() { + fun `favorites returns matching cocktails reactively`() { val vm = createViewModel() - val favorites = vm.getFavoriteCocktails() + val favorites = vm.favorites.value assertEquals(2, favorites.size) assertEquals("Mojito", favorites[0].name) assertEquals("Negroni", favorites[1].name) } @Test - fun `getFavoriteCocktails returns empty when no favorites`() { + fun `favorites returns empty when no favorites`() { every { favoritesRepository.favoriteIds } returns MutableStateFlow(emptySet()) val vm = createViewModel() - assertTrue(vm.getFavoriteCocktails().isEmpty()) + assertTrue(vm.favorites.value.isEmpty()) } @Test diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt index 03376fe..f033875 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt @@ -216,28 +216,28 @@ class HomeViewModelTest { @Test fun `hasActiveFilters is false initially`() { val vm = createViewModel() - assertFalse(vm.hasActiveFilters) + assertFalse(vm.hasActiveFilters.value) } @Test fun `hasActiveFilters is true with spirit filter`() { val vm = createViewModel() vm.toggleSpirit("Rum") - assertTrue(vm.hasActiveFilters) + assertTrue(vm.hasActiveFilters.value) } @Test fun `hasActiveFilters is true with category filter`() { val vm = createViewModel() vm.toggleCategory("Classic") - assertTrue(vm.hasActiveFilters) + assertTrue(vm.hasActiveFilters.value) } @Test fun `hasActiveFilters is true with difficulty filter`() { val vm = createViewModel() vm.selectDifficulty("Easy") - assertTrue(vm.hasActiveFilters) + assertTrue(vm.hasActiveFilters.value) } @Test From e136481ab0e05646e2958a6302c7a9af6fd0f561 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 10:55:52 +0200 Subject: [PATCH 07/15] refactor(android): extract all hardcoded strings to strings.xml Replace ~50 hardcoded UI strings across all screen composables with stringResource(R.string.xxx) references. Grouped by screen in strings.xml. Navigation bottom bar labels use @StringRes resource IDs. ViewModel toast strings are intentionally left as-is per Android conventions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/ui/navigation/Navigation.kt | 16 ++-- .../shaker/ui/screen/detail/DetailScreen.kt | 12 ++- .../ui/screen/favorites/FavoritesScreen.kt | 8 +- .../shaker/ui/screen/home/FilterSheet.kt | 12 ++- .../shaker/ui/screen/home/HomeScreen.kt | 14 +-- .../ui/screen/onboarding/OnboardingScreen.kt | 6 +- .../ui/screen/settings/SettingsScreen.kt | 76 +++++++-------- android/app/src/main/res/values/strings.xml | 94 +++++++++++++++++++ 8 files changed, 174 insertions(+), 64 deletions(-) diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt b/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt index 55842a7..88da108 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt @@ -19,7 +19,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.annotation.StringRes import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavType @@ -32,6 +34,7 @@ import com.purchasely.shaker.data.OnboardingRepository import com.purchasely.shaker.ui.screen.detail.DetailScreen import com.purchasely.shaker.ui.screen.favorites.FavoritesScreen import com.purchasely.shaker.ui.screen.home.HomeScreen +import com.purchasely.shaker.R import com.purchasely.shaker.ui.screen.onboarding.OnboardingScreen import com.purchasely.shaker.ui.screen.settings.SettingsScreen import org.koin.compose.koinInject @@ -47,15 +50,15 @@ sealed class Screen(val route: String) { data class BottomNavItem( val screen: Screen, - val label: String, + @StringRes val labelRes: Int, val selectedIcon: ImageVector, val unselectedIcon: ImageVector ) val bottomNavItems = listOf( - BottomNavItem(Screen.Home, "Home", Icons.Filled.Home, Icons.Outlined.Home), - BottomNavItem(Screen.Favorites, "Favorites", Icons.Filled.Favorite, Icons.Outlined.FavoriteBorder), - BottomNavItem(Screen.Settings, "Settings", Icons.Filled.Settings, Icons.Outlined.Settings), + BottomNavItem(Screen.Home, R.string.home, Icons.Filled.Home, Icons.Outlined.Home), + BottomNavItem(Screen.Favorites, R.string.favorites, Icons.Filled.Favorite, Icons.Outlined.FavoriteBorder), + BottomNavItem(Screen.Settings, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings), ) @Composable @@ -86,14 +89,15 @@ fun ShakerNavHost() { NavigationBar { bottomNavItems.forEach { item -> val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true + val label = stringResource(item.labelRes) NavigationBarItem( icon = { Icon( imageVector = if (selected) item.selectedIcon else item.unselectedIcon, - contentDescription = item.label + contentDescription = label ) }, - label = { Text(item.label) }, + label = { Text(label) }, selected = selected, onClick = { navController.navigate(item.screen.route) { diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt index b24a174..01ee220 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt @@ -42,8 +42,10 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.core.view.WindowCompat import androidx.compose.ui.unit.dp +import com.purchasely.shaker.R import com.purchasely.shaker.ui.components.CocktailImage import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -153,7 +155,7 @@ fun DetailScreen( // Ingredients Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Ingredients", + text = stringResource(R.string.ingredients), style = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.height(8.dp)) @@ -183,7 +185,7 @@ fun DetailScreen( // Instructions Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Instructions", + text = stringResource(R.string.instructions), style = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.height(8.dp)) @@ -237,7 +239,7 @@ fun DetailScreen( contentDescription = null, modifier = Modifier.padding(end = 8.dp) ) - Text("Unlock Full Recipe") + Text(stringResource(R.string.unlock_full_recipe)) } } } @@ -255,7 +257,7 @@ fun DetailScreen( IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.back), tint = Color.White ) } @@ -270,7 +272,7 @@ fun DetailScreen( }) { Icon( imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + contentDescription = if (isFavorite) stringResource(R.string.remove_from_favorites) else stringResource(R.string.add_to_favorites), tint = if (isFavorite) Color.Red else Color.White ) } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt index 7c10d88..1e1f38a 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt @@ -35,7 +35,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.purchasely.shaker.R import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.ui.components.CocktailImage import org.koin.androidx.compose.koinViewModel @@ -72,13 +74,13 @@ fun FavoritesScreen( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "No favorites yet", + text = stringResource(R.string.no_favorites_yet), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Tap the heart icon on a cocktail to save it here.", + text = stringResource(R.string.favorites_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -92,7 +94,7 @@ fun FavoritesScreen( ) ) { Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp)) - Text("Unlock Favorites") + Text(stringResource(R.string.unlock_favorites)) } } } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt index 8acdc9f..9a991bb 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/FilterSheet.kt @@ -21,7 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.purchasely.shaker.R @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -49,11 +51,11 @@ fun FilterSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Filters", + text = stringResource(R.string.filters), style = MaterialTheme.typography.titleLarge ) TextButton(onClick = { viewModel.clearFilters() }) { - Text("Clear all") + Text(stringResource(R.string.clear_all)) } } @@ -61,7 +63,7 @@ fun FilterSheet( // Spirit (multi-select) Text( - text = "Spirit", + text = stringResource(R.string.spirit), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -80,7 +82,7 @@ fun FilterSheet( // Category (multi-select) Text( - text = "Category", + text = stringResource(R.string.category), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -99,7 +101,7 @@ fun FilterSheet( // Difficulty (single-select) Text( - text = "Difficulty", + text = stringResource(R.string.difficulty), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt index 0f20e3b..15e94c6 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt @@ -46,8 +46,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.purchasely.shaker.R import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.purchasely.EmbeddedScreenBanner import com.purchasely.shaker.purchasely.FetchResult @@ -86,8 +88,8 @@ fun HomeScreen( onSearch = {}, expanded = false, onExpandedChange = {}, - placeholder = { Text("Search cocktails...") }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + placeholder = { Text(stringResource(R.string.search_cocktails)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = stringResource(R.string.search)) }, trailingIcon = { if (!isPremium && isFiltersLoading) { Box( @@ -109,10 +111,10 @@ fun HomeScreen( }) { if (hasActiveFilters) { BadgedBox(badge = { Badge() }) { - Icon(Icons.Default.Tune, contentDescription = "Filters") + Icon(Icons.Default.Tune, contentDescription = stringResource(R.string.filters)) } } else { - Icon(Icons.Default.Tune, contentDescription = "Filters") + Icon(Icons.Default.Tune, contentDescription = stringResource(R.string.filters)) } } } @@ -140,13 +142,13 @@ fun HomeScreen( ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = "No cocktails found", + text = stringResource(R.string.no_cocktails_found), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Try a different search or filter.", + text = stringResource(R.string.try_different_search), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt index 1155650..22f17b9 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt @@ -17,9 +17,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.purchasely.shaker.R import com.purchasely.shaker.purchasely.FetchResult import org.koin.androidx.compose.koinViewModel @@ -73,14 +75,14 @@ private fun SplashContent() { ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Shaker", + text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Discover cocktails", + text = stringResource(R.string.discover_cocktails), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) ) 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 8936a93..df8f6c4 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 @@ -44,7 +44,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.purchasely.shaker.R import com.purchasely.shaker.data.PurchaselySdkMode import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @@ -97,7 +99,7 @@ fun SettingsScreen( ) { // Account section Text( - text = "Account", + text = stringResource(R.string.account), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -110,7 +112,7 @@ fun SettingsScreen( Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "Logged in as", + text = stringResource(R.string.logged_in_as), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -121,7 +123,7 @@ fun SettingsScreen( } TextButton(onClick = { viewModel.logout() }) { Text( - "Logout", + stringResource(R.string.logout), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error ) @@ -136,8 +138,8 @@ fun SettingsScreen( OutlinedTextField( value = loginInput, onValueChange = { loginInput = it }, - label = { Text("User ID") }, - placeholder = { Text("Enter any user ID") }, + label = { Text(stringResource(R.string.user_id)) }, + placeholder = { Text(stringResource(R.string.enter_user_id)) }, singleLine = true, modifier = Modifier.weight(1f) ) @@ -149,7 +151,7 @@ fun SettingsScreen( }, enabled = loginInput.isNotBlank() ) { - Text("Login") + Text(stringResource(R.string.login)) } } } @@ -158,10 +160,10 @@ fun SettingsScreen( // Premium status Row(verticalAlignment = Alignment.CenterVertically) { - Text("Premium Status", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.premium_status), style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.weight(1f)) Text( - text = if (isPremium) "Active" else "Free", + text = if (isPremium) stringResource(R.string.active) else stringResource(R.string.free), style = MaterialTheme.typography.bodyMedium, color = if (isPremium) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) @@ -169,7 +171,7 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Anonymous ID", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.anonymous_id), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text( text = anonymousId, style = MaterialTheme.typography.bodySmall, @@ -182,9 +184,9 @@ fun SettingsScreen( ClipEntry(ClipData.newPlainText("anonymousId", anonymousId)) ) } - Toast.makeText(context, "Copied!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show() }) { - Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp)) + Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy), modifier = Modifier.size(18.dp)) } } @@ -194,7 +196,7 @@ fun SettingsScreen( // Purchases section Text( - text = "Purchases", + text = stringResource(R.string.purchases), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -203,14 +205,14 @@ fun SettingsScreen( onClick = { viewModel.restorePurchases() }, modifier = Modifier.fillMaxWidth() ) { - Text("Restore Purchases") + Text(stringResource(R.string.restore_purchases)) } Spacer(modifier = Modifier.height(8.dp)) OutlinedButton( onClick = { viewModel.showOnboardingPaywall() }, modifier = Modifier.fillMaxWidth() ) { - Text("Show Onboarding") + Text(stringResource(R.string.show_onboarding)) } Spacer(modifier = Modifier.height(24.dp)) @@ -219,7 +221,7 @@ fun SettingsScreen( // Purchasely SDK section Text( - text = "Purchasely SDK", + text = stringResource(R.string.purchasely_sdk), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -240,7 +242,7 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Default mode is Paywall Observer.", + text = stringResource(R.string.default_mode_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -251,46 +253,46 @@ fun SettingsScreen( // Data Privacy section Text( - text = "Data Privacy", + text = stringResource(R.string.data_privacy), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(12.dp)) ConsentToggleRow( - label = "Analytics", - description = "Anonymous audience measurement", + label = stringResource(R.string.analytics), + description = stringResource(R.string.analytics_desc), checked = analyticsConsent, onCheckedChange = { viewModel.setAnalyticsConsent(it) } ) ConsentToggleRow( - label = "Identified Analytics", - description = "User-identified analytics", + label = stringResource(R.string.identified_analytics), + description = stringResource(R.string.identified_analytics_desc), checked = identifiedAnalyticsConsent, onCheckedChange = { viewModel.setIdentifiedAnalyticsConsent(it) } ) ConsentToggleRow( - label = "Personalization", - description = "Personalized content & offers", + label = stringResource(R.string.personalization), + description = stringResource(R.string.personalization_desc), checked = personalizationConsent, onCheckedChange = { viewModel.setPersonalizationConsent(it) } ) ConsentToggleRow( - label = "Campaigns", - description = "Promotional campaigns", + label = stringResource(R.string.campaigns), + description = stringResource(R.string.campaigns_desc), checked = campaignsConsent, onCheckedChange = { viewModel.setCampaignsConsent(it) } ) ConsentToggleRow( - label = "Third-party Integrations", - description = "External analytics & integrations", + label = stringResource(R.string.third_party), + description = stringResource(R.string.third_party_desc), checked = thirdPartyConsent, onCheckedChange = { viewModel.setThirdPartyConsent(it) } ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Technical processing required for app operation cannot be disabled.", + text = stringResource(R.string.technical_processing_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -301,14 +303,14 @@ fun SettingsScreen( // Appearance section Text( - text = "Appearance", + text = stringResource(R.string.appearance), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(12.dp)) val themes = listOf("light", "dark", "system") - val labels = listOf("Light", "Dark", "System") + val labels = listOf(stringResource(R.string.light), stringResource(R.string.dark), stringResource(R.string.system)) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { themes.forEachIndexed { index, mode -> SegmentedButton( @@ -327,20 +329,20 @@ fun SettingsScreen( // Display Mode section Text( - text = "Screen Display Mode", + text = stringResource(R.string.screen_display_mode), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "How paywalls are presented on screen", + text = stringResource(R.string.display_mode_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(12.dp)) val displayModes = listOf("fullscreen", "modal", "drawer", "popin") - val displayLabels = listOf("Full", "Modal", "Drawer", "Popin") + val displayLabels = listOf(stringResource(R.string.full), stringResource(R.string.modal), stringResource(R.string.drawer), stringResource(R.string.popin)) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { displayModes.forEachIndexed { index, mode -> SegmentedButton( @@ -359,14 +361,14 @@ fun SettingsScreen( // About section Text( - text = "About", + text = stringResource(R.string.about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(12.dp)) Row { - Text("Version", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.version), style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.weight(1f)) Text( text = "1.0.0", @@ -376,7 +378,7 @@ fun SettingsScreen( } Spacer(modifier = Modifier.height(8.dp)) Row { - Text("Purchasely SDK", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.purchasely_sdk), style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.weight(1f)) Text( text = viewModel.sdkVersion, @@ -387,7 +389,7 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Powered by Purchasely", + text = stringResource(R.string.powered_by_purchasely), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5aac37a..55fb154 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,98 @@ Shaker + + + Search cocktails… + Search + Filters + No cocktails found + Try a different search or filter. + + + Ingredients + Instructions + Unlock Full Recipe + Back + Add to favorites + Remove from favorites + + + No favorites yet + Tap the heart icon on a cocktail to save it here. + Unlock Favorites + + + Account + Logged in as + Logout + User ID + Enter any user ID + Login + Premium Status + Active + Free + Anonymous ID + Copied! + Copy + + + Purchases + Restore Purchases + Show Onboarding + + + Purchasely SDK + Default mode is Paywall Observer. + + + Data Privacy + Analytics + Anonymous audience measurement + Identified Analytics + User-identified analytics + Personalization + Personalized content & offers + Campaigns + Promotional campaigns + Third-party Integrations + External analytics & integrations + Technical processing required for app operation cannot be disabled. + + + Appearance + Light + Dark + System + + + Screen Display Mode + How paywalls are presented on screen + Full + Modal + Drawer + Popin + + + About + Version + Powered by Purchasely + + + Purchases restored successfully! + No purchases to restore + + + Discover cocktails + + + Home + Favorites + Settings + + + Spirit + Category + Difficulty + Clear all From 7e26cd17a233036f8bcce135e3cb1fc09f511426 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 11:01:56 +0200 Subject: [PATCH 08/15] refactor(android): add Repository interfaces in domain layer Extract domain interfaces (CocktailRepository, FavoritesRepository, OnboardingRepository, PremiumRepository) so ViewModels depend on abstractions instead of concrete data-layer classes. Rename data implementations to *Impl, update DI bindings, and migrate all ViewModel constructors and tests to use the domain interfaces. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/purchasely/shaker/ShakerApp.kt | 6 ++--- ...epository.kt => CocktailRepositoryImpl.kt} | 13 ++++++----- ...pository.kt => FavoritesRepositoryImpl.kt} | 13 ++++++----- ...ository.kt => OnboardingRepositoryImpl.kt} | 5 +++-- ...remiumManager.kt => PremiumManagerImpl.kt} | 7 +++--- .../com/purchasely/shaker/di/AppModule.kt | 22 +++++++++++-------- .../domain/repository/CocktailRepository.kt | 11 ++++++++++ .../domain/repository/FavoritesRepository.kt | 11 ++++++++++ .../domain/repository/OnboardingRepository.kt | 5 +++++ .../domain/repository/PremiumRepository.kt | 8 +++++++ .../shaker/ui/navigation/Navigation.kt | 2 +- .../ui/screen/detail/DetailViewModel.kt | 12 +++++----- .../ui/screen/favorites/FavoritesViewModel.kt | 12 +++++----- .../shaker/ui/screen/home/HomeViewModel.kt | 10 ++++----- .../screen/onboarding/OnboardingViewModel.kt | 6 ++--- .../ui/screen/settings/SettingsViewModel.kt | 14 ++++++------ ...Test.kt => FavoritesRepositoryImplTest.kt} | 4 ++-- ...est.kt => OnboardingRepositoryImplTest.kt} | 10 ++++----- .../ui/screen/detail/DetailViewModelTest.kt | 18 +++++++-------- .../favorites/FavoritesViewModelTest.kt | 14 ++++++------ .../ui/screen/home/HomeViewModelTest.kt | 16 +++++++------- .../screen/settings/SettingsViewModelTest.kt | 16 +++++++------- 22 files changed, 139 insertions(+), 96 deletions(-) rename android/app/src/main/java/com/purchasely/shaker/data/{CocktailRepository.kt => CocktailRepositoryImpl.kt} (55%) rename android/app/src/main/java/com/purchasely/shaker/data/{FavoritesRepository.kt => FavoritesRepositoryImpl.kt} (69%) rename android/app/src/main/java/com/purchasely/shaker/data/{OnboardingRepository.kt => OnboardingRepositoryImpl.kt} (59%) rename android/app/src/main/java/com/purchasely/shaker/data/{PremiumManager.kt => PremiumManagerImpl.kt} (83%) create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/repository/CocktailRepository.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/repository/FavoritesRepository.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/repository/OnboardingRepository.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/repository/PremiumRepository.kt rename android/app/src/test/java/com/purchasely/shaker/data/{FavoritesRepositoryTest.kt => FavoritesRepositoryImplTest.kt} (96%) rename android/app/src/test/java/com/purchasely/shaker/data/{OnboardingRepositoryTest.kt => OnboardingRepositoryImplTest.kt} (81%) 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 ee11481..6bd1f80 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt @@ -1,7 +1,7 @@ package com.purchasely.shaker import android.app.Application -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.di.appModule import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.purchasely.ext.LogLevel @@ -12,7 +12,7 @@ import org.koin.core.context.startKoin class ShakerApp : Application() { private val purchaselyWrapper: PurchaselyWrapper by inject() - private val premiumManager: PremiumManager by inject() + private val premiumRepository: PremiumRepository by inject() override fun onCreate() { super.onCreate() @@ -26,7 +26,7 @@ class ShakerApp : Application() { application = this, apiKey = BuildConfig.PURCHASELY_API_KEY, logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN, - onConfigured = { premiumManager.refreshPremiumStatus() } + onConfigured = { premiumRepository.refreshPremiumStatus() } ) } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/CocktailRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/CocktailRepositoryImpl.kt similarity index 55% rename from android/app/src/main/java/com/purchasely/shaker/data/CocktailRepository.kt rename to android/app/src/main/java/com/purchasely/shaker/data/CocktailRepositoryImpl.kt index 57afe7e..2c69f69 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/CocktailRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/CocktailRepositoryImpl.kt @@ -3,14 +3,15 @@ package com.purchasely.shaker.data import android.content.Context import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.domain.model.CocktailsData +import com.purchasely.shaker.domain.repository.CocktailRepository import kotlinx.serialization.json.Json -class CocktailRepository(private val context: Context) { +class CocktailRepositoryImpl(private val context: Context) : CocktailRepository { private val json = Json { ignoreUnknownKeys = true } private var cocktails: List = emptyList() - fun loadCocktails(): List { + override fun loadCocktails(): List { if (cocktails.isNotEmpty()) return cocktails val jsonString = context.assets.open("cocktails.json") @@ -21,11 +22,11 @@ class CocktailRepository(private val context: Context) { return cocktails } - fun getCocktail(id: String): Cocktail? { + override fun getCocktail(id: String): Cocktail? { return loadCocktails().find { it.id == id } } - fun getSpirits(): List = loadCocktails().map { it.spirit }.distinct().sorted() - fun getCategories(): List = loadCocktails().map { it.category }.distinct().sorted() - fun getDifficulties(): List = loadCocktails().map { it.difficulty }.distinct() + override fun getSpirits(): List = loadCocktails().map { it.spirit }.distinct().sorted() + override fun getCategories(): List = loadCocktails().map { it.category }.distinct().sorted() + override fun getDifficulties(): List = loadCocktails().map { it.difficulty }.distinct() } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepositoryImpl.kt similarity index 69% rename from android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt rename to android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepositoryImpl.kt index b967723..42b8dc6 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/FavoritesRepositoryImpl.kt @@ -1,22 +1,23 @@ package com.purchasely.shaker.data import com.purchasely.shaker.data.storage.KeyValueStore +import com.purchasely.shaker.domain.repository.FavoritesRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -class FavoritesRepository(private val store: KeyValueStore) { +class FavoritesRepositoryImpl(private val store: KeyValueStore) : FavoritesRepository { private val _favoriteIds = MutableStateFlow>(emptySet()) - val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + override val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() init { _favoriteIds.value = store.getStringSet(KEY_FAVORITES) } - fun isFavorite(cocktailId: String): Boolean = _favoriteIds.value.contains(cocktailId) + override fun isFavorite(cocktailId: String): Boolean = _favoriteIds.value.contains(cocktailId) - fun toggleFavorite(cocktailId: String) { + override fun toggleFavorite(cocktailId: String) { val current = _favoriteIds.value.toMutableSet() if (current.contains(cocktailId)) { current.remove(cocktailId) @@ -27,14 +28,14 @@ class FavoritesRepository(private val store: KeyValueStore) { store.putStringSet(KEY_FAVORITES, current) } - fun addFavorite(cocktailId: String) { + override fun addFavorite(cocktailId: String) { val current = _favoriteIds.value.toMutableSet() current.add(cocktailId) _favoriteIds.value = current store.putStringSet(KEY_FAVORITES, current) } - fun removeFavorite(cocktailId: String) { + override fun removeFavorite(cocktailId: String) { val current = _favoriteIds.value.toMutableSet() current.remove(cocktailId) _favoriteIds.value = current diff --git a/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepositoryImpl.kt similarity index 59% rename from android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt rename to android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepositoryImpl.kt index 027fa4e..727d454 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepositoryImpl.kt @@ -1,10 +1,11 @@ package com.purchasely.shaker.data import com.purchasely.shaker.data.storage.KeyValueStore +import com.purchasely.shaker.domain.repository.OnboardingRepository -class OnboardingRepository(private val store: KeyValueStore) { +class OnboardingRepositoryImpl(private val store: KeyValueStore) : OnboardingRepository { - var isOnboardingCompleted: Boolean + override var isOnboardingCompleted: Boolean get() = store.getBoolean(KEY_COMPLETED) set(value) { store.putBoolean(KEY_COMPLETED, value) } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/PremiumManager.kt b/android/app/src/main/java/com/purchasely/shaker/data/PremiumManagerImpl.kt similarity index 83% rename from android/app/src/main/java/com/purchasely/shaker/data/PremiumManager.kt rename to android/app/src/main/java/com/purchasely/shaker/data/PremiumManagerImpl.kt index 3106b04..bc6c40b 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/PremiumManager.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/PremiumManagerImpl.kt @@ -1,6 +1,7 @@ package com.purchasely.shaker.data import android.util.Log +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.purchasely.ext.SubscriptionsListener import io.purchasely.models.PLYSubscriptionData @@ -8,12 +9,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -class PremiumManager(private val wrapper: PurchaselyWrapper) { +class PremiumManagerImpl(private val wrapper: PurchaselyWrapper) : PremiumRepository { private val _isPremium = MutableStateFlow(false) - val isPremium: StateFlow = _isPremium.asStateFlow() + override val isPremium: StateFlow = _isPremium.asStateFlow() - fun refreshPremiumStatus() { + override fun refreshPremiumStatus() { // PURCHASELY: Fetch the current user's active subscriptions to determine premium access // Pass false to use cached data; true forces a network refresh // Docs: https://docs.purchasely.com/advanced-features/subscription-status diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index f774f13..30c89cc 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -3,12 +3,16 @@ package com.purchasely.shaker.di import android.content.Context import com.android.billingclient.api.BillingClient import com.android.billingclient.api.PendingPurchasesParams -import com.purchasely.shaker.data.CocktailRepository -import com.purchasely.shaker.data.FavoritesRepository -import com.purchasely.shaker.data.OnboardingRepository -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.data.CocktailRepositoryImpl +import com.purchasely.shaker.data.FavoritesRepositoryImpl +import com.purchasely.shaker.data.OnboardingRepositoryImpl +import com.purchasely.shaker.data.PremiumManagerImpl import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.SettingsRepository +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.domain.repository.FavoritesRepository +import com.purchasely.shaker.domain.repository.OnboardingRepository +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.data.purchase.PurchaseManager import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest @@ -30,7 +34,7 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val appModule = module { - single { CocktailRepository(androidContext()) } + single { CocktailRepositoryImpl(androidContext()) } single(named("favorites")) { SharedPreferencesKeyValueStore( androidContext().getSharedPreferences("shaker_favorites", Context.MODE_PRIVATE) @@ -46,8 +50,8 @@ val appModule = module { androidContext().getSharedPreferences("shaker_settings", Context.MODE_PRIVATE) ) as KeyValueStore } - single { FavoritesRepository(get(named("favorites"))) } - single { OnboardingRepository(get(named("onboarding"))) } + single { FavoritesRepositoryImpl(get(named("favorites"))) } + single { OnboardingRepositoryImpl(get(named("onboarding"))) } single { RunningModeRepository(get(named("settings"))) } single { SettingsRepository(get(named("settings"))) } // Reactive flows for purchase orchestration @@ -80,8 +84,8 @@ val appModule = module { scope = get(named("appScope")) ) } - single { - PremiumManager(wrapper = get()).also { pm -> + single { + PremiumManagerImpl(wrapper = get()).also { pm -> get().onTransactionCompleted = { pm.refreshPremiumStatus() } } } diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/repository/CocktailRepository.kt b/android/app/src/main/java/com/purchasely/shaker/domain/repository/CocktailRepository.kt new file mode 100644 index 0000000..6439f51 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/repository/CocktailRepository.kt @@ -0,0 +1,11 @@ +package com.purchasely.shaker.domain.repository + +import com.purchasely.shaker.domain.model.Cocktail + +interface CocktailRepository { + fun loadCocktails(): List + fun getCocktail(id: String): Cocktail? + fun getSpirits(): List + fun getCategories(): List + fun getDifficulties(): List +} diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/repository/FavoritesRepository.kt b/android/app/src/main/java/com/purchasely/shaker/domain/repository/FavoritesRepository.kt new file mode 100644 index 0000000..6f9a804 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/repository/FavoritesRepository.kt @@ -0,0 +1,11 @@ +package com.purchasely.shaker.domain.repository + +import kotlinx.coroutines.flow.StateFlow + +interface FavoritesRepository { + val favoriteIds: StateFlow> + fun isFavorite(cocktailId: String): Boolean + fun toggleFavorite(cocktailId: String) + fun addFavorite(cocktailId: String) + fun removeFavorite(cocktailId: String) +} diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/repository/OnboardingRepository.kt b/android/app/src/main/java/com/purchasely/shaker/domain/repository/OnboardingRepository.kt new file mode 100644 index 0000000..d54c6f0 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/repository/OnboardingRepository.kt @@ -0,0 +1,5 @@ +package com.purchasely.shaker.domain.repository + +interface OnboardingRepository { + var isOnboardingCompleted: Boolean +} diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/repository/PremiumRepository.kt b/android/app/src/main/java/com/purchasely/shaker/domain/repository/PremiumRepository.kt new file mode 100644 index 0000000..a05bfed --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/repository/PremiumRepository.kt @@ -0,0 +1,8 @@ +package com.purchasely.shaker.domain.repository + +import kotlinx.coroutines.flow.StateFlow + +interface PremiumRepository { + val isPremium: StateFlow + fun refreshPremiumStatus() +} diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt b/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt index 88da108..8fce0f8 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt @@ -30,7 +30,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.purchasely.shaker.data.OnboardingRepository +import com.purchasely.shaker.domain.repository.OnboardingRepository import com.purchasely.shaker.ui.screen.detail.DetailScreen import com.purchasely.shaker.ui.screen.favorites.FavoritesScreen import com.purchasely.shaker.ui.screen.home.HomeScreen diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt index 1b3b75c..0b40fa3 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt @@ -4,9 +4,9 @@ import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.purchasely.shaker.data.CocktailRepository -import com.purchasely.shaker.data.FavoritesRepository -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.domain.repository.FavoritesRepository +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult @@ -22,7 +22,7 @@ import kotlinx.coroutines.launch class DetailViewModel( private val repository: CocktailRepository, - private val premiumManager: PremiumManager, + private val premiumRepository: PremiumRepository, private val favoritesRepository: FavoritesRepository, private val purchaselyWrapper: PurchaselyWrapper, private val cocktailId: String @@ -31,7 +31,7 @@ class DetailViewModel( private val _cocktail = MutableStateFlow(null) val cocktail: StateFlow = _cocktail.asStateFlow() - val isPremium: StateFlow = premiumManager.isPremium + val isPremium: StateFlow = premiumRepository.isPremium val favoriteIds: StateFlow> = favoritesRepository.favoriteIds @@ -136,6 +136,6 @@ class DetailViewModel( } fun onPaywallDismissed() { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt index 4e4576d..a90b3fc 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt @@ -4,9 +4,9 @@ import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.purchasely.shaker.data.CocktailRepository -import com.purchasely.shaker.data.FavoritesRepository -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.domain.repository.FavoritesRepository +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult @@ -23,12 +23,12 @@ import kotlinx.coroutines.launch class FavoritesViewModel( private val cocktailRepository: CocktailRepository, private val favoritesRepository: FavoritesRepository, - private val premiumManager: PremiumManager, + private val premiumRepository: PremiumRepository, private val purchaselyWrapper: PurchaselyWrapper ) : ViewModel() { val favoriteIds: StateFlow> = favoritesRepository.favoriteIds - val isPremium: StateFlow = premiumManager.isPremium + val isPremium: StateFlow = premiumRepository.isPremium private val _favorites = MutableStateFlow>(emptyList()) val favorites: StateFlow> = _favorites.asStateFlow() @@ -84,7 +84,7 @@ class FavoritesViewModel( } fun onPaywallDismissed() { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } companion object { diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt index f5fb020..6531c80 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt @@ -4,8 +4,8 @@ import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.purchasely.shaker.data.CocktailRepository -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult @@ -21,7 +21,7 @@ import kotlinx.coroutines.launch class HomeViewModel( private val repository: CocktailRepository, - private val premiumManager: PremiumManager, + private val premiumRepository: PremiumRepository, private val purchaselyWrapper: PurchaselyWrapper ) : ViewModel() { @@ -31,7 +31,7 @@ class HomeViewModel( private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() - val isPremium: StateFlow = premiumManager.isPremium + val isPremium: StateFlow = premiumRepository.isPremium // Filter state private val _selectedSpirits = MutableStateFlow>(emptySet()) @@ -150,7 +150,7 @@ class HomeViewModel( } fun onPaywallDismissed() { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } private fun applyFilters() { diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt index ea7fcb9..7154803 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt @@ -3,7 +3,7 @@ package com.purchasely.shaker.ui.screen.onboarding import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle @@ -11,7 +11,7 @@ import com.purchasely.shaker.purchasely.PurchaselyWrapper class OnboardingViewModel( private val purchaselyWrapper: PurchaselyWrapper, - private val premiumManager: PremiumManager + private val premiumRepository: PremiumRepository ) : ViewModel() { private var pendingPresentation: PresentationHandle? = null @@ -31,7 +31,7 @@ class OnboardingViewModel( when (result) { is DisplayResult.Purchased, is DisplayResult.Restored -> { Log.d(TAG, "[Shaker] Purchased/Restored from onboarding") - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } is DisplayResult.Cancelled -> { Log.d(TAG, "[Shaker] Onboarding paywall cancelled") 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 becb6b6..ec3a2d4 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 @@ -5,7 +5,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.purchasely.shaker.data.PurchaselySdkMode -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.SettingsRepository import com.purchasely.shaker.purchasely.DisplayResult @@ -23,7 +23,7 @@ import kotlinx.coroutines.launch class SettingsViewModel( private val settingsRepo: SettingsRepository, - private val premiumManager: PremiumManager, + private val premiumRepository: PremiumRepository, private val runningModeRepo: RunningModeRepository, private val purchaselyWrapper: PurchaselyWrapper ) : ViewModel() { @@ -31,7 +31,7 @@ class SettingsViewModel( private val _userId = MutableStateFlow(settingsRepo.userId) val userId: StateFlow = _userId.asStateFlow() - val isPremium: StateFlow = premiumManager.isPremium + val isPremium: StateFlow = premiumRepository.isPremium private val _restoreMessage = MutableStateFlow(null) val restoreMessage: StateFlow = _restoreMessage.asStateFlow() @@ -95,7 +95,7 @@ class SettingsViewModel( // Docs: https://docs.purchasely.com/quick-start/sdk-configuration/user-login purchaselyWrapper.userLogin(userId) { refresh -> if (refresh) { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } Log.d(TAG, "[Shaker] Logged in as: $userId (refresh: $refresh)") } @@ -114,7 +114,7 @@ class SettingsViewModel( purchaselyWrapper.userLogout() _userId.value = null settingsRepo.userId = null - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() Log.d(TAG, "[Shaker] Logged out") } @@ -125,7 +125,7 @@ class SettingsViewModel( // Docs: https://docs.purchasely.com/quick-start/sdk-implementation/restore-purchases purchaselyWrapper.restoreAllProducts( onSuccess = { plan -> - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() _restoreMessage.value = "Purchases restored successfully!" Log.d(TAG, "[Shaker] Restore success: ${plan?.name}") }, @@ -141,7 +141,7 @@ class SettingsViewModel( } fun onPurchaseCompleted() { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } fun showOnboardingPaywall() { diff --git a/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryImplTest.kt similarity index 96% rename from android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt rename to android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryImplTest.kt index d29cdbe..d86ac4b 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/FavoritesRepositoryImplTest.kt @@ -7,7 +7,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -class FavoritesRepositoryTest { +class FavoritesRepositoryImplTest { private lateinit var store: InMemoryKeyValueStore @@ -16,7 +16,7 @@ class FavoritesRepositoryTest { store = InMemoryKeyValueStore() } - private fun createRepository(): FavoritesRepository = FavoritesRepository(store) + private fun createRepository(): FavoritesRepositoryImpl = FavoritesRepositoryImpl(store) @Test fun `initial state is empty when no stored favorites`() { diff --git a/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryImplTest.kt similarity index 81% rename from android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt rename to android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryImplTest.kt index 62c0446..7c070aa 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryImplTest.kt @@ -6,7 +6,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -class OnboardingRepositoryTest { +class OnboardingRepositoryImplTest { private lateinit var store: InMemoryKeyValueStore @@ -17,13 +17,13 @@ class OnboardingRepositoryTest { @Test fun `initial state is false`() { - val repo = OnboardingRepository(store) + val repo = OnboardingRepositoryImpl(store) assertFalse(repo.isOnboardingCompleted) } @Test fun `setting to true persists`() { - val repo = OnboardingRepository(store) + val repo = OnboardingRepositoryImpl(store) repo.isOnboardingCompleted = true assertTrue(repo.isOnboardingCompleted) assertTrue(store.getBoolean("onboarding_completed")) @@ -32,7 +32,7 @@ class OnboardingRepositoryTest { @Test fun `setting to false persists`() { store.putBoolean("onboarding_completed", true) - val repo = OnboardingRepository(store) + val repo = OnboardingRepositoryImpl(store) repo.isOnboardingCompleted = false assertFalse(repo.isOnboardingCompleted) assertFalse(store.getBoolean("onboarding_completed")) @@ -41,7 +41,7 @@ class OnboardingRepositoryTest { @Test fun `reads stored value on creation`() { store.putBoolean("onboarding_completed", true) - val repo = OnboardingRepository(store) + val repo = OnboardingRepositoryImpl(store) assertTrue(repo.isOnboardingCompleted) } } diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt index 64deb2f..27ce5aa 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt @@ -1,8 +1,8 @@ package com.purchasely.shaker.ui.screen.detail -import com.purchasely.shaker.data.CocktailRepository -import com.purchasely.shaker.data.FavoritesRepository -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.domain.repository.FavoritesRepository +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.testCocktail @@ -35,7 +35,7 @@ class DetailViewModelTest { private val mojito = testCocktail("mojito", "Mojito", "Rum", "Classic", "Easy") private lateinit var repository: CocktailRepository - private lateinit var premiumManager: PremiumManager + private lateinit var premiumRepository: PremiumRepository private lateinit var favoritesRepository: FavoritesRepository private lateinit var wrapper: PurchaselyWrapper @@ -47,7 +47,7 @@ class DetailViewModelTest { every { getCocktail("mojito") } returns mojito every { getCocktail("nonexistent") } returns null } - premiumManager = mockk { + premiumRepository = mockk { every { isPremium } returns MutableStateFlow(false) every { refreshPremiumStatus() } returns Unit } @@ -66,7 +66,7 @@ class DetailViewModelTest { } private fun createViewModel(cocktailId: String = "mojito") = - DetailViewModel(repository, premiumManager, favoritesRepository, wrapper, cocktailId) + DetailViewModel(repository, premiumRepository, favoritesRepository, wrapper, cocktailId) @Test fun `loads cocktail by id on init`() { @@ -137,13 +137,13 @@ class DetailViewModelTest { fun `onPaywallDismissed refreshes premium status`() { val vm = createViewModel() vm.onPaywallDismissed() - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test - fun `isPremium exposes premiumManager state`() { + fun `isPremium exposes premiumRepository state`() { val premiumFlow = MutableStateFlow(false) - every { premiumManager.isPremium } returns premiumFlow + every { premiumRepository.isPremium } returns premiumFlow val vm = createViewModel() assertFalse(vm.isPremium.value) premiumFlow.value = true diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt index 51ecc27..9d62299 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModelTest.kt @@ -1,8 +1,8 @@ package com.purchasely.shaker.ui.screen.favorites -import com.purchasely.shaker.data.CocktailRepository -import com.purchasely.shaker.data.FavoritesRepository -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.domain.repository.FavoritesRepository +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.testCocktail @@ -37,7 +37,7 @@ class FavoritesViewModelTest { private lateinit var cocktailRepository: CocktailRepository private lateinit var favoritesRepository: FavoritesRepository - private lateinit var premiumManager: PremiumManager + private lateinit var premiumRepository: PremiumRepository private lateinit var wrapper: PurchaselyWrapper @Before @@ -49,7 +49,7 @@ class FavoritesViewModelTest { favoritesRepository = mockk(relaxed = true) { every { favoriteIds } returns MutableStateFlow(setOf("1", "3")) } - premiumManager = mockk { + premiumRepository = mockk { every { isPremium } returns MutableStateFlow(false) every { refreshPremiumStatus() } returns Unit } @@ -64,7 +64,7 @@ class FavoritesViewModelTest { } private fun createViewModel() = - FavoritesViewModel(cocktailRepository, favoritesRepository, premiumManager, wrapper) + FavoritesViewModel(cocktailRepository, favoritesRepository, premiumRepository, wrapper) @Test fun `favorites returns matching cocktails reactively`() { @@ -100,7 +100,7 @@ class FavoritesViewModelTest { fun `onPaywallDismissed refreshes premium status`() { val vm = createViewModel() vm.onPaywallDismissed() - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt index f033875..dbb7a2c 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt @@ -1,7 +1,7 @@ package com.purchasely.shaker.ui.screen.home -import com.purchasely.shaker.data.CocktailRepository -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.testCocktail @@ -37,7 +37,7 @@ class HomeViewModelTest { ) private lateinit var repository: CocktailRepository - private lateinit var premiumManager: PremiumManager + private lateinit var premiumRepository: PremiumRepository private lateinit var wrapper: PurchaselyWrapper @Before @@ -49,7 +49,7 @@ class HomeViewModelTest { every { getCategories() } returns listOf("Bitter", "Classic", "Tropical") every { getDifficulties() } returns listOf("Easy", "Medium", "Hard") } - premiumManager = mockk { + premiumRepository = mockk { every { isPremium } returns MutableStateFlow(false) every { refreshPremiumStatus() } returns Unit } @@ -63,7 +63,7 @@ class HomeViewModelTest { Dispatchers.resetMain() } - private fun createViewModel() = HomeViewModel(repository, premiumManager, wrapper) + private fun createViewModel() = HomeViewModel(repository, premiumRepository, wrapper) @Test fun `initial cocktails are loaded from repository`() { @@ -249,7 +249,7 @@ class HomeViewModelTest { @Test fun `init does not prefetch presentations when premium`() { - every { premiumManager.isPremium } returns MutableStateFlow(true) + every { premiumRepository.isPremium } returns MutableStateFlow(true) createViewModel() coVerify(exactly = 0) { wrapper.loadPresentation(any(), any()) } } @@ -258,12 +258,12 @@ class HomeViewModelTest { fun `onPaywallDismissed refreshes premium status`() { val vm = createViewModel() vm.onPaywallDismissed() - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test fun `onFilterClick does nothing when premium`() { - every { premiumManager.isPremium } returns MutableStateFlow(true) + every { premiumRepository.isPremium } returns MutableStateFlow(true) val vm = createViewModel() vm.onFilterClick() // No exception, no paywall display signal diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt index b486251..6e97340 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt @@ -1,7 +1,7 @@ package com.purchasely.shaker.ui.screen.settings import com.purchasely.shaker.data.PurchaselySdkMode -import com.purchasely.shaker.data.PremiumManager +import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.SettingsRepository import com.purchasely.shaker.data.storage.InMemoryKeyValueStore @@ -38,7 +38,7 @@ class SettingsViewModelTest { private lateinit var store: InMemoryKeyValueStore private lateinit var settingsRepo: SettingsRepository - private lateinit var premiumManager: PremiumManager + private lateinit var premiumRepository: PremiumRepository private lateinit var runningModeRepo: RunningModeRepository private lateinit var wrapper: PurchaselyWrapper @@ -48,7 +48,7 @@ class SettingsViewModelTest { store = InMemoryKeyValueStore() settingsRepo = SettingsRepository(store) - premiumManager = mockk { + premiumRepository = mockk { every { isPremium } returns MutableStateFlow(false) every { refreshPremiumStatus() } returns Unit } @@ -67,7 +67,7 @@ class SettingsViewModelTest { Dispatchers.resetMain() } - private fun createViewModel() = SettingsViewModel(settingsRepo, premiumManager, runningModeRepo, wrapper) + private fun createViewModel() = SettingsViewModel(settingsRepo, premiumRepository, runningModeRepo, wrapper) @Test fun `initial userId is null when not stored`() { @@ -122,7 +122,7 @@ class SettingsViewModelTest { } val vm = createViewModel() vm.login("kevin") - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test @@ -133,7 +133,7 @@ class SettingsViewModelTest { assertNull(vm.userId.value) verify { wrapper.userLogout() } assertNull(settingsRepo.userId) - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test @@ -145,7 +145,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.restorePurchases() assertEquals("Purchases restored successfully!", vm.restoreMessage.value) - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test @@ -280,7 +280,7 @@ class SettingsViewModelTest { fun `onPurchaseCompleted refreshes premium status`() { val vm = createViewModel() vm.onPurchaseCompleted() - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test From 86a9dcc13e98e2253c3b8c00cf2097143c34969f Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 14:03:38 +0200 Subject: [PATCH 09/15] refactor(android): type-safe settings with ThemeMode and DisplayMode enums Replaces stringly-typed settings with compile-time safe enums. Unifies RunningModeRepository on PurchaselySdkMode. --- .../shaker/data/RunningModeRepository.kt | 17 ++++++-- .../shaker/data/SettingsRepository.kt | 14 ++++--- .../shaker/domain/model/DisplayMode.kt | 13 ++++++ .../shaker/domain/model/ThemeMode.kt | 12 ++++++ .../ui/screen/settings/SettingsScreen.kt | 12 +++--- .../ui/screen/settings/SettingsViewModel.kt | 14 ++++--- .../shaker/data/RunningModeRepositoryTest.kt | 36 +++++++++++----- .../shaker/data/SettingsRepositoryTest.kt | 42 +++++++++++++++---- .../screen/settings/SettingsViewModelTest.kt | 18 ++++---- 9 files changed, 130 insertions(+), 48 deletions(-) create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/model/DisplayMode.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/model/ThemeMode.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt index 8ae050b..ec46f87 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt @@ -7,12 +7,19 @@ class RunningModeRepository(private val store: KeyValueStore) { var runningMode: PLYRunningMode get() { - val stored = store.getString(KEY_RUNNING_MODE, "full") - return if (stored == "observer") PLYRunningMode.PaywallObserver else PLYRunningMode.Full + val stored = store.getString(KEY_RUNNING_MODE, PurchaselySdkMode.DEFAULT.storageValue) + // Support legacy "observer" value from previous versions + val mode = if (stored == LEGACY_OBSERVER) { + PurchaselySdkMode.PAYWALL_OBSERVER + } else { + PurchaselySdkMode.fromStorage(stored) + } + return mode.runningMode } set(value) { - val str = if (value == PLYRunningMode.PaywallObserver) "observer" else "full" - store.putString(KEY_RUNNING_MODE, str) + val mode = PurchaselySdkMode.entries.firstOrNull { it.runningMode == value } + ?: PurchaselySdkMode.DEFAULT + store.putString(KEY_RUNNING_MODE, mode.storageValue) } val isObserverMode: Boolean @@ -20,5 +27,7 @@ class RunningModeRepository(private val store: KeyValueStore) { companion object { private const val KEY_RUNNING_MODE = "running_mode" + /** Legacy storage value from before PurchaselySdkMode unification */ + private const val LEGACY_OBSERVER = "observer" } } diff --git a/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt index 7e4ab08..2b089c6 100644 --- a/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt +++ b/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt @@ -1,6 +1,8 @@ package com.purchasely.shaker.data import com.purchasely.shaker.data.storage.KeyValueStore +import com.purchasely.shaker.domain.model.DisplayMode +import com.purchasely.shaker.domain.model.ThemeMode class SettingsRepository(private val store: KeyValueStore) { @@ -10,13 +12,13 @@ class SettingsRepository(private val store: KeyValueStore) { if (value != null) store.putString(KEY_USER_ID, value) else store.remove(KEY_USER_ID) } - var themeMode: String - get() = store.getString(KEY_THEME, "system") ?: "system" - set(value) = store.putString(KEY_THEME, value) + var themeMode: ThemeMode + get() = ThemeMode.fromStorage(store.getString(KEY_THEME)) + set(value) = store.putString(KEY_THEME, value.storageValue) - var displayMode: String - get() = store.getString(KEY_DISPLAY_MODE, "fullscreen") ?: "fullscreen" - set(value) = store.putString(KEY_DISPLAY_MODE, value) + var displayMode: DisplayMode + get() = DisplayMode.fromStorage(store.getString(KEY_DISPLAY_MODE)) + set(value) = store.putString(KEY_DISPLAY_MODE, value.storageValue) var sdkModeStorage: String get() = store.getString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue) diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/model/DisplayMode.kt b/android/app/src/main/java/com/purchasely/shaker/domain/model/DisplayMode.kt new file mode 100644 index 0000000..7b8d0ba --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/model/DisplayMode.kt @@ -0,0 +1,13 @@ +package com.purchasely.shaker.domain.model + +enum class DisplayMode(val storageValue: String, val label: String) { + FULLSCREEN("fullscreen", "Full"), + MODAL("modal", "Modal"), + DRAWER("drawer", "Drawer"), + POPIN("popin", "Popin"); + + companion object { + fun fromStorage(value: String?): DisplayMode = + entries.firstOrNull { it.storageValue == value } ?: FULLSCREEN + } +} diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/model/ThemeMode.kt b/android/app/src/main/java/com/purchasely/shaker/domain/model/ThemeMode.kt new file mode 100644 index 0000000..0aa1032 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/model/ThemeMode.kt @@ -0,0 +1,12 @@ +package com.purchasely.shaker.domain.model + +enum class ThemeMode(val storageValue: String, val label: String) { + LIGHT("light", "Light"), + DARK("dark", "Dark"), + SYSTEM("system", "System"); + + companion object { + fun fromStorage(value: String?): ThemeMode = + entries.firstOrNull { it.storageValue == value } ?: SYSTEM + } +} 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 df8f6c4..259bf5a 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 @@ -48,6 +48,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.purchasely.shaker.R import com.purchasely.shaker.data.PurchaselySdkMode +import com.purchasely.shaker.domain.model.DisplayMode +import com.purchasely.shaker.domain.model.ThemeMode import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @@ -309,8 +311,7 @@ fun SettingsScreen( ) Spacer(modifier = Modifier.height(12.dp)) - val themes = listOf("light", "dark", "system") - val labels = listOf(stringResource(R.string.light), stringResource(R.string.dark), stringResource(R.string.system)) + val themes = ThemeMode.entries SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { themes.forEachIndexed { index, mode -> SegmentedButton( @@ -318,7 +319,7 @@ fun SettingsScreen( onClick = { viewModel.setThemeMode(mode) }, shape = SegmentedButtonDefaults.itemShape(index = index, count = themes.size) ) { - Text(labels[index]) + Text(mode.label) } } } @@ -341,8 +342,7 @@ fun SettingsScreen( ) Spacer(modifier = Modifier.height(12.dp)) - val displayModes = listOf("fullscreen", "modal", "drawer", "popin") - val displayLabels = listOf(stringResource(R.string.full), stringResource(R.string.modal), stringResource(R.string.drawer), stringResource(R.string.popin)) + val displayModes = DisplayMode.entries SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { displayModes.forEachIndexed { index, mode -> SegmentedButton( @@ -350,7 +350,7 @@ fun SettingsScreen( onClick = { viewModel.setDisplayMode(mode) }, shape = SegmentedButtonDefaults.itemShape(index = index, count = displayModes.size) ) { - Text(displayLabels[index], style = MaterialTheme.typography.labelSmall) + Text(mode.label, style = MaterialTheme.typography.labelSmall) } } } 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 ec3a2d4..e3cb876 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 @@ -5,6 +5,8 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.purchasely.shaker.data.PurchaselySdkMode +import com.purchasely.shaker.domain.model.DisplayMode +import com.purchasely.shaker.domain.model.ThemeMode import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.SettingsRepository @@ -37,7 +39,7 @@ class SettingsViewModel( val restoreMessage: StateFlow = _restoreMessage.asStateFlow() private val _themeMode = MutableStateFlow(settingsRepo.themeMode) - val themeMode: StateFlow = _themeMode.asStateFlow() + val themeMode: StateFlow = _themeMode.asStateFlow() private val _sdkMode = MutableStateFlow( PurchaselySdkMode.fromStorage(settingsRepo.sdkModeStorage) @@ -69,7 +71,7 @@ class SettingsViewModel( val anonymousId: StateFlow = _anonymousId.asStateFlow() private val _displayMode = MutableStateFlow(settingsRepo.displayMode) - val displayMode: StateFlow = _displayMode.asStateFlow() + val displayMode: StateFlow = _displayMode.asStateFlow() // Signal Screen to display onboarding paywall private var pendingOnboardingPresentation: PresentationHandle? = null @@ -177,18 +179,18 @@ class SettingsViewModel( } } - fun setDisplayMode(mode: String) { + fun setDisplayMode(mode: DisplayMode) { _displayMode.value = mode settingsRepo.displayMode = mode - Log.d(TAG, "[Shaker] Display mode changed to: $mode") + Log.d(TAG, "[Shaker] Display mode changed to: ${mode.storageValue}") } - fun setThemeMode(mode: String) { + fun setThemeMode(mode: ThemeMode) { _themeMode.value = mode settingsRepo.themeMode = mode // PURCHASELY: Track the user's preferred theme as a custom attribute for audience segmentation // Docs: https://docs.purchasely.com/advanced-features/user-attributes - purchaselyWrapper.setUserAttribute("app_theme", mode) + purchaselyWrapper.setUserAttribute("app_theme", mode.storageValue) } fun setSdkMode(mode: PurchaselySdkMode) { diff --git a/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt index ca9cff7..a92fff7 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/RunningModeRepositoryTest.kt @@ -18,26 +18,34 @@ class RunningModeRepositoryTest { } @Test - fun `default mode is Full`() { + fun `default mode is PaywallObserver (PurchaselySdkMode DEFAULT)`() { val repo = RunningModeRepository(store) - assertEquals(PLYRunningMode.Full, repo.runningMode) + assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) } @Test - fun `isObserverMode is false when Full`() { + fun `isObserverMode is true by default`() { val repo = RunningModeRepository(store) - assertFalse(repo.isObserverMode) + assertTrue(repo.isObserverMode) } @Test - fun `setting to PaywallObserver persists observer string`() { + fun `setting to PaywallObserver persists paywallObserver string`() { val repo = RunningModeRepository(store) repo.runningMode = PLYRunningMode.PaywallObserver - assertEquals("observer", store.getString("running_mode")) + assertEquals("paywallObserver", store.getString("running_mode")) + } + + @Test + fun `reading paywallObserver from storage`() { + store.putString("running_mode", "paywallObserver") + val repo = RunningModeRepository(store) + assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) + assertTrue(repo.isObserverMode) } @Test - fun `reading PaywallObserver from storage`() { + fun `legacy observer value migrates to PaywallObserver`() { store.putString("running_mode", "observer") val repo = RunningModeRepository(store) assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) @@ -46,16 +54,24 @@ class RunningModeRepositoryTest { @Test fun `setting to Full persists full string`() { - store.putString("running_mode", "observer") + store.putString("running_mode", "paywallObserver") val repo = RunningModeRepository(store) repo.runningMode = PLYRunningMode.Full assertEquals("full", store.getString("running_mode")) } @Test - fun `unknown stored value defaults to Full`() { - store.putString("running_mode", "unknown") + fun `reading Full from storage`() { + store.putString("running_mode", "full") val repo = RunningModeRepository(store) assertEquals(PLYRunningMode.Full, repo.runningMode) + assertFalse(repo.isObserverMode) + } + + @Test + fun `unknown stored value defaults to PaywallObserver`() { + store.putString("running_mode", "unknown") + val repo = RunningModeRepository(store) + assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) } } diff --git a/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt index cfd2aaa..b26830f 100644 --- a/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt @@ -1,6 +1,8 @@ package com.purchasely.shaker.data import com.purchasely.shaker.data.storage.InMemoryKeyValueStore +import com.purchasely.shaker.domain.model.DisplayMode +import com.purchasely.shaker.domain.model.ThemeMode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -39,25 +41,49 @@ class SettingsRepositoryTest { } @Test - fun `themeMode defaults to system`() { - assertEquals("system", repo.themeMode) + fun `themeMode defaults to SYSTEM`() { + assertEquals(ThemeMode.SYSTEM, repo.themeMode) } @Test fun `themeMode round-trips`() { - repo.themeMode = "dark" - assertEquals("dark", repo.themeMode) + repo.themeMode = ThemeMode.DARK + assertEquals(ThemeMode.DARK, repo.themeMode) } @Test - fun `displayMode defaults to fullscreen`() { - assertEquals("fullscreen", repo.displayMode) + fun `themeMode stores raw string value`() { + repo.themeMode = ThemeMode.LIGHT + assertEquals("light", store.getString("theme_mode")) + } + + @Test + fun `themeMode falls back to SYSTEM for unknown value`() { + store.putString("theme_mode", "unknown") + assertEquals(ThemeMode.SYSTEM, repo.themeMode) + } + + @Test + fun `displayMode defaults to FULLSCREEN`() { + assertEquals(DisplayMode.FULLSCREEN, repo.displayMode) } @Test fun `displayMode round-trips`() { - repo.displayMode = "embedded" - assertEquals("embedded", repo.displayMode) + repo.displayMode = DisplayMode.MODAL + assertEquals(DisplayMode.MODAL, repo.displayMode) + } + + @Test + fun `displayMode stores raw string value`() { + repo.displayMode = DisplayMode.DRAWER + assertEquals("drawer", store.getString("display_mode")) + } + + @Test + fun `displayMode falls back to FULLSCREEN for unknown value`() { + store.putString("display_mode", "unknown") + assertEquals(DisplayMode.FULLSCREEN, repo.displayMode) } @Test diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt index 6e97340..2c5d3d9 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt @@ -1,6 +1,8 @@ package com.purchasely.shaker.ui.screen.settings import com.purchasely.shaker.data.PurchaselySdkMode +import com.purchasely.shaker.domain.model.DisplayMode +import com.purchasely.shaker.domain.model.ThemeMode import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.SettingsRepository @@ -169,24 +171,24 @@ class SettingsViewModelTest { @Test fun `setThemeMode persists and sets user attribute`() { val vm = createViewModel() - vm.setThemeMode("dark") - assertEquals("dark", vm.themeMode.value) - assertEquals("dark", settingsRepo.themeMode) + vm.setThemeMode(ThemeMode.DARK) + assertEquals(ThemeMode.DARK, vm.themeMode.value) + assertEquals(ThemeMode.DARK, settingsRepo.themeMode) verify { wrapper.setUserAttribute("app_theme", "dark") } } @Test - fun `initial themeMode defaults to system`() { + fun `initial themeMode defaults to SYSTEM`() { val vm = createViewModel() - assertEquals("system", vm.themeMode.value) + assertEquals(ThemeMode.SYSTEM, vm.themeMode.value) } @Test fun `setDisplayMode persists value`() { val vm = createViewModel() - vm.setDisplayMode("embedded") - assertEquals("embedded", vm.displayMode.value) - assertEquals("embedded", settingsRepo.displayMode) + vm.setDisplayMode(DisplayMode.MODAL) + assertEquals(DisplayMode.MODAL, vm.displayMode.value) + assertEquals(DisplayMode.MODAL, settingsRepo.displayMode) } @Test From a238db80068de53395e3220cf83b65c928bc0748 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 14:08:55 +0200 Subject: [PATCH 10/15] refactor(android): remove Activity from ViewModel signatures Move paywall display logic (PurchaselyWrapper.display) from ViewModels to Screens. SharedFlows now emit PresentationHandle instead of Unit, letting Screens resolve Activity and call display() directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/ui/screen/detail/DetailScreen.kt | 20 ++++++-- .../ui/screen/detail/DetailViewModel.kt | 50 +++---------------- .../ui/screen/favorites/FavoritesScreen.kt | 12 ++++- .../ui/screen/favorites/FavoritesViewModel.kt | 23 ++------- .../shaker/ui/screen/home/HomeScreen.kt | 12 ++++- .../shaker/ui/screen/home/HomeViewModel.kt | 24 ++------- .../ui/screen/onboarding/OnboardingScreen.kt | 12 ++++- .../screen/onboarding/OnboardingViewModel.kt | 31 ++---------- .../ui/screen/settings/SettingsScreen.kt | 12 ++++- .../ui/screen/settings/SettingsViewModel.kt | 23 ++------- 10 files changed, 74 insertions(+), 145 deletions(-) diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt index 01ee220..f634ef3 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailScreen.kt @@ -46,8 +46,11 @@ import androidx.compose.ui.res.stringResource import androidx.core.view.WindowCompat import androidx.compose.ui.unit.dp import com.purchasely.shaker.R +import com.purchasely.shaker.purchasely.DisplayResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.components.CocktailImage import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @@ -62,6 +65,7 @@ fun DetailScreen( val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle() val isFavorite = favoriteIds.contains(cocktailId) val context = LocalContext.current + val purchaselyWrapper: PurchaselyWrapper = koinInject() // Force light (white) status bar icons over the hero image val view = LocalView.current @@ -77,17 +81,25 @@ fun DetailScreen( // Collect recipe paywall display requests from ViewModel LaunchedEffect(Unit) { - viewModel.requestRecipePaywall.collect { + viewModel.requestRecipePaywall.collect { handle -> val activity = context as? Activity ?: return@collect - viewModel.displayPendingRecipePaywall(activity) + val result = purchaselyWrapper.display(handle, activity) + when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> viewModel.onPaywallDismissed() + else -> {} + } } } // Collect favorites paywall display requests from ViewModel LaunchedEffect(Unit) { - viewModel.requestFavoritesPaywall.collect { + viewModel.requestFavoritesPaywall.collect { handle -> val activity = context as? Activity ?: return@collect - viewModel.displayPendingFavoritesPaywall(activity) + val result = purchaselyWrapper.display(handle, activity) + when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> viewModel.onPaywallDismissed() + else -> {} + } } } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt index 0b40fa3..d6ffb30 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt @@ -1,6 +1,5 @@ package com.purchasely.shaker.ui.screen.detail -import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -8,7 +7,6 @@ import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.FavoritesRepository import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail -import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper @@ -36,14 +34,12 @@ class DetailViewModel( val favoriteIds: StateFlow> = favoritesRepository.favoriteIds // Signal Screen to display recipe paywall - private var pendingRecipePresentation: PresentationHandle? = null - private val _requestRecipePaywall = MutableSharedFlow() - val requestRecipePaywall: SharedFlow = _requestRecipePaywall.asSharedFlow() + private val _requestRecipePaywall = MutableSharedFlow() + val requestRecipePaywall: SharedFlow = _requestRecipePaywall.asSharedFlow() // Signal Screen to display favorites paywall - private var pendingFavoritesPresentation: PresentationHandle? = null - private val _requestFavoritesPaywall = MutableSharedFlow() - val requestFavoritesPaywall: SharedFlow = _requestFavoritesPaywall.asSharedFlow() + private val _requestFavoritesPaywall = MutableSharedFlow() + val requestFavoritesPaywall: SharedFlow = _requestFavoritesPaywall.asSharedFlow() init { _cocktail.value = repository.getCocktail(cocktailId) @@ -76,8 +72,7 @@ class DetailViewModel( ) when (result) { is FetchResult.Success -> { - pendingRecipePresentation = result.handle - _requestRecipePaywall.emit(Unit) + _requestRecipePaywall.emit(result.handle) } is FetchResult.Client -> { Log.d("DetailViewModel", "[Shaker] CLIENT presentation received for recipe_detail placement — build custom UI here") @@ -87,32 +82,12 @@ class DetailViewModel( } } - suspend fun displayPendingRecipePaywall(activity: Activity) { - val handle = pendingRecipePresentation ?: return - pendingRecipePresentation = null - val result = purchaselyWrapper.display(handle, activity) - when (result) { - is DisplayResult.Purchased -> { - Log.d("DetailViewModel", "[Shaker] Purchased: ${result.planName}") - onPaywallDismissed() - } - is DisplayResult.Restored -> { - Log.d("DetailViewModel", "[Shaker] Restored: ${result.planName}") - onPaywallDismissed() - } - is DisplayResult.Cancelled -> { - Log.d("DetailViewModel", "[Shaker] Cancelled") - } - } - } - fun showFavoritesPaywall() { viewModelScope.launch { val result = purchaselyWrapper.loadPresentation(placementId = "favorites") when (result) { is FetchResult.Success -> { - pendingFavoritesPresentation = result.handle - _requestFavoritesPaywall.emit(Unit) + _requestFavoritesPaywall.emit(result.handle) } is FetchResult.Client -> { Log.d("DetailViewModel", "[Shaker] CLIENT presentation received for favorites placement — build custom UI here") @@ -122,19 +97,6 @@ class DetailViewModel( } } - suspend fun displayPendingFavoritesPaywall(activity: Activity) { - val handle = pendingFavoritesPresentation ?: return - pendingFavoritesPresentation = null - val result = purchaselyWrapper.display(handle, activity) - when (result) { - is DisplayResult.Purchased, is DisplayResult.Restored -> { - Log.d("DetailViewModel", "[Shaker] Purchased/Restored from favorites: ${(result as? DisplayResult.Purchased)?.planName ?: (result as? DisplayResult.Restored)?.planName}") - onPaywallDismissed() - } - else -> {} - } - } - fun onPaywallDismissed() { premiumRepository.refreshPremiumStatus() } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt index 1e1f38a..31f2ba2 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesScreen.kt @@ -39,8 +39,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.purchasely.shaker.R import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.purchasely.DisplayResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.components.CocktailImage import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable fun FavoritesScreen( @@ -50,12 +53,17 @@ fun FavoritesScreen( val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() val favorites by viewModel.favorites.collectAsStateWithLifecycle() val context = LocalContext.current + val purchaselyWrapper: PurchaselyWrapper = koinInject() // Collect paywall display requests from ViewModel LaunchedEffect(Unit) { - viewModel.requestPaywallDisplay.collect { + viewModel.requestPaywallDisplay.collect { handle -> val activity = context as? Activity ?: return@collect - viewModel.displayPendingPaywall(activity) + val result = purchaselyWrapper.display(handle, activity) + when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> viewModel.onPaywallDismissed() + else -> {} + } } } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt index a90b3fc..0f8459e 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt @@ -1,6 +1,5 @@ package com.purchasely.shaker.ui.screen.favorites -import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -8,7 +7,6 @@ import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.FavoritesRepository import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail -import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper @@ -34,9 +32,8 @@ class FavoritesViewModel( val favorites: StateFlow> = _favorites.asStateFlow() // Signal Screen to display favorites paywall - private var pendingPresentation: PresentationHandle? = null - private val _requestPaywallDisplay = MutableSharedFlow() - val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() + private val _requestPaywallDisplay = MutableSharedFlow() + val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() init { viewModelScope.launch { @@ -54,8 +51,7 @@ class FavoritesViewModel( viewModelScope.launch { when (val result = purchaselyWrapper.loadPresentation("favorites")) { is FetchResult.Success -> { - pendingPresentation = result.handle - _requestPaywallDisplay.emit(Unit) + _requestPaywallDisplay.emit(result.handle) } is FetchResult.Client -> { Log.d(TAG, "[Shaker] CLIENT presentation received for favorites placement — build custom UI here") @@ -70,19 +66,6 @@ class FavoritesViewModel( } } - suspend fun displayPendingPaywall(activity: Activity) { - val handle = pendingPresentation ?: return - pendingPresentation = null - val result = purchaselyWrapper.display(handle, activity) - when (result) { - is DisplayResult.Purchased, is DisplayResult.Restored -> { - Log.d(TAG, "[Shaker] Purchased/Restored from favorites") - onPaywallDismissed() - } - else -> {} - } - } - fun onPaywallDismissed() { premiumRepository.refreshPremiumStatus() } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt index 15e94c6..4f5549a 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeScreen.kt @@ -51,10 +51,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.purchasely.shaker.R import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.EmbeddedScreenBanner import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.ui.components.CocktailImage import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -69,13 +72,18 @@ fun HomeScreen( val hasActiveFilters by viewModel.hasActiveFilters.collectAsStateWithLifecycle() val inlinePresentation by viewModel.inlinePresentation.collectAsStateWithLifecycle() val context = LocalContext.current + val purchaselyWrapper: PurchaselyWrapper = koinInject() var showFilterSheet by remember { mutableStateOf(false) } // Collect paywall display requests from ViewModel LaunchedEffect(Unit) { - viewModel.requestPaywallDisplay.collect { + viewModel.requestPaywallDisplay.collect { handle -> val activity = context as? Activity ?: return@collect - viewModel.displayPendingPaywall(activity) + val result = purchaselyWrapper.display(handle, activity) + when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> viewModel.onPaywallDismissed() + else -> {} + } } } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt index 6531c80..ad4799a 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt @@ -1,13 +1,10 @@ package com.purchasely.shaker.ui.screen.home -import android.app.Activity -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail -import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper @@ -68,9 +65,8 @@ class HomeViewModel( val isFiltersLoading: StateFlow = _isFiltersLoading.asStateFlow() // Signal Screen to display filters paywall - private var pendingFiltersPresentation: PresentationHandle? = null - private val _requestPaywallDisplay = MutableSharedFlow() - val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() + private val _requestPaywallDisplay = MutableSharedFlow() + val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() init { _cocktails.value = repository.loadCocktails() @@ -101,21 +97,7 @@ class HomeViewModel( if (isPremium.value) return val result = _filtersPresentation.value if (result is FetchResult.Success) { - pendingFiltersPresentation = result.handle - viewModelScope.launch { _requestPaywallDisplay.emit(Unit) } - } - } - - suspend fun displayPendingPaywall(activity: Activity) { - val handle = pendingFiltersPresentation ?: return - pendingFiltersPresentation = null - val result = purchaselyWrapper.display(handle, activity) - when (result) { - is DisplayResult.Purchased, is DisplayResult.Restored -> { - Log.d("HomeViewModel", "[Shaker] Purchased/Restored from filters") - onPaywallDismissed() - } - else -> {} + viewModelScope.launch { _requestPaywallDisplay.emit(result.handle) } } } diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt index 22f17b9..c439821 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingScreen.kt @@ -22,8 +22,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.purchasely.shaker.R +import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable fun OnboardingScreen( @@ -32,6 +35,7 @@ fun OnboardingScreen( viewModel: OnboardingViewModel = koinViewModel() ) { val context = LocalContext.current + val purchaselyWrapper: PurchaselyWrapper = koinInject() LaunchedEffect(Unit) { if (!showOnboarding) { @@ -45,9 +49,13 @@ fun OnboardingScreen( return@LaunchedEffect } - when (viewModel.loadOnboarding()) { + when (val fetchResult = viewModel.loadOnboarding()) { is FetchResult.Success -> { - viewModel.displayOnboarding(activity) + val displayResult = purchaselyWrapper.display(fetchResult.handle, activity) + when (displayResult) { + is DisplayResult.Purchased, is DisplayResult.Restored -> viewModel.onPurchaseCompleted() + else -> {} + } onComplete() } else -> onComplete() diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt index 7154803..22b2553 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt @@ -1,10 +1,7 @@ package com.purchasely.shaker.ui.screen.onboarding -import android.app.Activity -import android.util.Log import androidx.lifecycle.ViewModel import com.purchasely.shaker.domain.repository.PremiumRepository -import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper @@ -14,33 +11,11 @@ class OnboardingViewModel( private val premiumRepository: PremiumRepository ) : ViewModel() { - private var pendingPresentation: PresentationHandle? = null - suspend fun loadOnboarding(): FetchResult { - val result = purchaselyWrapper.loadPresentation("onboarding") - if (result is FetchResult.Success) { - pendingPresentation = result.handle - } - return result - } - - suspend fun displayOnboarding(activity: Activity): DisplayResult? { - val handle = pendingPresentation ?: return null - pendingPresentation = null - val result = purchaselyWrapper.display(handle, activity) - when (result) { - is DisplayResult.Purchased, is DisplayResult.Restored -> { - Log.d(TAG, "[Shaker] Purchased/Restored from onboarding") - premiumRepository.refreshPremiumStatus() - } - is DisplayResult.Cancelled -> { - Log.d(TAG, "[Shaker] Onboarding paywall cancelled") - } - } - return result + return purchaselyWrapper.loadPresentation("onboarding") } - companion object { - private const val TAG = "OnboardingViewModel" + fun onPurchaseCompleted() { + premiumRepository.refreshPremiumStatus() } } 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 259bf5a..bbca3ae 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 @@ -50,8 +50,11 @@ import com.purchasely.shaker.R import com.purchasely.shaker.data.PurchaselySdkMode import com.purchasely.shaker.domain.model.DisplayMode import com.purchasely.shaker.domain.model.ThemeMode +import com.purchasely.shaker.purchasely.DisplayResult +import com.purchasely.shaker.purchasely.PurchaselyWrapper import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable fun SettingsScreen( @@ -74,6 +77,7 @@ fun SettingsScreen( val clipboard = LocalClipboard.current val clipboardScope = rememberCoroutineScope() val context = LocalContext.current + val purchaselyWrapper: PurchaselyWrapper = koinInject() var loginInput by remember { mutableStateOf("") } // Show restore toast @@ -86,9 +90,13 @@ fun SettingsScreen( // Collect paywall display requests from ViewModel LaunchedEffect(Unit) { - viewModel.requestPaywallDisplay.collect { + viewModel.requestPaywallDisplay.collect { handle -> val activity = context as? Activity ?: return@collect - viewModel.displayPendingPaywall(activity) + val result = purchaselyWrapper.display(handle, activity) + when (result) { + is DisplayResult.Purchased, is DisplayResult.Restored -> viewModel.onPurchaseCompleted() + else -> {} + } } } 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 e3cb876..7f6e20a 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 @@ -1,6 +1,5 @@ package com.purchasely.shaker.ui.screen.settings -import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -10,7 +9,6 @@ import com.purchasely.shaker.domain.model.ThemeMode import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.data.RunningModeRepository import com.purchasely.shaker.data.SettingsRepository -import com.purchasely.shaker.purchasely.DisplayResult import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper @@ -74,9 +72,8 @@ class SettingsViewModel( val displayMode: StateFlow = _displayMode.asStateFlow() // Signal Screen to display onboarding paywall - private var pendingOnboardingPresentation: PresentationHandle? = null - private val _requestPaywallDisplay = MutableSharedFlow() - val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() + private val _requestPaywallDisplay = MutableSharedFlow() + val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() val sdkVersion: String get() = purchaselyWrapper.sdkVersion @@ -150,8 +147,7 @@ class SettingsViewModel( viewModelScope.launch { when (val result = purchaselyWrapper.loadPresentation("onboarding")) { is FetchResult.Success -> { - pendingOnboardingPresentation = result.handle - _requestPaywallDisplay.emit(Unit) + _requestPaywallDisplay.emit(result.handle) } is FetchResult.Client -> { Log.d(TAG, "[Shaker] CLIENT presentation received for onboarding placement — build custom UI here") @@ -166,19 +162,6 @@ class SettingsViewModel( } } - suspend fun displayPendingPaywall(activity: Activity) { - val handle = pendingOnboardingPresentation ?: return - pendingOnboardingPresentation = null - val result = purchaselyWrapper.display(handle, activity) - when (result) { - is DisplayResult.Purchased, is DisplayResult.Restored -> { - Log.d(TAG, "[Shaker] Purchased/Restored from onboarding") - onPurchaseCompleted() - } - else -> {} - } - } - fun setDisplayMode(mode: DisplayMode) { _displayMode.value = mode settingsRepo.displayMode = mode From bb24aac4311054c20e7b7d1e77821352e5313317 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 14:12:53 +0200 Subject: [PATCH 11/15] refactor(android): introduce UseCases for key business operations Extract filtering logic into GetFilteredCocktailsUseCase and favorite toggling into ToggleFavoriteUseCase. HomeViewModel and DetailViewModel now delegate to these UseCases instead of implementing logic directly. Includes 14 new unit tests for the UseCases. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/purchasely/shaker/di/AppModule.kt | 8 +- .../usecase/GetFilteredCocktailsUseCase.kt | 23 ++++ .../domain/usecase/ToggleFavoriteUseCase.kt | 11 ++ .../ui/screen/detail/DetailViewModel.kt | 4 +- .../shaker/ui/screen/home/HomeViewModel.kt | 24 ++-- .../GetFilteredCocktailsUseCaseTest.kt | 116 ++++++++++++++++++ .../usecase/ToggleFavoriteUseCaseTest.kt | 31 +++++ .../ui/screen/detail/DetailViewModelTest.kt | 5 +- .../ui/screen/home/HomeViewModelTest.kt | 5 +- 9 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCase.kt create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCase.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCaseTest.kt create mode 100644 android/app/src/test/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCaseTest.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt index 30c89cc..b4240fa 100644 --- a/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt +++ b/android/app/src/main/java/com/purchasely/shaker/di/AppModule.kt @@ -13,6 +13,8 @@ import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.FavoritesRepository import com.purchasely.shaker.domain.repository.OnboardingRepository import com.purchasely.shaker.domain.repository.PremiumRepository +import com.purchasely.shaker.domain.usecase.GetFilteredCocktailsUseCase +import com.purchasely.shaker.domain.usecase.ToggleFavoriteUseCase import com.purchasely.shaker.data.purchase.PurchaseManager import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest @@ -89,9 +91,11 @@ val appModule = module { get().onTransactionCompleted = { pm.refreshPremiumStatus() } } } + factory { GetFilteredCocktailsUseCase(get()) } + factory { ToggleFavoriteUseCase(get()) } viewModel { OnboardingViewModel(get(), get()) } - viewModel { HomeViewModel(get(), get(), get()) } - viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } + viewModel { HomeViewModel(get(), get(), get(), get()) } + viewModel { params -> DetailViewModel(get(), get(), get(), get(), get(), params.get()) } viewModel { FavoritesViewModel(get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get(), get()) } } diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCase.kt b/android/app/src/main/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCase.kt new file mode 100644 index 0000000..17c68c0 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCase.kt @@ -0,0 +1,23 @@ +package com.purchasely.shaker.domain.usecase + +import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.domain.repository.CocktailRepository + +class GetFilteredCocktailsUseCase( + private val repository: CocktailRepository +) { + operator fun invoke( + query: String = "", + spirits: Set = emptySet(), + categories: Set = emptySet(), + difficulty: String? = null + ): List { + return repository.loadCocktails().filter { cocktail -> + val matchesQuery = query.isBlank() || cocktail.name.contains(query, ignoreCase = true) + val matchesSpirit = spirits.isEmpty() || spirits.contains(cocktail.spirit) + val matchesCategory = categories.isEmpty() || categories.contains(cocktail.category) + val matchesDifficulty = difficulty == null || cocktail.difficulty == difficulty + matchesQuery && matchesSpirit && matchesCategory && matchesDifficulty + } + } +} diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCase.kt b/android/app/src/main/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCase.kt new file mode 100644 index 0000000..9a2ee2f --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCase.kt @@ -0,0 +1,11 @@ +package com.purchasely.shaker.domain.usecase + +import com.purchasely.shaker.domain.repository.FavoritesRepository + +class ToggleFavoriteUseCase( + private val favoritesRepository: FavoritesRepository +) { + operator fun invoke(cocktailId: String) { + favoritesRepository.toggleFavorite(cocktailId) + } +} diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt index d6ffb30..43d6aa7 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt @@ -7,6 +7,7 @@ import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.FavoritesRepository import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.domain.usecase.ToggleFavoriteUseCase import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper @@ -23,6 +24,7 @@ class DetailViewModel( private val premiumRepository: PremiumRepository, private val favoritesRepository: FavoritesRepository, private val purchaselyWrapper: PurchaselyWrapper, + private val toggleFavoriteUseCase: ToggleFavoriteUseCase, private val cocktailId: String ) : ViewModel() { @@ -61,7 +63,7 @@ class DetailViewModel( fun isFavorite(): Boolean = favoritesRepository.isFavorite(cocktailId) fun toggleFavorite() { - favoritesRepository.toggleFavorite(cocktailId) + toggleFavoriteUseCase(cocktailId) } fun showRecipePaywall() { diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt index ad4799a..a38e31a 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.PremiumRepository import com.purchasely.shaker.domain.model.Cocktail +import com.purchasely.shaker.domain.usecase.GetFilteredCocktailsUseCase import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper @@ -19,7 +20,8 @@ import kotlinx.coroutines.launch class HomeViewModel( private val repository: CocktailRepository, private val premiumRepository: PremiumRepository, - private val purchaselyWrapper: PurchaselyWrapper + private val purchaselyWrapper: PurchaselyWrapper, + private val getFilteredCocktails: GetFilteredCocktailsUseCase ) : ViewModel() { private val _cocktails = MutableStateFlow>(emptyList()) @@ -69,7 +71,7 @@ class HomeViewModel( val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() init { - _cocktails.value = repository.loadCocktails() + _cocktails.value = getFilteredCocktails() prefetchPresentations() } @@ -136,17 +138,11 @@ class HomeViewModel( } private fun applyFilters() { - val query = _searchQuery.value - val spirits = _selectedSpirits.value - val categories = _selectedCategories.value - val difficulty = _selectedDifficulty.value - - _cocktails.value = repository.loadCocktails().filter { cocktail -> - val matchesQuery = query.isBlank() || cocktail.name.contains(query, ignoreCase = true) - val matchesSpirit = spirits.isEmpty() || spirits.contains(cocktail.spirit) - val matchesCategory = categories.isEmpty() || categories.contains(cocktail.category) - val matchesDifficulty = difficulty == null || cocktail.difficulty == difficulty - matchesQuery && matchesSpirit && matchesCategory && matchesDifficulty - } + _cocktails.value = getFilteredCocktails( + query = _searchQuery.value, + spirits = _selectedSpirits.value, + categories = _selectedCategories.value, + difficulty = _selectedDifficulty.value + ) } } diff --git a/android/app/src/test/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCaseTest.kt b/android/app/src/test/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCaseTest.kt new file mode 100644 index 0000000..a71ecdc --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/domain/usecase/GetFilteredCocktailsUseCaseTest.kt @@ -0,0 +1,116 @@ +package com.purchasely.shaker.domain.usecase + +import com.purchasely.shaker.domain.repository.CocktailRepository +import com.purchasely.shaker.testCocktail +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class GetFilteredCocktailsUseCaseTest { + + private val testCocktails = listOf( + testCocktail("1", "Mojito", "Rum", "Classic", "Easy"), + testCocktail("2", "Margarita", "Tequila", "Classic", "Medium"), + testCocktail("3", "Negroni", "Gin", "Bitter", "Easy"), + testCocktail("4", "Old Fashioned", "Whiskey", "Classic", "Hard"), + testCocktail("5", "Daiquiri", "Rum", "Tropical", "Easy") + ) + + private lateinit var repository: CocktailRepository + private lateinit var useCase: GetFilteredCocktailsUseCase + + @Before + fun setUp() { + repository = mockk { + every { loadCocktails() } returns testCocktails + } + useCase = GetFilteredCocktailsUseCase(repository) + } + + @Test + fun `no filters returns all cocktails`() { + val result = useCase() + assertEquals(testCocktails, result) + } + + @Test + fun `filter by spirit`() { + val result = useCase(spirits = setOf("Rum")) + assertEquals(2, result.size) + assertTrue(result.all { it.spirit == "Rum" }) + } + + @Test + fun `filter by multiple spirits`() { + val result = useCase(spirits = setOf("Rum", "Gin")) + assertEquals(3, result.size) + assertTrue(result.all { it.spirit in setOf("Rum", "Gin") }) + } + + @Test + fun `filter by query case insensitive`() { + val result = useCase(query = "mojito") + assertEquals(1, result.size) + assertEquals("Mojito", result[0].name) + } + + @Test + fun `filter by query partial match`() { + val result = useCase(query = "Moj") + assertEquals(1, result.size) + assertEquals("Mojito", result[0].name) + } + + @Test + fun `filter by category`() { + val result = useCase(categories = setOf("Classic")) + assertEquals(3, result.size) + assertTrue(result.all { it.category == "Classic" }) + } + + @Test + fun `filter by difficulty`() { + val result = useCase(difficulty = "Easy") + assertEquals(3, result.size) + assertTrue(result.all { it.difficulty == "Easy" }) + } + + @Test + fun `combined filters`() { + val result = useCase( + spirits = setOf("Rum"), + categories = setOf("Classic"), + difficulty = "Easy" + ) + assertEquals(1, result.size) + assertEquals("Mojito", result[0].name) + } + + @Test + fun `combined query and spirit filter`() { + val result = useCase(query = "Daiquiri", spirits = setOf("Rum")) + assertEquals(1, result.size) + assertEquals("Daiquiri", result[0].name) + } + + @Test + fun `empty result when no matches`() { + val result = useCase(query = "NonExistent") + assertTrue(result.isEmpty()) + } + + @Test + fun `empty result with conflicting filters`() { + val result = useCase(spirits = setOf("Gin"), categories = setOf("Classic")) + assertTrue(result.isEmpty()) + } + + @Test + fun `blank query returns all cocktails`() { + val result = useCase(query = " ") + assertEquals(testCocktails, result) + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCaseTest.kt b/android/app/src/test/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCaseTest.kt new file mode 100644 index 0000000..7ecf0a9 --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/domain/usecase/ToggleFavoriteUseCaseTest.kt @@ -0,0 +1,31 @@ +package com.purchasely.shaker.domain.usecase + +import com.purchasely.shaker.domain.repository.FavoritesRepository +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class ToggleFavoriteUseCaseTest { + + private lateinit var favoritesRepository: FavoritesRepository + private lateinit var useCase: ToggleFavoriteUseCase + + @Before + fun setUp() { + favoritesRepository = mockk(relaxed = true) + useCase = ToggleFavoriteUseCase(favoritesRepository) + } + + @Test + fun `invoke delegates to favoritesRepository toggleFavorite`() { + useCase("cocktail-1") + verify { favoritesRepository.toggleFavorite("cocktail-1") } + } + + @Test + fun `invoke passes correct cocktail id`() { + useCase("mojito") + verify { favoritesRepository.toggleFavorite("mojito") } + } +} diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt index 27ce5aa..68546bf 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/detail/DetailViewModelTest.kt @@ -3,6 +3,7 @@ package com.purchasely.shaker.ui.screen.detail import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.FavoritesRepository import com.purchasely.shaker.domain.repository.PremiumRepository +import com.purchasely.shaker.domain.usecase.ToggleFavoriteUseCase import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.testCocktail @@ -38,6 +39,7 @@ class DetailViewModelTest { private lateinit var premiumRepository: PremiumRepository private lateinit var favoritesRepository: FavoritesRepository private lateinit var wrapper: PurchaselyWrapper + private lateinit var toggleFavoriteUseCase: ToggleFavoriteUseCase @Before fun setUp() { @@ -58,6 +60,7 @@ class DetailViewModelTest { wrapper = mockk(relaxed = true) { coEvery { loadPresentation(any(), any()) } returns FetchResult.Deactivated } + toggleFavoriteUseCase = ToggleFavoriteUseCase(favoritesRepository) } @After @@ -66,7 +69,7 @@ class DetailViewModelTest { } private fun createViewModel(cocktailId: String = "mojito") = - DetailViewModel(repository, premiumRepository, favoritesRepository, wrapper, cocktailId) + DetailViewModel(repository, premiumRepository, favoritesRepository, wrapper, toggleFavoriteUseCase, cocktailId) @Test fun `loads cocktail by id on init`() { diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt index dbb7a2c..0d3dec1 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/home/HomeViewModelTest.kt @@ -2,6 +2,7 @@ package com.purchasely.shaker.ui.screen.home import com.purchasely.shaker.domain.repository.CocktailRepository import com.purchasely.shaker.domain.repository.PremiumRepository +import com.purchasely.shaker.domain.usecase.GetFilteredCocktailsUseCase import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.testCocktail @@ -39,6 +40,7 @@ class HomeViewModelTest { private lateinit var repository: CocktailRepository private lateinit var premiumRepository: PremiumRepository private lateinit var wrapper: PurchaselyWrapper + private lateinit var getFilteredCocktails: GetFilteredCocktailsUseCase @Before fun setUp() { @@ -56,6 +58,7 @@ class HomeViewModelTest { wrapper = mockk(relaxed = true) { coEvery { loadPresentation(any(), any()) } returns FetchResult.Deactivated } + getFilteredCocktails = GetFilteredCocktailsUseCase(repository) } @After @@ -63,7 +66,7 @@ class HomeViewModelTest { Dispatchers.resetMain() } - private fun createViewModel() = HomeViewModel(repository, premiumRepository, wrapper) + private fun createViewModel() = HomeViewModel(repository, premiumRepository, wrapper, getFilteredCocktails) @Test fun `initial cocktails are loaded from repository`() { From bf37882bb69a55c370edb665cb9ae7e7faa7516f Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 14:14:53 +0200 Subject: [PATCH 12/15] refactor(android): migrate to type-safe Navigation Compose routes Replace sealed Screen class with @Serializable destinations (Home, Favorites, Settings, Detail) using Navigation Compose 2.8+ type-safe API. Eliminates string-based routes, navArgument declarations, and manual route construction in favor of composable and toRoute(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shaker/ui/navigation/Navigation.kt | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt b/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt index 8fce0f8..cba6494 100644 --- a/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt +++ b/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt @@ -22,14 +22,13 @@ import androidx.compose.ui.Modifier import androidx.annotation.StringRes import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument +import androidx.navigation.toRoute import com.purchasely.shaker.domain.repository.OnboardingRepository import com.purchasely.shaker.ui.screen.detail.DetailScreen import com.purchasely.shaker.ui.screen.favorites.FavoritesScreen @@ -37,28 +36,25 @@ import com.purchasely.shaker.ui.screen.home.HomeScreen import com.purchasely.shaker.R import com.purchasely.shaker.ui.screen.onboarding.OnboardingScreen import com.purchasely.shaker.ui.screen.settings.SettingsScreen +import kotlinx.serialization.Serializable import org.koin.compose.koinInject -sealed class Screen(val route: String) { - data object Home : Screen("home") - data object Favorites : Screen("favorites") - data object Settings : Screen("settings") - data object Detail : Screen("detail/{cocktailId}") { - fun createRoute(cocktailId: String) = "detail/$cocktailId" - } -} +@Serializable data object Home +@Serializable data object Favorites +@Serializable data object Settings +@Serializable data class Detail(val cocktailId: String) data class BottomNavItem( - val screen: Screen, + val route: Any, @StringRes val labelRes: Int, val selectedIcon: ImageVector, val unselectedIcon: ImageVector ) val bottomNavItems = listOf( - BottomNavItem(Screen.Home, R.string.home, Icons.Filled.Home, Icons.Outlined.Home), - BottomNavItem(Screen.Favorites, R.string.favorites, Icons.Filled.Favorite, Icons.Outlined.FavoriteBorder), - BottomNavItem(Screen.Settings, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings), + BottomNavItem(Home, R.string.home, Icons.Filled.Home, Icons.Outlined.Home), + BottomNavItem(Favorites, R.string.favorites, Icons.Filled.Favorite, Icons.Outlined.FavoriteBorder), + BottomNavItem(Settings, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings), ) @Composable @@ -81,14 +77,16 @@ fun ShakerNavHost() { return } - val showBottomBar = bottomNavItems.any { it.screen.route == currentDestination?.route } + val showBottomBar = currentDestination?.let { dest -> + bottomNavItems.any { dest.hasRoute(it.route::class) } + } ?: false Scaffold( bottomBar = { if (showBottomBar) { NavigationBar { bottomNavItems.forEach { item -> - val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true + val selected = currentDestination?.hasRoute(item.route::class) == true val label = stringResource(item.labelRes) NavigationBarItem( icon = { @@ -100,7 +98,7 @@ fun ShakerNavHost() { label = { Text(label) }, selected = selected, onClick = { - navController.navigate(item.screen.route) { + navController.navigate(item.route) { popUpTo(navController.graph.findStartDestination().id) { saveState = true } @@ -116,33 +114,30 @@ fun ShakerNavHost() { ) { innerPadding -> NavHost( navController = navController, - startDestination = Screen.Home.route, + startDestination = Home, modifier = Modifier.padding(bottom = innerPadding.calculateBottomPadding()) ) { - composable(Screen.Home.route) { + composable { HomeScreen( onCocktailClick = { cocktailId -> - navController.navigate(Screen.Detail.createRoute(cocktailId)) + navController.navigate(Detail(cocktailId)) } ) } - composable(Screen.Favorites.route) { + composable { FavoritesScreen( onCocktailClick = { cocktailId -> - navController.navigate(Screen.Detail.createRoute(cocktailId)) + navController.navigate(Detail(cocktailId)) } ) } - composable(Screen.Settings.route) { + composable { SettingsScreen() } - composable( - route = Screen.Detail.route, - arguments = listOf(navArgument("cocktailId") { type = NavType.StringType }) - ) { backStackEntry -> - val cocktailId = backStackEntry.arguments?.getString("cocktailId") ?: return@composable + composable { backStackEntry -> + val detail: Detail = backStackEntry.toRoute() DetailScreen( - cocktailId = cocktailId, + cocktailId = detail.cocktailId, onBack = { navController.popBackStack() } ) } From 030d4b458844d84a3d987def148251f3ace9e1c8 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 14:17:22 +0200 Subject: [PATCH 13/15] test(android): scaffold Compose UI test infrastructure Add Compose UI test dependencies (ui-test-junit4, ui-test-manifest, androidx-test-ext-junit) to the version catalog and build.gradle.kts. Create a minimal HomeScreenTest that validates the androidTest build pipeline compiles and the Compose test rule works correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/build.gradle.kts | 6 ++++ .../purchasely/shaker/ui/HomeScreenTest.kt | 32 +++++++++++++++++++ android/gradle/libs.versions.toml | 4 +++ 3 files changed, 42 insertions(+) create mode 100644 android/app/src/androidTest/java/com/purchasely/shaker/ui/HomeScreenTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f16e665..ecce916 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -108,4 +108,10 @@ dependencies { testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) + + // Android instrumentation / Compose UI tests + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.ext.junit) + debugImplementation(libs.compose.ui.test.manifest) } diff --git a/android/app/src/androidTest/java/com/purchasely/shaker/ui/HomeScreenTest.kt b/android/app/src/androidTest/java/com/purchasely/shaker/ui/HomeScreenTest.kt new file mode 100644 index 0000000..3e70f72 --- /dev/null +++ b/android/app/src/androidTest/java/com/purchasely/shaker/ui/HomeScreenTest.kt @@ -0,0 +1,32 @@ +package com.purchasely.shaker.ui + +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Scaffold for Compose UI tests. + * + * A full HomeScreen test would require Koin test modules and mocked + * repositories. This file validates that the androidTest build pipeline + * compiles and that the Compose test infrastructure is wired correctly. + */ +@RunWith(AndroidJUnit4::class) +class HomeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun composeTestInfrastructure_works() { + composeTestRule.setContent { + Text("Hello Shaker") + } + composeTestRule.onNodeWithText("Hello Shaker").assertIsDisplayed() + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index e526b20..9a4e9ec 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -15,6 +15,7 @@ junit = "4.13.2" mockk = "1.13.13" coroutines-test = "1.9.0" turbine = "1.2.0" +androidx-test-ext-junit = "1.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -47,6 +48,9 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-test" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 010af61b8cf9993d2f9f4daa6b3e24fa38e81637 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 14:29:44 +0200 Subject: [PATCH 14/15] refactor(android): eliminate remaining SDK leaks from UI layer - Create ConsentPurpose domain enum, replace PLYDataProcessingPurpose in SettingsViewModel - Wrap restoreAllProducts to return String? instead of PLYPlan?/PLYError? - Remove unused runningMode StateFlow from SettingsViewModel - Store onConfigured callback so restart() propagates it - Zero io.purchasely imports in the entire UI layer --- .../shaker/domain/model/ConsentPurpose.kt | 9 +++++ .../shaker/purchasely/PurchaselyWrapper.kt | 28 ++++++++++--- .../ui/screen/settings/SettingsScreen.kt | 1 - .../ui/screen/settings/SettingsViewModel.kt | 29 ++++++-------- .../screen/settings/SettingsViewModelTest.kt | 39 ++++++------------- 5 files changed, 55 insertions(+), 51 deletions(-) create mode 100644 android/app/src/main/java/com/purchasely/shaker/domain/model/ConsentPurpose.kt diff --git a/android/app/src/main/java/com/purchasely/shaker/domain/model/ConsentPurpose.kt b/android/app/src/main/java/com/purchasely/shaker/domain/model/ConsentPurpose.kt new file mode 100644 index 0000000..90b3402 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/domain/model/ConsentPurpose.kt @@ -0,0 +1,9 @@ +package com.purchasely.shaker.domain.model + +enum class ConsentPurpose { + ANALYTICS, + IDENTIFIED_ANALYTICS, + PERSONALIZATION, + CAMPAIGNS, + THIRD_PARTY_INTEGRATIONS +} diff --git a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt index 160ad88..51384da 100644 --- a/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt +++ b/android/app/src/main/java/com/purchasely/shaker/purchasely/PurchaselyWrapper.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.util.Log import android.view.View import com.purchasely.shaker.data.RunningModeRepository +import com.purchasely.shaker.domain.model.ConsentPurpose import com.purchasely.shaker.data.purchase.PurchaseRequest import com.purchasely.shaker.data.purchase.RestoreRequest import com.purchasely.shaker.data.purchase.TransactionResult @@ -19,6 +20,7 @@ import io.purchasely.ext.PLYPresentationInfo import io.purchasely.ext.PLYPresentationProperties import io.purchasely.ext.PLYPresentationType import io.purchasely.ext.SubscriptionsListener +import io.purchasely.ext.PLYDataProcessingPurpose import io.purchasely.ext.PLYProductViewResult import io.purchasely.ext.Purchasely import io.purchasely.ext.fetchPresentation @@ -46,6 +48,7 @@ class PurchaselyWrapper( private var application: Application? = null private var apiKey: String = "" private var logLevel: LogLevel = LogLevel.DEBUG + private var onConfiguredCallback: (() -> Unit)? = null private var pendingProcessAction: ((Boolean) -> Unit)? = null private var collectionJob: Job? = null @@ -73,6 +76,7 @@ class PurchaselyWrapper( this.application = application this.apiKey = apiKey this.logLevel = logLevel + this.onConfiguredCallback = onConfigured val mode = runningModeRepo.runningMode @@ -109,7 +113,7 @@ class PurchaselyWrapper( fun restart() { close() val app = application ?: return - initialize(app, apiKey, logLevel) + initialize(app, apiKey, logLevel, onConfiguredCallback) } fun close() { @@ -331,10 +335,13 @@ class PurchaselyWrapper( // MARK: - Restore fun restoreAllProducts( - onSuccess: (PLYPlan?) -> Unit, - onError: (PLYError?) -> Unit + onSuccess: (String?) -> Unit, + onError: (String?) -> Unit ) { - Purchasely.restoreAllProducts(onSuccess, onError) + Purchasely.restoreAllProducts( + { plan -> onSuccess(plan?.name) }, + { error -> onError(error?.message) } + ) } // MARK: - Observer Mode @@ -345,8 +352,17 @@ class PurchaselyWrapper( // MARK: - GDPR Consent - fun revokeDataProcessingConsent(purposes: Set) { - Purchasely.revokeDataProcessingConsent(purposes) + fun revokeDataProcessingConsent(purposes: Set) { + val sdkPurposes = purposes.mapTo(mutableSetOf()) { purpose -> + when (purpose) { + ConsentPurpose.ANALYTICS -> PLYDataProcessingPurpose.Analytics + ConsentPurpose.IDENTIFIED_ANALYTICS -> PLYDataProcessingPurpose.IdentifiedAnalytics + ConsentPurpose.PERSONALIZATION -> PLYDataProcessingPurpose.Personalization + ConsentPurpose.CAMPAIGNS -> PLYDataProcessingPurpose.Campaigns + ConsentPurpose.THIRD_PARTY_INTEGRATIONS -> PLYDataProcessingPurpose.ThirdPartyIntegrations + } + } + Purchasely.revokeDataProcessingConsent(sdkPurposes) } // MARK: - SDK Info 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 bbca3ae..32ffc36 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 @@ -71,7 +71,6 @@ fun SettingsScreen( val personalizationConsent by viewModel.personalizationConsent.collectAsStateWithLifecycle() val campaignsConsent by viewModel.campaignsConsent.collectAsStateWithLifecycle() val thirdPartyConsent by viewModel.thirdPartyConsent.collectAsStateWithLifecycle() - val runningMode by viewModel.runningMode.collectAsStateWithLifecycle() val anonymousId by viewModel.anonymousId.collectAsStateWithLifecycle() val displayMode by viewModel.displayMode.collectAsStateWithLifecycle() val clipboard = LocalClipboard.current 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 7f6e20a..e418768 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 @@ -12,7 +12,7 @@ import com.purchasely.shaker.data.SettingsRepository import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PresentationHandle import com.purchasely.shaker.purchasely.PurchaselyWrapper -import io.purchasely.ext.PLYDataProcessingPurpose +import com.purchasely.shaker.domain.model.ConsentPurpose import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -60,11 +60,6 @@ class SettingsViewModel( private val _thirdPartyConsent = MutableStateFlow(settingsRepo.thirdPartyConsent) val thirdPartyConsent: StateFlow = _thirdPartyConsent.asStateFlow() - private val _runningMode = MutableStateFlow( - if (runningModeRepo.isObserverMode) "observer" else "full" - ) - val runningMode: StateFlow = _runningMode.asStateFlow() - private val _anonymousId = MutableStateFlow(purchaselyWrapper.anonymousUserId) val anonymousId: StateFlow = _anonymousId.asStateFlow() @@ -123,14 +118,14 @@ class SettingsViewModel( // Required by App Store / Play Store guidelines; call on explicit user request only // Docs: https://docs.purchasely.com/quick-start/sdk-implementation/restore-purchases purchaselyWrapper.restoreAllProducts( - onSuccess = { plan -> + onSuccess = { planName -> premiumRepository.refreshPremiumStatus() _restoreMessage.value = "Purchases restored successfully!" - Log.d(TAG, "[Shaker] Restore success: ${plan?.name}") + Log.d(TAG, "[Shaker] Restore success: $planName") }, - onError = { error -> - _restoreMessage.value = error?.message ?: "No purchases to restore" - Log.e(TAG, "[Shaker] Restore error: ${error?.message}") + onError = { errorMessage -> + _restoreMessage.value = errorMessage ?: "No purchases to restore" + Log.e(TAG, "[Shaker] Restore error: $errorMessage") } ) } @@ -220,12 +215,12 @@ class SettingsViewModel( } private fun applyConsentPreferences() { - val revoked = mutableSetOf() - if (!_analyticsConsent.value) revoked.add(PLYDataProcessingPurpose.Analytics) - if (!_identifiedAnalyticsConsent.value) revoked.add(PLYDataProcessingPurpose.IdentifiedAnalytics) - if (!_personalizationConsent.value) revoked.add(PLYDataProcessingPurpose.Personalization) - if (!_campaignsConsent.value) revoked.add(PLYDataProcessingPurpose.Campaigns) - if (!_thirdPartyConsent.value) revoked.add(PLYDataProcessingPurpose.ThirdPartyIntegrations) + val revoked = mutableSetOf() + if (!_analyticsConsent.value) revoked.add(ConsentPurpose.ANALYTICS) + if (!_identifiedAnalyticsConsent.value) revoked.add(ConsentPurpose.IDENTIFIED_ANALYTICS) + if (!_personalizationConsent.value) revoked.add(ConsentPurpose.PERSONALIZATION) + if (!_campaignsConsent.value) revoked.add(ConsentPurpose.CAMPAIGNS) + if (!_thirdPartyConsent.value) revoked.add(ConsentPurpose.THIRD_PARTY_INTEGRATIONS) // PURCHASELY: Revoke GDPR data-processing consent for specific purposes // Pass the set of revoked purposes; an empty set re-grants all consent // Docs: https://docs.purchasely.com/advanced-features/gdpr diff --git a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt index 2c5d3d9..7377aa2 100644 --- a/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt +++ b/android/app/src/test/java/com/purchasely/shaker/ui/screen/settings/SettingsViewModelTest.kt @@ -15,9 +15,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify -import io.purchasely.ext.PLYDataProcessingPurpose -import io.purchasely.models.PLYError -import io.purchasely.models.PLYPlan +import com.purchasely.shaker.domain.model.ConsentPurpose import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -140,9 +138,9 @@ class SettingsViewModelTest { @Test fun `restorePurchases success updates message`() { - val successSlot = slot<(PLYPlan?) -> Unit>() + val successSlot = slot<(String?) -> Unit>() every { wrapper.restoreAllProducts(capture(successSlot), any()) } answers { - successSlot.captured(null) + successSlot.captured("Premium Plan") } val vm = createViewModel() vm.restorePurchases() @@ -152,9 +150,9 @@ class SettingsViewModelTest { @Test fun `restorePurchases error updates message`() { - val errorSlot = slot<(PLYError?) -> Unit>() + val errorSlot = slot<(String?) -> Unit>() every { wrapper.restoreAllProducts(any(), capture(errorSlot)) } answers { - errorSlot.captured(mockk { every { message } returns "No purchases found" }) + errorSlot.captured("No purchases found") } val vm = createViewModel() vm.restorePurchases() @@ -216,7 +214,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.setAnalyticsConsent(false) assertFalse(vm.analyticsConsent.value) - verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.Analytics) }) } + verify { wrapper.revokeDataProcessingConsent(match { it.contains(ConsentPurpose.ANALYTICS) }) } } @Test @@ -224,7 +222,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.setIdentifiedAnalyticsConsent(false) assertFalse(vm.identifiedAnalyticsConsent.value) - verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.IdentifiedAnalytics) }) } + verify { wrapper.revokeDataProcessingConsent(match { it.contains(ConsentPurpose.IDENTIFIED_ANALYTICS) }) } } @Test @@ -232,7 +230,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.setPersonalizationConsent(false) assertFalse(vm.personalizationConsent.value) - verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.Personalization) }) } + verify { wrapper.revokeDataProcessingConsent(match { it.contains(ConsentPurpose.PERSONALIZATION) }) } } @Test @@ -240,7 +238,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.setCampaignsConsent(false) assertFalse(vm.campaignsConsent.value) - verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.Campaigns) }) } + verify { wrapper.revokeDataProcessingConsent(match { it.contains(ConsentPurpose.CAMPAIGNS) }) } } @Test @@ -248,7 +246,7 @@ class SettingsViewModelTest { val vm = createViewModel() vm.setThirdPartyConsent(false) assertFalse(vm.thirdPartyConsent.value) - verify { wrapper.revokeDataProcessingConsent(match { it.contains(PLYDataProcessingPurpose.ThirdPartyIntegrations) }) } + verify { wrapper.revokeDataProcessingConsent(match { it.contains(ConsentPurpose.THIRD_PARTY_INTEGRATIONS) }) } } @Test @@ -265,8 +263,8 @@ class SettingsViewModelTest { vm.setPersonalizationConsent(false) verify { wrapper.revokeDataProcessingConsent(match { - it.contains(PLYDataProcessingPurpose.Analytics) && - it.contains(PLYDataProcessingPurpose.Personalization) + it.contains(ConsentPurpose.ANALYTICS) && + it.contains(ConsentPurpose.PERSONALIZATION) }) } } @@ -291,19 +289,6 @@ class SettingsViewModelTest { assertFalse(vm.isPremium.value) } - @Test - fun `initial runningMode reads from repository`() { - val vm = createViewModel() - assertEquals("full", vm.runningMode.value) - } - - @Test - fun `initial runningMode is observer when repo says so`() { - every { runningModeRepo.isObserverMode } returns true - val vm = createViewModel() - assertEquals("observer", vm.runningMode.value) - } - @Test fun `setSdkMode calls wrapper restart`() { settingsRepo.sdkModeStorage = PurchaselySdkMode.FULL.storageValue From e05bbef0d65abaec6c2ca12b298e33e027a86ce7 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 16 Apr 2026 14:37:52 +0200 Subject: [PATCH 15/15] fix(ci): migrate iOS build from CocoaPods to SPM iOS was migrated to Swift Package Manager in d70aa30 but CI still used pod install. Replace with xcodebuild -resolvePackageDependencies and build with .xcodeproj instead of .xcworkspace. --- .github/workflows/ci.yml | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 993aede..b0a5134 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,30 +35,20 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - bundler-cache: false - - - run: gem install cocoapods - - name: Install XcodeGen run: brew install xcodegen - run: xcodegen generate - - name: Cache CocoaPods - uses: actions/cache@v4 - with: - path: ios/Pods - key: pods-${{ hashFiles('ios/Podfile.lock') }} - restore-keys: pods- - - - run: pod install + - name: Resolve Swift packages + run: | + xcodebuild -resolvePackageDependencies \ + -project Shaker.xcodeproj \ + -scheme Shaker - run: | xcodebuild build \ - -workspace Shaker.xcworkspace \ + -project Shaker.xcodeproj \ -scheme Shaker \ -destination 'generic/platform=iOS Simulator' \ -quiet