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 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/app/src/main/java/com/purchasely/shaker/ShakerApp.kt b/android/app/src/main/java/com/purchasely/shaker/ShakerApp.kt index 86ea2cf..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,6 +1,7 @@ package com.purchasely.shaker import android.app.Application +import com.purchasely.shaker.domain.repository.PremiumRepository 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 premiumRepository: PremiumRepository 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 = { 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 51% 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 a4966bc..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,26 +1,23 @@ package com.purchasely.shaker.data -import android.content.Context -import android.content.SharedPreferences +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(context: Context) { - - private val prefs: SharedPreferences = - context.getSharedPreferences("shaker_favorites", Context.MODE_PRIVATE) +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 = prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet() + _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) @@ -28,21 +25,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) { + override 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) { + override 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 deleted file mode 100644 index efd5b6c..0000000 --- a/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.purchasely.shaker.data - -import android.content.Context -import android.content.SharedPreferences - -class OnboardingRepository(context: Context) { - - private val prefs: SharedPreferences = - context.getSharedPreferences("shaker_onboarding", Context.MODE_PRIVATE) - - var isOnboardingCompleted: Boolean - get() = prefs.getBoolean(KEY_COMPLETED, false) - set(value) { prefs.edit().putBoolean(KEY_COMPLETED, value).apply() } - - companion object { - private const val KEY_COMPLETED = "onboarding_completed" - } -} diff --git a/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepositoryImpl.kt b/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepositoryImpl.kt new file mode 100644 index 0000000..727d454 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/OnboardingRepositoryImpl.kt @@ -0,0 +1,15 @@ +package com.purchasely.shaker.data + +import com.purchasely.shaker.data.storage.KeyValueStore +import com.purchasely.shaker.domain.repository.OnboardingRepository + +class OnboardingRepositoryImpl(private val store: KeyValueStore) : OnboardingRepository { + + override var isOnboardingCompleted: Boolean + 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/PremiumManager.kt b/android/app/src/main/java/com/purchasely/shaker/data/PremiumManagerImpl.kt similarity index 75% 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 6a7b92a..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,23 +1,24 @@ package com.purchasely.shaker.data import android.util.Log -import io.purchasely.ext.Purchasely +import com.purchasely.shaker.domain.repository.PremiumRepository +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 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 - 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/data/RunningModeRepository.kt b/android/app/src/main/java/com/purchasely/shaker/data/RunningModeRepository.kt index 7fca671..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 @@ -1,22 +1,25 @@ 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") - 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" - prefs.edit().putString(KEY_RUNNING_MODE, str).apply() + val mode = PurchaselySdkMode.entries.firstOrNull { it.runningMode == value } + ?: PurchaselySdkMode.DEFAULT + store.putString(KEY_RUNNING_MODE, mode.storageValue) } val isObserverMode: Boolean @@ -24,5 +27,7 @@ class RunningModeRepository(context: Context) { 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 new file mode 100644 index 0000000..2b089c6 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/data/SettingsRepository.kt @@ -0,0 +1,64 @@ +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) { + + 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: ThemeMode + get() = ThemeMode.fromStorage(store.getString(KEY_THEME)) + set(value) = store.putString(KEY_THEME, value.storageValue) + + 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) + ?: 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/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 abf1bf8..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 @@ -1,19 +1,30 @@ 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.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 +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 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 @@ -25,11 +36,26 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val appModule = module { - single { CocktailRepository(androidContext()) } - single { FavoritesRepository(androidContext()) } - single { OnboardingRepository(androidContext()) } - single { RunningModeRepository(androidContext()) } - single { PremiumManager() } + single { CocktailRepositoryImpl(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 { FavoritesRepositoryImpl(get(named("favorites"))) } + single { OnboardingRepositoryImpl(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() } @@ -53,7 +79,6 @@ val appModule = module { } single { PurchaselyWrapper( - premiumManager = get(), runningModeRepo = get(), purchaseRequests = get(named("purchaseRequests")), restoreRequests = get(named("restoreRequests")), @@ -61,8 +86,16 @@ val appModule = module { scope = get(named("appScope")) ) } - viewModel { HomeViewModel(get(), get(), get()) } - viewModel { params -> DetailViewModel(get(), get(), get(), get(), params.get()) } + single { + PremiumManagerImpl(wrapper = get()).also { pm -> + get().onTransactionCompleted = { pm.refreshPremiumStatus() } + } + } + factory { GetFilteredCocktailsUseCase(get()) } + factory { ToggleFavoriteUseCase(get()) } + viewModel { OnboardingViewModel(get(), 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(androidContext(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get()) } } 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/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/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/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/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..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 @@ -7,19 +7,20 @@ 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.domain.model.ConsentPurpose import com.purchasely.shaker.data.purchase.PurchaseRequest 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 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 @@ -35,7 +36,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, @@ -43,9 +43,12 @@ 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 + private var onConfiguredCallback: (() -> Unit)? = null private var pendingProcessAction: ((Boolean) -> Unit)? = null private var collectionJob: Job? = null @@ -67,11 +70,13 @@ 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 this.logLevel = logLevel + this.onConfiguredCallback = onConfigured val mode = runningModeRepo.runningMode @@ -85,7 +90,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}") @@ -108,7 +113,7 @@ class PurchaselyWrapper( fun restart() { close() val app = application ?: return - initialize(app, apiKey, logLevel) + initialize(app, apiKey, logLevel, onConfiguredCallback) } fun close() { @@ -186,7 +191,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 -> { @@ -241,23 +246,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 +275,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) { @@ -320,13 +326,22 @@ class PurchaselyWrapper( Purchasely.incrementUserAttribute(key) } + // MARK: - Subscriptions + + fun userSubscriptions(invalidateCache: Boolean, listener: SubscriptionsListener) { + Purchasely.userSubscriptions(invalidateCache, listener) + } + // 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 @@ -337,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/navigation/Navigation.kt b/android/app/src/main/java/com/purchasely/shaker/ui/navigation/Navigation.kt index 55842a7..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 @@ -19,43 +19,42 @@ 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.navigation.NavDestination.Companion.hierarchy +import androidx.compose.ui.res.stringResource +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 com.purchasely.shaker.data.OnboardingRepository +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 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 label: String, + val route: Any, + @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(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 @@ -78,25 +77,28 @@ 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 = { 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) { + navController.navigate(item.route) { popUpTo(navController.graph.findStartDestination().id) { saveState = true } @@ -112,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() } ) } 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..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 @@ -42,10 +42,15 @@ 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.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) @@ -60,11 +65,13 @@ 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 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 { @@ -74,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 -> {} + } } } @@ -152,7 +167,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)) @@ -182,7 +197,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)) @@ -236,7 +251,7 @@ fun DetailScreen( contentDescription = null, modifier = Modifier.padding(end = 8.dp) ) - Text("Unlock Full Recipe") + Text(stringResource(R.string.unlock_full_recipe)) } } } @@ -254,7 +269,7 @@ fun DetailScreen( IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.back), tint = Color.White ) } @@ -269,7 +284,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/detail/DetailViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/detail/DetailViewModel.kt index 1d1a0c0..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 @@ -1,17 +1,16 @@ package com.purchasely.shaker.ui.screen.detail -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.domain.usecase.ToggleFavoriteUseCase 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 @@ -22,28 +21,27 @@ 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 toggleFavoriteUseCase: ToggleFavoriteUseCase, private val cocktailId: String ) : ViewModel() { 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 // Signal Screen to display recipe paywall - private var pendingRecipePresentation: PLYPresentation? = 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: PLYPresentation? = 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) @@ -65,7 +63,7 @@ class DetailViewModel( fun isFavorite(): Boolean = favoritesRepository.isFavorite(cocktailId) fun toggleFavorite() { - favoritesRepository.toggleFavorite(cocktailId) + toggleFavoriteUseCase(cocktailId) } fun showRecipePaywall() { @@ -76,8 +74,7 @@ class DetailViewModel( ) when (result) { is FetchResult.Success -> { - pendingRecipePresentation = result.presentation - _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 +84,12 @@ class DetailViewModel( } } - suspend fun displayPendingRecipePaywall(activity: Activity) { - val presentation = pendingRecipePresentation ?: return - pendingRecipePresentation = null - val result = purchaselyWrapper.display(presentation, 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.presentation - _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,20 +99,7 @@ class DetailViewModel( } } - suspend fun displayPendingFavoritesPaywall(activity: Activity) { - val presentation = pendingFavoritesPresentation ?: return - pendingFavoritesPresentation = null - val result = purchaselyWrapper.display(presentation, 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() { - premiumManager.refreshPremiumStatus() + 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 dac36b8..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 @@ -35,26 +35,35 @@ 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.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( 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 + 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 -> {} + } } } @@ -73,13 +82,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) ) @@ -93,7 +102,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/favorites/FavoritesViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/favorites/FavoritesViewModel.kt index b3c53d3..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,42 +1,46 @@ package com.purchasely.shaker.ui.screen.favorites -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 +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 import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow 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() // Signal Screen to display favorites paywall - private var pendingPresentation: PLYPresentation? = null - private val _requestPaywallDisplay = MutableSharedFlow() - val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() + 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) { @@ -47,8 +51,7 @@ class FavoritesViewModel( viewModelScope.launch { when (val result = purchaselyWrapper.loadPresentation("favorites")) { is FetchResult.Success -> { - pendingPresentation = result.presentation - _requestPaywallDisplay.emit(Unit) + _requestPaywallDisplay.emit(result.handle) } is FetchResult.Client -> { Log.d(TAG, "[Shaker] CLIENT presentation received for favorites placement — build custom UI here") @@ -57,27 +60,14 @@ 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 - pendingPresentation = null - val result = purchaselyWrapper.display(presentation, activity) - when (result) { - is DisplayResult.Purchased, is DisplayResult.Restored -> { - Log.d(TAG, "[Shaker] Purchased/Restored from favorites") - onPaywallDismissed() - } - else -> {} - } - } - fun onPaywallDismissed() { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } companion object { 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 6fa409a..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 @@ -46,13 +46,18 @@ 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.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 @@ -64,15 +69,21 @@ 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 + 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 -> {} + } } } @@ -85,8 +96,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( @@ -106,12 +117,12 @@ fun HomeScreen( viewModel.onFilterClick() } }) { - if (viewModel.hasActiveFilters) { + 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)) } } } @@ -139,13 +150,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/home/HomeViewModel.kt b/android/app/src/main/java/com/purchasely/shaker/ui/screen/home/HomeViewModel.kt index f25d4b9..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 @@ -1,16 +1,14 @@ 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.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.domain.usecase.GetFilteredCocktailsUseCase 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 @@ -21,8 +19,9 @@ import kotlinx.coroutines.launch class HomeViewModel( private val repository: CocktailRepository, - private val premiumManager: PremiumManager, - private val purchaselyWrapper: PurchaselyWrapper + private val premiumRepository: PremiumRepository, + private val purchaselyWrapper: PurchaselyWrapper, + private val getFilteredCocktails: GetFilteredCocktailsUseCase ) : ViewModel() { private val _cocktails = MutableStateFlow>(emptyList()) @@ -31,7 +30,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()) @@ -47,10 +46,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) @@ -64,12 +67,11 @@ class HomeViewModel( val isFiltersLoading: StateFlow = _isFiltersLoading.asStateFlow() // Signal Screen to display filters paywall - private var pendingFiltersPresentation: PLYPresentation? = null - private val _requestPaywallDisplay = MutableSharedFlow() - val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() + private val _requestPaywallDisplay = MutableSharedFlow() + val requestPaywallDisplay: SharedFlow = _requestPaywallDisplay.asSharedFlow() init { - _cocktails.value = repository.loadCocktails() + _cocktails.value = getFilteredCocktails() prefetchPresentations() } @@ -97,21 +99,7 @@ class HomeViewModel( if (isPremium.value) return val result = _filtersPresentation.value if (result is FetchResult.Success) { - pendingFiltersPresentation = result.presentation - viewModelScope.launch { _requestPaywallDisplay.emit(Unit) } - } - } - - suspend fun displayPendingPaywall(activity: Activity) { - val presentation = pendingFiltersPresentation ?: return - pendingFiltersPresentation = null - val result = purchaselyWrapper.display(presentation, 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) } } } @@ -120,6 +108,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 +116,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,24 +130,19 @@ class HomeViewModel( _selectedCategories.value = emptySet() _selectedDifficulty.value = null applyFilters() + updateHasActiveFilters() } fun onPaywallDismissed() { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } 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/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..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 @@ -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 @@ -18,22 +17,24 @@ 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.data.PremiumManager +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( showOnboarding: Boolean, - onComplete: () -> Unit + onComplete: () -> Unit, + viewModel: OnboardingViewModel = koinViewModel() ) { val context = LocalContext.current - val premiumManager: PremiumManager = koinInject() val purchaselyWrapper: PurchaselyWrapper = koinInject() LaunchedEffect(Unit) { @@ -48,33 +49,16 @@ fun OnboardingScreen( return@LaunchedEffect } - when (val result = purchaselyWrapper.loadPresentation("onboarding")) { + when (val fetchResult = viewModel.loadOnboarding()) { is FetchResult.Success -> { - val displayResult = purchaselyWrapper.display(result.presentation, activity) + val displayResult = purchaselyWrapper.display(fetchResult.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") - } + is DisplayResult.Purchased, is DisplayResult.Restored -> viewModel.onPurchaseCompleted() + else -> {} } 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.error?.message}") - onComplete() - } + else -> onComplete() } } @@ -99,14 +83,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) ) @@ -119,5 +103,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..22b2553 --- /dev/null +++ b/android/app/src/main/java/com/purchasely/shaker/ui/screen/onboarding/OnboardingViewModel.kt @@ -0,0 +1,21 @@ +package com.purchasely.shaker.ui.screen.onboarding + +import androidx.lifecycle.ViewModel +import com.purchasely.shaker.domain.repository.PremiumRepository +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 premiumRepository: PremiumRepository +) : ViewModel() { + + suspend fun loadOnboarding(): FetchResult { + return purchaselyWrapper.loadPresentation("onboarding") + } + + 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 8936a93..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 @@ -44,10 +44,17 @@ 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 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( @@ -64,12 +71,12 @@ 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 val clipboardScope = rememberCoroutineScope() val context = LocalContext.current + val purchaselyWrapper: PurchaselyWrapper = koinInject() var loginInput by remember { mutableStateOf("") } // Show restore toast @@ -82,9 +89,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 -> {} + } } } @@ -97,7 +108,7 @@ fun SettingsScreen( ) { // Account section Text( - text = "Account", + text = stringResource(R.string.account), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -110,7 +121,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 +132,7 @@ fun SettingsScreen( } TextButton(onClick = { viewModel.logout() }) { Text( - "Logout", + stringResource(R.string.logout), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error ) @@ -136,8 +147,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 +160,7 @@ fun SettingsScreen( }, enabled = loginInput.isNotBlank() ) { - Text("Login") + Text(stringResource(R.string.login)) } } } @@ -158,10 +169,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 +180,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 +193,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 +205,7 @@ fun SettingsScreen( // Purchases section Text( - text = "Purchases", + text = stringResource(R.string.purchases), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -203,14 +214,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 +230,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 +251,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 +262,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 +312,13 @@ 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 themes = ThemeMode.entries SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { themes.forEachIndexed { index, mode -> SegmentedButton( @@ -316,7 +326,7 @@ fun SettingsScreen( onClick = { viewModel.setThemeMode(mode) }, shape = SegmentedButtonDefaults.itemShape(index = index, count = themes.size) ) { - Text(labels[index]) + Text(mode.label) } } } @@ -327,20 +337,19 @@ 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 displayModes = DisplayMode.entries SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { displayModes.forEachIndexed { index, mode -> SegmentedButton( @@ -348,7 +357,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) } } } @@ -359,14 +368,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 +385,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 +396,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/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..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 @@ -1,19 +1,18 @@ 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.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.purchasely.DisplayResult +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 io.purchasely.ext.PLYPresentation +import com.purchasely.shaker.domain.model.ConsentPurpose import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -23,71 +22,58 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class SettingsViewModel( - private val context: Context, - private val premiumManager: PremiumManager, + private val settingsRepo: SettingsRepository, + private val premiumRepository: PremiumRepository, 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 + val isPremium: StateFlow = premiumRepository.isPremium private val _restoreMessage = MutableStateFlow(null) val restoreMessage: StateFlow = _restoreMessage.asStateFlow() - private val _themeMode = MutableStateFlow(prefs.getString(KEY_THEME, "system") ?: "system") - val themeMode: StateFlow = _themeMode.asStateFlow() + 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( - if (runningModeRepo.isObserverMode) "observer" else "full" - ) - val runningMode: StateFlow = _runningMode.asStateFlow() - private val _anonymousId = MutableStateFlow(purchaselyWrapper.anonymousUserId) val anonymousId: StateFlow = _anonymousId.asStateFlow() - private val _displayMode = MutableStateFlow(prefs.getString(KEY_DISPLAY_MODE, "fullscreen") ?: "fullscreen") - val displayMode: StateFlow = _displayMode.asStateFlow() + private val _displayMode = MutableStateFlow(settingsRepo.displayMode) + val displayMode: StateFlow = _displayMode.asStateFlow() // Signal Screen to display onboarding paywall - private var pendingOnboardingPresentation: PLYPresentation? = 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 init { - if (!prefs.contains(PurchaselySdkMode.KEY)) { - prefs.edit().putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue).apply() - } + settingsRepo.initSdkModeIfNeeded() applyConsentPreferences() } @@ -103,13 +89,13 @@ 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)") } _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,8 +107,8 @@ 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() - premiumManager.refreshPremiumStatus() + settingsRepo.userId = null + premiumRepository.refreshPremiumStatus() Log.d(TAG, "[Shaker] Logged out") } @@ -132,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 -> - premiumManager.refreshPremiumStatus() + 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") } ) } @@ -149,15 +135,14 @@ class SettingsViewModel( } fun onPurchaseCompleted() { - premiumManager.refreshPremiumStatus() + premiumRepository.refreshPremiumStatus() } fun showOnboardingPaywall() { viewModelScope.launch { when (val result = purchaselyWrapper.loadPresentation("onboarding")) { is FetchResult.Success -> { - pendingOnboardingPresentation = result.presentation - _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,74 +151,61 @@ 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 - pendingOnboardingPresentation = null - val result = purchaselyWrapper.display(presentation, activity) - when (result) { - is DisplayResult.Purchased, is DisplayResult.Restored -> { - Log.d(TAG, "[Shaker] Purchased/Restored from onboarding") - onPurchaseCompleted() - } - else -> {} - } - } - - fun setDisplayMode(mode: String) { + fun setDisplayMode(mode: DisplayMode) { _displayMode.value = mode - prefs.edit().putString(KEY_DISPLAY_MODE, mode).apply() - Log.d(TAG, "[Shaker] Display mode changed to: $mode") + settingsRepo.displayMode = mode + Log.d(TAG, "[Shaker] Display mode changed to: ${mode.storageValue}") } - fun setThemeMode(mode: String) { + fun setThemeMode(mode: ThemeMode) { _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) + purchaselyWrapper.setUserAttribute("app_theme", mode.storageValue) } fun setSdkMode(mode: PurchaselySdkMode) { 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() } @@ -243,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 @@ -258,13 +230,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/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 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 64% 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 a62b514..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 @@ -1,42 +1,22 @@ 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 import org.junit.Before import org.junit.Test -class FavoritesRepositoryTest { +class FavoritesRepositoryImplTest { - 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(): FavoritesRepositoryImpl = FavoritesRepositoryImpl(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/OnboardingRepositoryImplTest.kt b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryImplTest.kt new file mode 100644 index 0000000..7c070aa --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryImplTest.kt @@ -0,0 +1,47 @@ +package com.purchasely.shaker.data + +import com.purchasely.shaker.data.storage.InMemoryKeyValueStore +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class OnboardingRepositoryImplTest { + + private lateinit var store: InMemoryKeyValueStore + + @Before + fun setUp() { + store = InMemoryKeyValueStore() + } + + @Test + fun `initial state is false`() { + val repo = OnboardingRepositoryImpl(store) + assertFalse(repo.isOnboardingCompleted) + } + + @Test + fun `setting to true persists`() { + val repo = OnboardingRepositoryImpl(store) + repo.isOnboardingCompleted = true + assertTrue(repo.isOnboardingCompleted) + assertTrue(store.getBoolean("onboarding_completed")) + } + + @Test + fun `setting to false persists`() { + store.putBoolean("onboarding_completed", true) + val repo = OnboardingRepositoryImpl(store) + repo.isOnboardingCompleted = false + assertFalse(repo.isOnboardingCompleted) + assertFalse(store.getBoolean("onboarding_completed")) + } + + @Test + fun `reads stored value on creation`() { + store.putBoolean("onboarding_completed", true) + val repo = OnboardingRepositoryImpl(store) + assertTrue(repo.isOnboardingCompleted) + } +} 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 deleted file mode 100644 index 96a689e..0000000 --- a/android/app/src/test/java/com/purchasely/shaker/data/OnboardingRepositoryTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -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 org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -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 - - @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 - } - } - - @Test - fun `initial state is false`() { - val repo = OnboardingRepository(context) - assertFalse(repo.isOnboardingCompleted) - } - - @Test - fun `setting to true persists`() { - val repo = OnboardingRepository(context) - repo.isOnboardingCompleted = true - assertTrue(repo.isOnboardingCompleted) - verify { editor.putBoolean("onboarding_completed", true) } - } - - @Test - fun `setting to false persists`() { - storedBoolean = true - val repo = OnboardingRepository(context) - repo.isOnboardingCompleted = false - assertFalse(repo.isOnboardingCompleted) - verify { editor.putBoolean("onboarding_completed", false) } - } - - @Test - fun `reads stored value on creation`() { - storedBoolean = true - val repo = OnboardingRepository(context) - 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..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 @@ -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,68 @@ 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) - assertEquals(PLYRunningMode.Full, repo.runningMode) + fun `default mode is PaywallObserver (PurchaselySdkMode DEFAULT)`() { + val repo = RunningModeRepository(store) + assertEquals(PLYRunningMode.PaywallObserver, repo.runningMode) } @Test - fun `isObserverMode is false when Full`() { - val repo = RunningModeRepository(context) - assertFalse(repo.isObserverMode) + fun `isObserverMode is true by default`() { + val repo = RunningModeRepository(store) + assertTrue(repo.isObserverMode) } @Test - fun `setting to PaywallObserver persists observer string`() { - val repo = RunningModeRepository(context) + fun `setting to PaywallObserver persists paywallObserver string`() { + val repo = RunningModeRepository(store) repo.runningMode = PLYRunningMode.PaywallObserver - verify { editor.putString("running_mode", "observer") } + 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`() { - storedString = "observer" - val repo = RunningModeRepository(context) + fun `legacy observer value migrates to PaywallObserver`() { + 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", "paywallObserver") + 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) + 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 new file mode 100644 index 0000000..b26830f --- /dev/null +++ b/android/app/src/test/java/com/purchasely/shaker/data/SettingsRepositoryTest.kt @@ -0,0 +1,137 @@ +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 +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(ThemeMode.SYSTEM, repo.themeMode) + } + + @Test + fun `themeMode round-trips`() { + repo.themeMode = ThemeMode.DARK + assertEquals(ThemeMode.DARK, repo.themeMode) + } + + @Test + 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 = 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 + 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/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) } +} 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/purchasely/PurchaselyWrapperTest.kt b/android/app/src/test/java/com/purchasely/shaker/purchasely/PurchaselyWrapperTest.kt index 513bcc1..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 @@ -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) } 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..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 @@ -1,8 +1,9 @@ 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.domain.usecase.ToggleFavoriteUseCase import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.testCocktail @@ -35,9 +36,10 @@ 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 + private lateinit var toggleFavoriteUseCase: ToggleFavoriteUseCase @Before fun setUp() { @@ -47,7 +49,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 } @@ -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, premiumManager, favoritesRepository, wrapper, cocktailId) + DetailViewModel(repository, premiumRepository, favoritesRepository, wrapper, toggleFavoriteUseCase, cocktailId) @Test fun `loads cocktail by id on init`() { @@ -137,13 +140,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 be9da03..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,22 +64,22 @@ class FavoritesViewModelTest { } private fun createViewModel() = - FavoritesViewModel(cocktailRepository, favoritesRepository, premiumManager, wrapper) + FavoritesViewModel(cocktailRepository, favoritesRepository, premiumRepository, 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 @@ -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 03376fe..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 @@ -1,7 +1,8 @@ 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.domain.usecase.GetFilteredCocktailsUseCase import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import com.purchasely.shaker.testCocktail @@ -37,8 +38,9 @@ class HomeViewModelTest { ) private lateinit var repository: CocktailRepository - private lateinit var premiumManager: PremiumManager + private lateinit var premiumRepository: PremiumRepository private lateinit var wrapper: PurchaselyWrapper + private lateinit var getFilteredCocktails: GetFilteredCocktailsUseCase @Before fun setUp() { @@ -49,13 +51,14 @@ 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 } 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, premiumManager, wrapper) + private fun createViewModel() = HomeViewModel(repository, premiumRepository, wrapper, getFilteredCocktails) @Test fun `initial cocktails are loaded from repository`() { @@ -216,28 +219,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 @@ -249,7 +252,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 +261,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 2e15a53..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 @@ -1,10 +1,12 @@ 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.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 +import com.purchasely.shaker.data.storage.InMemoryKeyValueStore import com.purchasely.shaker.purchasely.FetchResult import com.purchasely.shaker.purchasely.PurchaselyWrapper import io.mockk.coEvery @@ -13,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 @@ -36,49 +36,19 @@ 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 premiumManager: PremiumManager + private lateinit var store: InMemoryKeyValueStore + private lateinit var settingsRepo: SettingsRepository + private lateinit var premiumRepository: PremiumRepository 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 - } - premiumManager = mockk { + store = InMemoryKeyValueStore() + settingsRepo = SettingsRepository(store) + premiumRepository = 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, premiumRepository, 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 @@ -152,37 +122,37 @@ class SettingsViewModelTest { } val vm = createViewModel() vm.login("kevin") - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @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") } - verify { premiumManager.refreshPremiumStatus() } + assertNull(settingsRepo.userId) + verify { premiumRepository.refreshPremiumStatus() } } @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() assertEquals("Purchases restored successfully!", vm.restoreMessage.value) - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @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() @@ -199,24 +169,24 @@ class SettingsViewModelTest { @Test fun `setThemeMode persists and sets user attribute`() { val vm = createViewModel() - vm.setThemeMode("dark") - assertEquals("dark", vm.themeMode.value) - verify { editor.putString("theme_mode", "dark") } + 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) - verify { editor.putString("display_mode", "embedded") } + vm.setDisplayMode(DisplayMode.MODAL) + assertEquals(DisplayMode.MODAL, vm.displayMode.value) + assertEquals(DisplayMode.MODAL, settingsRepo.displayMode) } @Test @@ -244,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 @@ -252,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 @@ -260,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 @@ -268,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 @@ -276,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 @@ -293,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) }) } } @@ -310,7 +280,7 @@ class SettingsViewModelTest { fun `onPurchaseCompleted refreshes premium status`() { val vm = createViewModel() vm.onPurchaseCompleted() - verify { premiumManager.refreshPremiumStatus() } + verify { premiumRepository.refreshPremiumStatus() } } @Test @@ -320,23 +290,24 @@ class SettingsViewModelTest { } @Test - fun `initial runningMode reads from repository`() { + fun `setSdkMode calls wrapper restart`() { + settingsRepo.sdkModeStorage = PurchaselySdkMode.FULL.storageValue val vm = createViewModel() - assertEquals("full", vm.runningMode.value) + vm.setSdkMode(PurchaselySdkMode.PAYWALL_OBSERVER) + verify { wrapper.restart() } } @Test - fun `initial runningMode is observer when repo says so`() { - every { runningModeRepo.isObserverMode } returns true + fun `initSdkModeIfNeeded sets default when not present`() { + // Store starts empty, creating VM triggers initSdkModeIfNeeded val vm = createViewModel() - assertEquals("observer", vm.runningMode.value) + assertEquals(PurchaselySdkMode.DEFAULT.storageValue, settingsRepo.sdkModeStorage) } @Test - fun `setSdkMode calls wrapper restart`() { - storedValues[PurchaselySdkMode.KEY] = PurchaselySdkMode.FULL.storageValue + fun `initSdkModeIfNeeded does not overwrite existing value`() { + settingsRepo.sdkModeStorage = PurchaselySdkMode.FULL.storageValue val vm = createViewModel() - vm.setSdkMode(PurchaselySdkMode.PAYWALL_OBSERVER) - verify { wrapper.restart() } + assertEquals(PurchaselySdkMode.FULL.storageValue, settingsRepo.sdkModeStorage) } } 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" }