Skip to content

Commit 157bc38

Browse files
authored
Merge pull request #14 from RevenueCat/feature/ui-paywalls-screen
Feature: Paywall UI and in-app purchases
2 parents e78d1c8 + 2d48e9c commit 157bc38

File tree

17 files changed

+372
-91
lines changed

17 files changed

+372
-91
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ dependencies {
6565
// Features
6666
implementation(projects.feature.home)
6767
implementation(projects.feature.article)
68+
implementation(projects.feature.paywalls)
6869

6970
// RevenueCat
7071
implementation(libs.revenuecat)

app/src/main/kotlin/com/revenuecat/articles/paywall/compose/navigation/CatArticlesNavigation.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.navigation.compose.composable
2121
import com.revenuecat.articles.paywall.core.navigation.CatArticlesScreen
2222
import com.revenuecat.articles.paywall.feature.article.CatArticlesDetail
2323
import com.revenuecat.articles.paywall.feature.home.CatArticlesHome
24+
import com.revenuecat.articles.paywall.paywalls.CatCustomPaywalls
2425

2526
fun NavGraphBuilder.catArticlesNavigation(sharedTransitionScope: SharedTransitionScope) {
2627
with(sharedTransitionScope) {
@@ -33,5 +34,9 @@ fun NavGraphBuilder.catArticlesNavigation(sharedTransitionScope: SharedTransitio
3334
) {
3435
CatArticlesDetail(animatedVisibilityScope = this)
3536
}
37+
38+
composable<CatArticlesScreen.Paywalls> {
39+
CatCustomPaywalls()
40+
}
3641
}
3742
}

core/data/src/main/kotlin/com/revenuecat/articles/paywall/coredata/di/DataModule.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ package com.revenuecat.articles.paywall.coredata.di
1717

1818
import com.revenuecat.articles.paywall.coredata.repository.ArticlesRepository
1919
import com.revenuecat.articles.paywall.coredata.repository.ArticlesRepositoryImpl
20-
import com.revenuecat.articles.paywall.coredata.repository.DetailsRepository
21-
import com.revenuecat.articles.paywall.coredata.repository.DetailsRepositoryImpl
20+
import com.revenuecat.articles.paywall.coredata.repository.PaywallsRepository
21+
import com.revenuecat.articles.paywall.coredata.repository.PaywallsRepositoryImpl
2222
import dagger.Binds
2323
import dagger.Module
2424
import dagger.hilt.InstallIn
@@ -32,5 +32,5 @@ internal interface DataModule {
3232
fun bindsArticlesRepository(articlesRepositoryImpl: ArticlesRepositoryImpl): ArticlesRepository
3333

3434
@Binds
35-
fun bindsDetailsRepository(detailsRepositoryImpl: DetailsRepositoryImpl): DetailsRepository
35+
fun bindsDetailsRepository(detailsRepositoryImpl: PaywallsRepositoryImpl): PaywallsRepository
3636
}

core/data/src/main/kotlin/com/revenuecat/articles/paywall/coredata/repository/DetailsRepository.kt renamed to core/data/src/main/kotlin/com/revenuecat/articles/paywall/coredata/repository/PaywallsRepository.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,22 @@
1515
*/
1616
package com.revenuecat.articles.paywall.coredata.repository
1717

18+
import android.app.Activity
1819
import com.revenuecat.purchases.CustomerInfo
1920
import com.revenuecat.purchases.Offering
21+
import com.revenuecat.purchases.Package
22+
import com.revenuecat.purchases.PurchaseResult
2023
import com.skydoves.sandwich.ApiResponse
2124
import kotlinx.coroutines.flow.Flow
2225

23-
interface DetailsRepository {
26+
interface PaywallsRepository {
2427

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

2730
fun fetchCustomerInfo(): Flow<CustomerInfo?>
31+
32+
fun awaitPurchases(
33+
activity: Activity,
34+
availablePackage: Package,
35+
): Flow<ApiResponse<PurchaseResult>>
2836
}

core/data/src/main/kotlin/com/revenuecat/articles/paywall/coredata/repository/DetailsRepositoryImpl.kt renamed to core/data/src/main/kotlin/com/revenuecat/articles/paywall/coredata/repository/PaywallsRepositoryImpl.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,28 @@
1515
*/
1616
package com.revenuecat.articles.paywall.coredata.repository
1717

18+
import android.app.Activity
1819
import com.revenuecat.articles.paywall.core.network.CatArticlesDispatchers
1920
import com.revenuecat.articles.paywall.core.network.Dispatcher
2021
import com.revenuecat.purchases.CustomerInfo
2122
import com.revenuecat.purchases.Offering
23+
import com.revenuecat.purchases.Package
24+
import com.revenuecat.purchases.PurchaseParams
2225
import com.revenuecat.purchases.Purchases
2326
import com.revenuecat.purchases.PurchasesException
2427
import com.revenuecat.purchases.awaitCustomerInfo
2528
import com.revenuecat.purchases.awaitOfferings
29+
import com.revenuecat.purchases.awaitPurchase
2630
import com.skydoves.sandwich.ApiResponse
2731
import kotlinx.coroutines.CoroutineDispatcher
2832
import kotlinx.coroutines.flow.Flow
2933
import kotlinx.coroutines.flow.flow
3034
import kotlinx.coroutines.flow.flowOn
3135
import javax.inject.Inject
3236

33-
internal class DetailsRepositoryImpl @Inject constructor(
37+
internal class PaywallsRepositoryImpl @Inject constructor(
3438
@Dispatcher(CatArticlesDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
35-
) : DetailsRepository {
39+
) : PaywallsRepository {
3640

3741
override fun fetchOffering(): Flow<ApiResponse<Offering>> = flow {
3842
try {
@@ -54,4 +58,18 @@ internal class DetailsRepositoryImpl @Inject constructor(
5458
emit(null)
5559
}
5660
}
61+
62+
override fun awaitPurchases(activity: Activity, availablePackage: Package) = flow {
63+
try {
64+
val result = Purchases.sharedInstance.awaitPurchase(
65+
purchaseParams = PurchaseParams.Builder(
66+
activity = activity,
67+
packageToPurchase = availablePackage,
68+
).build(),
69+
)
70+
emit(ApiResponse.of { result })
71+
} catch (e: Exception) {
72+
emit(ApiResponse.exception(e))
73+
}
74+
}
5775
}

core/designsystem/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@
2020
<string name="paywall_description">This article is available to members only. \nUnlock it to access exclusive member benefits.</string>
2121
<string name="paywall_cta">Join Cat Articles</string>
2222
<string name="entitlement_premium">premium</string>
23+
<string name="slide_subscribe">Slide to subscribe</string>
2324
</resources>

core/navigation/src/main/kotlin/com/revenuecat/articles/paywall/core/navigation/CatArticlesScreen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ sealed interface CatArticlesScreen {
2424
@Serializable
2525
data object CatHome : CatArticlesScreen
2626

27+
@Serializable
28+
data object Paywalls : CatArticlesScreen
29+
2730
@Serializable
2831
data class CatArticle(val article: Article) : CatArticlesScreen {
2932
companion object {

feature/article/src/main/kotlin/com/revenuecat/articles/paywall/feature/article/CatArticlesDetail.kt

Lines changed: 5 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717

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

20-
import android.app.Activity
21-
import android.widget.Toast
2220
import androidx.compose.animation.AnimatedVisibility
2321
import androidx.compose.animation.AnimatedVisibilityScope
2422
import androidx.compose.animation.SharedTransitionLayout
@@ -41,16 +39,12 @@ import androidx.compose.material3.Icon
4139
import androidx.compose.material3.Text
4240
import androidx.compose.runtime.Composable
4341
import androidx.compose.runtime.getValue
44-
import androidx.compose.runtime.mutableStateOf
45-
import androidx.compose.runtime.remember
4642
import androidx.compose.runtime.setValue
4743
import androidx.compose.ui.Alignment
4844
import androidx.compose.ui.Modifier
4945
import androidx.compose.ui.graphics.Brush
5046
import androidx.compose.ui.graphics.Color
51-
import androidx.compose.ui.graphics.toArgb
5247
import androidx.compose.ui.layout.ContentScale
53-
import androidx.compose.ui.platform.LocalContext
5448
import androidx.compose.ui.platform.LocalInspectionMode
5549
import androidx.compose.ui.res.painterResource
5650
import androidx.compose.ui.res.stringResource
@@ -59,7 +53,7 @@ import androidx.compose.ui.text.style.TextAlign
5953
import androidx.compose.ui.tooling.preview.Preview
6054
import androidx.compose.ui.unit.dp
6155
import androidx.compose.ui.unit.sp
62-
import androidx.hilt.navigation.compose.hiltViewModel
56+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
6357
import androidx.lifecycle.compose.collectAsStateWithLifecycle
6458
import com.kmpalette.palette.graphics.Palette
6559
import com.revenuecat.articles.paywall.compose.core.designsystem.R
@@ -71,12 +65,7 @@ import com.revenuecat.articles.paywall.core.designsystem.theme.CatArticlesTheme
7165
import com.revenuecat.articles.paywall.core.model.Article
7266
import com.revenuecat.articles.paywall.core.model.MockUtils.mockArticle
7367
import com.revenuecat.articles.paywall.core.navigation.boundsTransform
74-
import com.revenuecat.purchases.CustomerInfo
7568
import com.revenuecat.purchases.InternalRevenueCatAPI
76-
import com.revenuecat.purchases.Offering
77-
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog
78-
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions
79-
import com.skydoves.compose.effects.RememberedEffect
8069
import com.skydoves.landscapist.ImageOptions
8170
import com.skydoves.landscapist.components.rememberImageComponent
8271
import com.skydoves.landscapist.glide.GlideImage
@@ -91,8 +80,6 @@ fun SharedTransitionScope.CatArticlesDetail(
9180
viewModel: CatArticlesDetailViewModel = hiltViewModel(),
9281
) {
9382
val article by viewModel.article.collectAsStateWithLifecycle()
94-
val customerInfo by viewModel.customerInfo.collectAsStateWithLifecycle()
95-
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
9683

9784
Column(
9885
modifier = Modifier
@@ -107,8 +94,7 @@ fun SharedTransitionScope.CatArticlesDetail(
10794
} else {
10895
CatArticlesDetailContent(
10996
article = article!!,
110-
uiState = uiState,
111-
customerInfo = customerInfo,
97+
viewModel = viewModel,
11298
animatedVisibilityScope = animatedVisibilityScope,
11399
navigateUp = { viewModel.navigateUp() },
114100
)
@@ -119,18 +105,16 @@ fun SharedTransitionScope.CatArticlesDetail(
119105
@Composable
120106
private fun SharedTransitionScope.CatArticlesDetailContent(
121107
article: Article,
122-
uiState: DetailUiState,
123-
customerInfo: CustomerInfo?,
108+
viewModel: CatArticlesDetailViewModel = hiltViewModel(),
124109
animatedVisibilityScope: AnimatedVisibilityScope,
125110
navigateUp: () -> Unit,
126111
) {
127112
var palette by rememberPaletteState()
128113
val backgroundBrush by palette.paletteBackgroundBrush()
129114

130-
val context = LocalContext.current
115+
val customerInfo by viewModel.customerInfo.collectAsStateWithLifecycle()
131116
val entitlementIdentifier = stringResource(R.string.entitlement_premium)
132117
val isEntitled = customerInfo?.entitlements[entitlementIdentifier]?.isActive == true
133-
var isVisiblePaywallDialog by remember(customerInfo) { mutableStateOf(false) }
134118

135119
DetailsAppBar(
136120
article = article,
@@ -146,34 +130,9 @@ private fun SharedTransitionScope.CatArticlesDetailContent(
146130

147131
DetailsContent(
148132
article = article,
133+
onJoinClicked = { viewModel.navigateToCustomPaywalls() },
149134
isEntitled = isEntitled,
150-
onJoinClicked = {
151-
if (uiState is DetailUiState.Success) {
152-
isVisiblePaywallDialog = true
153-
} else if (uiState is DetailUiState.Error) {
154-
Toast.makeText(context, uiState.message, Toast.LENGTH_SHORT).show()
155-
}
156-
},
157135
)
158-
159-
if (isVisiblePaywallDialog && !isEntitled) {
160-
val offering = (uiState as? DetailUiState.Success)?.offering ?: return
161-
PaywallDialog(
162-
PaywallDialogOptions.Builder()
163-
.setDismissRequest { isVisiblePaywallDialog = false }
164-
.setOffering(offering)
165-
.build(),
166-
)
167-
}
168-
169-
RememberedEffect(isVisiblePaywallDialog) {
170-
val window = (context as Activity).window
171-
window.statusBarColor = if (isVisiblePaywallDialog) {
172-
Color.Black.toArgb()
173-
} else {
174-
Color.Transparent.toArgb()
175-
}
176-
}
177136
}
178137

179138
@Composable
@@ -327,15 +286,6 @@ private fun CatArticlesDetailContentPreview() {
327286
Column(modifier = Modifier.fillMaxSize()) {
328287
CatArticlesDetailContent(
329288
article = mockArticle,
330-
uiState = DetailUiState.Success(
331-
Offering(
332-
identifier = "",
333-
serverDescription = "",
334-
metadata = mapOf(),
335-
availablePackages = listOf(),
336-
),
337-
),
338-
customerInfo = null,
339289
animatedVisibilityScope = this@AnimatedVisibility,
340290
navigateUp = {},
341291
)

feature/article/src/main/kotlin/com/revenuecat/articles/paywall/feature/article/CatArticlesDetailViewModel.kt

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,61 +15,37 @@
1515
*/
1616
package com.revenuecat.articles.paywall.feature.article
1717

18-
import androidx.compose.runtime.Stable
1918
import androidx.lifecycle.SavedStateHandle
2019
import androidx.lifecycle.ViewModel
2120
import androidx.lifecycle.viewModelScope
2221
import com.revenuecat.articles.paywall.core.model.Article
2322
import com.revenuecat.articles.paywall.core.navigation.AppComposeNavigator
2423
import com.revenuecat.articles.paywall.core.navigation.CatArticlesScreen
25-
import com.revenuecat.articles.paywall.coredata.repository.DetailsRepository
26-
import com.revenuecat.purchases.Offering
27-
import com.skydoves.sandwich.fold
24+
import com.revenuecat.articles.paywall.coredata.repository.PaywallsRepository
2825
import dagger.hilt.android.lifecycle.HiltViewModel
2926
import kotlinx.coroutines.flow.SharingStarted
30-
import kotlinx.coroutines.flow.StateFlow
31-
import kotlinx.coroutines.flow.mapLatest
3227
import kotlinx.coroutines.flow.stateIn
3328
import javax.inject.Inject
3429

3530
@HiltViewModel
3631
class CatArticlesDetailViewModel @Inject constructor(
37-
repository: DetailsRepository,
32+
repository: PaywallsRepository,
3833
private val navigator: AppComposeNavigator<CatArticlesScreen>,
3934
savedStateHandle: SavedStateHandle,
4035
) : ViewModel() {
4136

4237
val article = savedStateHandle.getStateFlow<Article?>("article", null)
43-
4438
val customerInfo = repository.fetchCustomerInfo().stateIn(
4539
scope = viewModelScope,
4640
started = SharingStarted.WhileSubscribed(5000),
4741
initialValue = null,
4842
)
4943

50-
val uiState: StateFlow<DetailUiState> = repository.fetchOffering()
51-
.mapLatest { response ->
52-
response.fold(
53-
onSuccess = { DetailUiState.Success(it) },
54-
onFailure = { DetailUiState.Error(it) },
55-
)
56-
}.stateIn(
57-
scope = viewModelScope,
58-
started = SharingStarted.WhileSubscribed(5000),
59-
initialValue = DetailUiState.Loading,
60-
)
44+
fun navigateToCustomPaywalls() {
45+
navigator.navigate(CatArticlesScreen.Paywalls)
46+
}
6147

6248
fun navigateUp() {
6349
navigator.navigateUp()
6450
}
6551
}
66-
67-
@Stable
68-
sealed interface DetailUiState {
69-
70-
data object Loading : DetailUiState
71-
72-
data class Success(val offering: Offering) : DetailUiState
73-
74-
data class Error(val message: String?) : DetailUiState
75-
}

feature/home/src/main/kotlin/com/revenuecat/articles/paywall/feature/home/CatArticlesHome.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
4848
import androidx.compose.ui.tooling.preview.Preview
4949
import androidx.compose.ui.unit.dp
5050
import androidx.compose.ui.unit.sp
51-
import androidx.hilt.navigation.compose.hiltViewModel
51+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
5252
import androidx.lifecycle.compose.collectAsStateWithLifecycle
5353
import com.revenuecat.articles.paywall.compose.core.designsystem.R
5454
import com.revenuecat.articles.paywall.core.designsystem.component.CatArticlesAppBar

0 commit comments

Comments
 (0)