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
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
29 changes: 29 additions & 0 deletions client/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ kotlin {
}
}

// Active Xcode developer dir (macOS only) so iOS test executables can locate the Swift
// back-compat static libraries RevenueCat's native code auto-links. Static frameworks are
// linked by Xcode (which supplies these), but Kotlin/Native test binaries are linked by Gradle
// outside Xcode and otherwise fail with "library 'swiftCompatibility56' not found".
val xcodeDeveloperDir: String? =
if (System.getProperty("os.name").orEmpty().startsWith("Mac")) {
project.providers
.exec { commandLine("xcode-select", "-p") }
.standardOutput
.asText
.get()
.trim()
} else {
null
}

listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
Expand All @@ -61,6 +77,13 @@ kotlin {
org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable::class.java
) {
linkerOpts.add("-lsqlite3")
xcodeDeveloperDir?.let { developerDir ->
val sdk =
if (iosTarget.name.contains("Simulator")) "iphonesimulator" else "iphoneos"
linkerOpts.add(
"-L$developerDir/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$sdk"
)
}
}
}

Expand Down Expand Up @@ -119,6 +142,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 @@ -156,6 +183,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
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 @@ -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 @@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
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 @@ -39,6 +40,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 @@ -71,10 +74,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 @@ -338,6 +369,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 @@ -459,7 +499,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 @@ -590,16 +634,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 @@ -619,6 +662,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 @@ -720,6 +769,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