Skip to content
Merged
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dependencies {
// Features
implementation(projects.feature.home)
implementation(projects.feature.article)
implementation(projects.feature.paywalls)

// RevenueCat
implementation(libs.revenuecat)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.navigation.compose.composable
import com.revenuecat.articles.paywall.core.navigation.CatArticlesScreen
import com.revenuecat.articles.paywall.feature.article.CatArticlesDetail
import com.revenuecat.articles.paywall.feature.home.CatArticlesHome
import com.revenuecat.articles.paywall.paywalls.CatCustomPaywalls

fun NavGraphBuilder.catArticlesNavigation(sharedTransitionScope: SharedTransitionScope) {
with(sharedTransitionScope) {
Expand All @@ -33,5 +34,9 @@ fun NavGraphBuilder.catArticlesNavigation(sharedTransitionScope: SharedTransitio
) {
CatArticlesDetail(animatedVisibilityScope = this)
}

composable<CatArticlesScreen.Paywalls> {
CatCustomPaywalls()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ package com.revenuecat.articles.paywall.coredata.di

import com.revenuecat.articles.paywall.coredata.repository.ArticlesRepository
import com.revenuecat.articles.paywall.coredata.repository.ArticlesRepositoryImpl
import com.revenuecat.articles.paywall.coredata.repository.DetailsRepository
import com.revenuecat.articles.paywall.coredata.repository.DetailsRepositoryImpl
import com.revenuecat.articles.paywall.coredata.repository.PaywallsRepository
import com.revenuecat.articles.paywall.coredata.repository.PaywallsRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
Expand All @@ -32,5 +32,5 @@ internal interface DataModule {
fun bindsArticlesRepository(articlesRepositoryImpl: ArticlesRepositoryImpl): ArticlesRepository

@Binds
fun bindsDetailsRepository(detailsRepositoryImpl: DetailsRepositoryImpl): DetailsRepository
fun bindsDetailsRepository(detailsRepositoryImpl: PaywallsRepositoryImpl): PaywallsRepository
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@
*/
package com.revenuecat.articles.paywall.coredata.repository

import android.app.Activity
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchaseResult
import com.skydoves.sandwich.ApiResponse
import kotlinx.coroutines.flow.Flow

interface DetailsRepository {
interface PaywallsRepository {

fun fetchOffering(): Flow<ApiResponse<Offering>>

fun fetchCustomerInfo(): Flow<CustomerInfo?>

fun awaitPurchases(
activity: Activity,
availablePackage: Package,
): Flow<ApiResponse<PurchaseResult>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,28 @@
*/
package com.revenuecat.articles.paywall.coredata.repository

import android.app.Activity
import com.revenuecat.articles.paywall.core.network.CatArticlesDispatchers
import com.revenuecat.articles.paywall.core.network.Dispatcher
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.awaitCustomerInfo
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase
import com.skydoves.sandwich.ApiResponse
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject

internal class DetailsRepositoryImpl @Inject constructor(
internal class PaywallsRepositoryImpl @Inject constructor(
@Dispatcher(CatArticlesDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
) : DetailsRepository {
) : PaywallsRepository {

override fun fetchOffering(): Flow<ApiResponse<Offering>> = flow {
try {
Expand All @@ -54,4 +58,18 @@ internal class DetailsRepositoryImpl @Inject constructor(
emit(null)
}
}

override fun awaitPurchases(activity: Activity, availablePackage: Package) = flow {
try {
val result = Purchases.sharedInstance.awaitPurchase(
purchaseParams = PurchaseParams.Builder(
activity = activity,
packageToPurchase = availablePackage,
).build(),
)
emit(ApiResponse.of { result })
} catch (e: Exception) {
emit(ApiResponse.exception(e))
}
}
}
1 change: 1 addition & 0 deletions core/designsystem/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@
<string name="paywall_description">This article is available to members only. \nUnlock it to access exclusive member benefits.</string>
<string name="paywall_cta">Join Cat Articles</string>
<string name="entitlement_premium">premium</string>
<string name="slide_subscribe">Slide to subscribe</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ sealed interface CatArticlesScreen {
@Serializable
data object CatHome : CatArticlesScreen

@Serializable
data object Paywalls : CatArticlesScreen

@Serializable
data class CatArticle(val article: Article) : CatArticlesScreen {
companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@

package com.revenuecat.articles.paywall.feature.article

import android.app.Activity
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionLayout
Expand All @@ -41,16 +39,12 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
Expand All @@ -59,7 +53,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.kmpalette.palette.graphics.Palette
import com.revenuecat.articles.paywall.compose.core.designsystem.R
Expand All @@ -71,12 +65,7 @@ import com.revenuecat.articles.paywall.core.designsystem.theme.CatArticlesTheme
import com.revenuecat.articles.paywall.core.model.Article
import com.revenuecat.articles.paywall.core.model.MockUtils.mockArticle
import com.revenuecat.articles.paywall.core.navigation.boundsTransform
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions
import com.skydoves.compose.effects.RememberedEffect
import com.skydoves.landscapist.ImageOptions
import com.skydoves.landscapist.components.rememberImageComponent
import com.skydoves.landscapist.glide.GlideImage
Expand All @@ -91,8 +80,6 @@ fun SharedTransitionScope.CatArticlesDetail(
viewModel: CatArticlesDetailViewModel = hiltViewModel(),
) {
val article by viewModel.article.collectAsStateWithLifecycle()
val customerInfo by viewModel.customerInfo.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Column(
modifier = Modifier
Expand All @@ -107,8 +94,7 @@ fun SharedTransitionScope.CatArticlesDetail(
} else {
CatArticlesDetailContent(
article = article!!,
uiState = uiState,
customerInfo = customerInfo,
viewModel = viewModel,
animatedVisibilityScope = animatedVisibilityScope,
navigateUp = { viewModel.navigateUp() },
)
Expand All @@ -119,18 +105,16 @@ fun SharedTransitionScope.CatArticlesDetail(
@Composable
private fun SharedTransitionScope.CatArticlesDetailContent(
article: Article,
uiState: DetailUiState,
customerInfo: CustomerInfo?,
viewModel: CatArticlesDetailViewModel = hiltViewModel(),
animatedVisibilityScope: AnimatedVisibilityScope,
navigateUp: () -> Unit,
) {
var palette by rememberPaletteState()
val backgroundBrush by palette.paletteBackgroundBrush()

val context = LocalContext.current
val customerInfo by viewModel.customerInfo.collectAsStateWithLifecycle()
val entitlementIdentifier = stringResource(R.string.entitlement_premium)
val isEntitled = customerInfo?.entitlements[entitlementIdentifier]?.isActive == true
var isVisiblePaywallDialog by remember(customerInfo) { mutableStateOf(false) }

DetailsAppBar(
article = article,
Expand All @@ -146,34 +130,9 @@ private fun SharedTransitionScope.CatArticlesDetailContent(

DetailsContent(
article = article,
onJoinClicked = { viewModel.navigateToCustomPaywalls() },
isEntitled = isEntitled,
onJoinClicked = {
if (uiState is DetailUiState.Success) {
isVisiblePaywallDialog = true
} else if (uiState is DetailUiState.Error) {
Toast.makeText(context, uiState.message, Toast.LENGTH_SHORT).show()
}
},
)

if (isVisiblePaywallDialog && !isEntitled) {
val offering = (uiState as? DetailUiState.Success)?.offering ?: return
PaywallDialog(
PaywallDialogOptions.Builder()
.setDismissRequest { isVisiblePaywallDialog = false }
.setOffering(offering)
.build(),
)
}

RememberedEffect(isVisiblePaywallDialog) {
val window = (context as Activity).window
window.statusBarColor = if (isVisiblePaywallDialog) {
Color.Black.toArgb()
} else {
Color.Transparent.toArgb()
}
}
}

@Composable
Expand Down Expand Up @@ -327,15 +286,6 @@ private fun CatArticlesDetailContentPreview() {
Column(modifier = Modifier.fillMaxSize()) {
CatArticlesDetailContent(
article = mockArticle,
uiState = DetailUiState.Success(
Offering(
identifier = "",
serverDescription = "",
metadata = mapOf(),
availablePackages = listOf(),
),
),
customerInfo = null,
animatedVisibilityScope = this@AnimatedVisibility,
navigateUp = {},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,61 +15,37 @@
*/
package com.revenuecat.articles.paywall.feature.article

import androidx.compose.runtime.Stable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.revenuecat.articles.paywall.core.model.Article
import com.revenuecat.articles.paywall.core.navigation.AppComposeNavigator
import com.revenuecat.articles.paywall.core.navigation.CatArticlesScreen
import com.revenuecat.articles.paywall.coredata.repository.DetailsRepository
import com.revenuecat.purchases.Offering
import com.skydoves.sandwich.fold
import com.revenuecat.articles.paywall.coredata.repository.PaywallsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject

@HiltViewModel
class CatArticlesDetailViewModel @Inject constructor(
repository: DetailsRepository,
repository: PaywallsRepository,
private val navigator: AppComposeNavigator<CatArticlesScreen>,
savedStateHandle: SavedStateHandle,
) : ViewModel() {

val article = savedStateHandle.getStateFlow<Article?>("article", null)

val customerInfo = repository.fetchCustomerInfo().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null,
)

val uiState: StateFlow<DetailUiState> = repository.fetchOffering()
.mapLatest { response ->
response.fold(
onSuccess = { DetailUiState.Success(it) },
onFailure = { DetailUiState.Error(it) },
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DetailUiState.Loading,
)
fun navigateToCustomPaywalls() {
navigator.navigate(CatArticlesScreen.Paywalls)
}

fun navigateUp() {
navigator.navigateUp()
}
}

@Stable
sealed interface DetailUiState {

data object Loading : DetailUiState

data class Success(val offering: Offering) : DetailUiState

data class Error(val message: String?) : DetailUiState
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.revenuecat.articles.paywall.compose.core.designsystem.R
import com.revenuecat.articles.paywall.core.designsystem.component.CatArticlesAppBar
Expand Down
1 change: 1 addition & 0 deletions feature/paywalls/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
39 changes: 39 additions & 0 deletions feature/paywalls/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 RevenueCat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("revenuecat.android.library")
id("revenuecat.android.library.compose")
id("revenuecat.android.feature")
id("revenuecat.android.hilt")
id("revenuecat.spotless")
}

android {
namespace = "com.revenuecat.articles.paywall.compose.feature.paywalls"
}

dependencies {
// RevenueCat Purchases
implementation(libs.revenuecat.ui)

// Compose
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.runtime)
implementation(libs.compose.slidetounlock)
}
Loading
Loading