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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.plusmobileapps.chefmate

import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable

/**
* RevenueCat's iOS code is Swift, and its object files auto-link the Swift back-compat static
* libraries (swiftCompatibility56, swiftCompatibilityConcurrency, …) using a search path baked into
* the published klib — an Xcode install that only exists on RevenueCat's build machine. Frameworks
* are linked by Xcode, which supplies those libraries anyway, but Kotlin/Native test executables
* are linked by Gradle outside Xcode and fail with "library 'swiftCompatibility56' not found".
*
* Point the iOS test binaries at the *active* toolchain's Swift lib dir for the target SDK. Call
* this from any module whose iOS test binaries link RevenueCat, directly or transitively. No-op off
* macOS so non-Apple CI is unaffected.
*/
fun Project.linkSwiftBackCompatIntoIosTestBinaries() {
if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return
val developerDir =
providers.exec { commandLine("xcode-select", "-p") }.standardOutput.asText.get().trim()

extensions.configure<KotlinMultiplatformExtension> {
targets.withType(KotlinNativeTarget::class.java).configureEach {
if (!name.startsWith("ios")) return@configureEach
val sdk = if (name.contains("Simulator")) "iphonesimulator" else "iphoneos"
val swiftLibDir = "$developerDir/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$sdk"
binaries.withType(TestExecutable::class.java).configureEach {
linkerOpts.add("-L$swiftLibDir")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ class BottomNavBlocImpl(
SettingsBloc.Output.OpenManageProfile -> OpenManageProfile
SettingsBloc.Output.OpenNotifications -> BottomNavBloc.Output.OpenNotifications
SettingsBloc.Output.OpenAppSettings -> OpenAppSettings
SettingsBloc.Output.OpenSubscription -> BottomNavBloc.Output.OpenSubscription
SettingsBloc.Output.OpenAiChat -> BottomNavBloc.Output.OpenAiChat
SettingsBloc.Output.OpenDeveloperSettings -> BottomNavBloc.Output.OpenDeveloperSettings
SettingsBloc.Output.OpenOnboarding -> BottomNavBloc.Output.OpenOnboarding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ interface BottomNavBloc : BackHandlerOwner, BackClickBloc, ComposeScreen {

data object OpenAppSettings : Output()

data object OpenSubscription : Output()

data object OpenAiChat : Output()

data object OpenDeveloperSettings : Output()
Expand Down
10 changes: 10 additions & 0 deletions client/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import com.plusmobileapps.chefmate.linkSwiftBackCompatIntoIosTestBinaries
import java.util.Properties
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
Expand Down Expand Up @@ -121,6 +122,10 @@ kotlin {
api(projects.client.util.impl)
api(projects.client.settings.impl)
api(projects.client.settings.root.impl)
api(projects.client.subscription.data.public)
api(projects.client.subscription.data.impl)
api(projects.client.subscription.public)
api(projects.client.subscription.impl)
api(projects.client.developerSettings.impl)
api(projects.client.toast.impl)
api(projects.client.toast.public)
Expand Down Expand Up @@ -159,6 +164,8 @@ kotlin {
implementation(projects.client.testing)
implementation(projects.client.database.testing)
implementation(projects.client.auth.data.testing)
implementation(projects.client.subscription.data.testing)
implementation(projects.client.subscription.implRobots)
implementation(projects.client.aichat.implRobots)
implementation(projects.client.bottomnav.implRobots)
implementation(projects.client.browser.implRobots)
Expand Down Expand Up @@ -189,6 +196,9 @@ kotlin {
}
}

// RevenueCat is pulled in transitively via :client:subscription:data:impl.
linkSwiftBackCompatIntoIosTestBinaries()

android {
namespace = "com.plusmobileapps.chefmate"
compileSdk = libs.versions.android.compileSdk.get().toInt()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.plusmobileapps.chefmate
import android.app.Application
import com.plusmobileapps.chefmate.buildconfig.BuildConfig
import com.plusmobileapps.chefmate.di.AndroidApplicationComponent
import com.plusmobileapps.chefmate.subscription.data.impl.SubscriptionInitializer
import dev.zacsweers.metro.createGraphFactory

class MyApplication : Application() {
Expand All @@ -11,6 +12,8 @@ class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
BugsnagInitializer(this).initialize(BuildConfig.BUGSNAG_API_KEY)
SubscriptionInitializer()
.initialize(BuildConfig.REVENUECAT_ANDROID_API_KEY, BuildConfig.IS_DEBUG)
appComponent = createGraphFactory<AndroidApplicationComponent.Factory>().create(this)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import com.plusmobileapps.chefmate.fakes.FakeGeminiClient
import com.plusmobileapps.chefmate.fakes.FakeGeminiRecipeExtractor
import com.plusmobileapps.chefmate.fakes.TestAuthenticationRepository
import com.plusmobileapps.chefmate.fakes.TestFeatureFlags
import com.plusmobileapps.chefmate.fakes.TestSubscriptionRepository

interface TestApplicationComponent : ApplicationComponent {
val fakeDatabase: FakeDatabase
val testAuthenticationRepository: TestAuthenticationRepository
val fakeGeminiClient: FakeGeminiClient
val fakeGeminiRecipeExtractor: FakeGeminiRecipeExtractor
val testFeatureFlags: TestFeatureFlags
val testSubscriptionRepository: TestSubscriptionRepository
}

expect fun createTestApplicationComponent(): TestApplicationComponent
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.plusmobileapps.chefmate.fakes

import com.plusmobileapps.chefmate.di.AppScope
import com.plusmobileapps.chefmate.subscription.data.SubscriptionOffering
import com.plusmobileapps.chefmate.subscription.data.SubscriptionRepository
import com.plusmobileapps.chefmate.subscription.data.impl.SubscriptionRepositoryImpl
import com.plusmobileapps.chefmate.subscription.data.testing.FakeSubscriptionRepository
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn

/**
* Replaces the production [SubscriptionRepositoryImpl] in tests. Defaults to a non-premium, loaded
* state so the AI-chat gate is exercised by default; call [setPremium] to unlock premium flows.
*/
@SingleIn(AppScope::class)
@Inject
@ContributesBinding(scope = AppScope::class, replaces = [SubscriptionRepositoryImpl::class])
class TestSubscriptionRepository(
private val fake: FakeSubscriptionRepository = FakeSubscriptionRepository()
) : SubscriptionRepository by fake {

fun setPremium(isPremium: Boolean) = fake.setPremium(isPremium)

fun setOffering(offering: SubscriptionOffering?) = fake.setOffering(offering)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class AiChatHistoryNavigationUiTest {
fun history_button_opens_history_screen_with_existing_conversation() =
runRootBlocTest { component ->
component.testFeatureFlags.set(FeatureFlagRegistry.AiChat, true)
component.testSubscriptionRepository.setPremium(true)
component.fakeGeminiClient.deltas = listOf("model reply about chicken")

bottomNav().clickMoreTab()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class AiChatNavigationUiTest {
fun add_recipe_pill_opens_edit_form_pre_filled_with_extracted_recipe() =
runRootBlocTest { component ->
component.testFeatureFlags.set(FeatureFlagRegistry.AiChat, true)
component.testSubscriptionRepository.setPremium(true)
component.fakeGeminiClient.deltas = listOf("Try lemon-roasted chicken thighs!")
component.fakeGeminiRecipeExtractor.response = extractedRecipe

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class AiChatRecipeReopenUiTest {
@Test
fun reopening_from_a_recipe_shows_the_prior_conversation() = runRootBlocTest { component ->
component.testFeatureFlags.set(FeatureFlagRegistry.AiChat, true)
component.testSubscriptionRepository.setPremium(true)
component.fakeGeminiClient.deltas = listOf("Bake it at 400°F for 25 minutes.")

recipeList().clickRecipe(TestRecipes.fullyPopulated.title)
Expand All @@ -43,6 +44,7 @@ class AiChatRecipeReopenUiTest {
@Test
fun new_conversation_button_clears_the_reopened_conversation() = runRootBlocTest { component ->
component.testFeatureFlags.set(FeatureFlagRegistry.AiChat, true)
component.testSubscriptionRepository.setPremium(true)
component.fakeGeminiClient.deltas = listOf("Sure, here's how.")

recipeList().clickRecipe(TestRecipes.fullyPopulated.title)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class AiChatSheetNavigationUiTest {
@Test
fun ai_chat_from_recipe_detail_opens_full_screen() = runRootBlocTest { component ->
component.testFeatureFlags.set(FeatureFlagRegistry.AiChat, true)
component.testSubscriptionRepository.setPremium(true)

recipeList().clickRecipe(TestRecipes.fullyPopulated.title)
recipeDetail().awaitDisplayed().tapAiChat()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class RecipeFromPhotoUiTest {
@Test
fun ai_chat_shows_attach_photo_button() = runRootBlocTest { component ->
component.testFeatureFlags.set(FeatureFlagRegistry.AiChat, true)
component.testSubscriptionRepository.setPremium(true)

bottomNav().clickMoreTab()
more().awaitDisplayed().clickAiChatRow()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@file:OptIn(ExperimentalTestApi::class)

package com.plusmobileapps.chefmate.tests

import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.waitUntilExactlyOneExists
import com.plusmobileapps.chefmate.featureflag.FeatureFlagRegistry
import com.plusmobileapps.chefmate.harness.runRootBlocTest
import com.plusmobileapps.chefmate.recipe.bottomnav.robots.bottomNav
import com.plusmobileapps.chefmate.settings.robots.more
import com.plusmobileapps.chefmate.subscription.robots.subscription
import kotlin.test.Test

@OptIn(ExperimentalTestApi::class)
class SubscriptionNavigationUiTest {

@Test
fun more_tab_premium_row_opens_paywall() = runRootBlocTest {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickSubscriptionRow()

subscription().awaitDisplayed()
}

@Test
fun ai_chat_when_not_premium_shows_gate_dialog_then_paywall_on_confirm() =
runRootBlocTest { component ->
// AI chat is enabled but the user is not premium (the test repo's default), so tapping
// it
// must surface the premium gate rather than the chat.
component.testFeatureFlags.set(FeatureFlagRegistry.AiChat, true)

bottomNav().clickMoreTab()
more().awaitDisplayed().clickAiChatRow()

// Gate dialog appears; confirming ("See Plans") opens the paywall.
waitUntilExactlyOneExists(hasText("See Plans"))
onNode(hasText("See Plans")).performClick()

subscription().awaitDisplayed()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.plusmobileapps.chefmate

import com.plusmobileapps.chefmate.buildconfig.BuildConfig
import com.plusmobileapps.chefmate.subscription.data.impl.SubscriptionInitializer

/** Called from `iOSApp.swift` at launch to configure RevenueCat with the iOS API key. */
fun initializeSubscriptions() {
SubscriptionInitializer().initialize(BuildConfig.REVENUECAT_IOS_API_KEY, BuildConfig.IS_DEBUG)
}
3 changes: 3 additions & 0 deletions client/root/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ kotlin {
implementation(projects.client.recipebook.edit.public)
implementation(projects.client.settings.root.public)
implementation(projects.client.auth.ui.public)
implementation(projects.client.subscription.public)
implementation(projects.client.subscription.data.public)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(projects.client.auth.data.testing)
implementation(projects.client.featureflag.testing)
implementation(projects.client.subscription.data.testing)
implementation(libs.multiplatform.settings.test)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.popWhile
import com.arkivanov.decompose.router.stack.replaceAll
import com.arkivanov.decompose.router.stack.replaceCurrent
import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.plusmobileapps.chefmate.BlocContext
import com.plusmobileapps.chefmate.aichat.AiChatRootBloc
Expand Down Expand Up @@ -40,6 +41,8 @@ import com.plusmobileapps.chefmate.recipebook.edit.EditRecipeBookBloc
import com.plusmobileapps.chefmate.root.RootBloc.Child.BottomNavigation
import com.plusmobileapps.chefmate.root.RootBlocImpl.Configuration.RecipeRoot
import com.plusmobileapps.chefmate.settings.root.SettingsRootBloc
import com.plusmobileapps.chefmate.subscription.SubscriptionBloc
import com.plusmobileapps.chefmate.subscription.data.SubscriptionRepository
import com.plusmobileapps.metro.extensions.assistedfactory.ContributesAssistedFactory
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
Expand Down Expand Up @@ -72,10 +75,38 @@ class RootBlocImpl(
private val exportRecipes: ExportRecipesBloc.Factory,
private val editRecipeBook: EditRecipeBookBloc.Factory,
private val editGroceryList: EditGroceryListBloc.Factory,
private val subscriptionRepository: SubscriptionRepository,
private val subscription: SubscriptionBloc.Factory,
) : RootBloc, BlocContext by context {

init {
createScope().launch { featureFlags.refresh() }
// Warm the subscription state so the AI-chat gate reads an accurate premium flag.
createScope().launch { subscriptionRepository.refresh() }
}

private val _premiumDialog = MutableValue(false)
override val premiumDialog: Value<Boolean> = _premiumDialog

override fun onPremiumDialogConfirmed() {
_premiumDialog.value = false
navigation.bringToFront(Configuration.Subscription)
}

override fun onPremiumDialogDismissed() {
_premiumDialog.value = false
}

/**
* Central premium gate for the AI chat. Premium users open it directly; everyone else gets the
* "premium required" dialog, whose confirmation routes to the paywall.
*/
private fun openAiChat(recipeContextId: Long?) {
if (subscriptionRepository.state.value.isPremium) {
navigation.bringToFront(Configuration.AiChat(recipeContextId))
} else {
_premiumDialog.value = true
}
}

private val initialBottomNavTab: BottomNavBloc.Tab? =
Expand Down Expand Up @@ -346,6 +377,15 @@ class RootBlocImpl(
)
)

Configuration.Subscription ->
RootBloc.Child.Subscription(
bloc =
subscription.create(
context = context,
output = ::handleSubscriptionOutput,
)
)

is Configuration.ExportRecipes ->
RootBloc.Child.ExportRecipes(
bloc =
Expand Down Expand Up @@ -467,7 +507,11 @@ class RootBlocImpl(
}

BottomNavBloc.Output.OpenAiChat -> {
navigation.bringToFront(Configuration.AiChat())
openAiChat(recipeContextId = null)
}

BottomNavBloc.Output.OpenSubscription -> {
navigation.bringToFront(Configuration.Subscription)
}

BottomNavBloc.Output.OpenDeveloperSettings -> {
Expand Down Expand Up @@ -598,16 +642,15 @@ class RootBlocImpl(
navigation.bringToFront(Configuration.CookMode(output.recipeId))
}
is RecipeRootBloc.Output.OpenAiChat -> {
navigation.bringToFront(Configuration.AiChat(recipeContextId = output.recipeId))
openAiChat(recipeContextId = output.recipeId)
}
}
}

private fun handleCookModeOutput(output: CookModeBloc.Output) {
when (output) {
CookModeBloc.Output.Finished -> navigation.pop()
is CookModeBloc.Output.OpenAiChat ->
navigation.bringToFront(Configuration.AiChat(recipeContextId = output.recipeId))
is CookModeBloc.Output.OpenAiChat -> openAiChat(recipeContextId = output.recipeId)
}
}

Expand All @@ -627,6 +670,12 @@ class RootBlocImpl(
}
}

private fun handleSubscriptionOutput(output: SubscriptionBloc.Output) {
when (output) {
SubscriptionBloc.Output.Finished -> navigation.pop()
}
}

private fun handleMealPlannerOutput(output: MealPlannerRootBloc.Output) {
when (output) {
MealPlannerRootBloc.Output.Finished -> navigation.pop()
Expand Down Expand Up @@ -742,6 +791,8 @@ class RootBlocImpl(

@Serializable data class AiChat(val recipeContextId: Long? = null) : Configuration()

@Serializable data object Subscription : Configuration()

@Serializable data class ExportRecipes(val recipeIds: Set<Long>?) : Configuration()

@Serializable data class EditRecipeBook(val bookId: Long?) : Configuration()
Expand Down
Loading
Loading