Skip to content

Commit 7ca663f

Browse files
authored
Merge pull request #4 from RevenueCat/feature/bookmark
Implement bookmark feature
2 parents ae81bdf + 7dd19aa commit 7ca663f

31 files changed

Lines changed: 1293 additions & 45 deletions

File tree

composeApp/build.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
22
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
3+
import java.util.Properties
34

45
plugins {
56
alias(libs.plugins.kotlin.multiplatform)
@@ -65,6 +66,7 @@ kotlin {
6566
implementation(projects.feature.paywalls)
6667
implementation(projects.feature.account)
6768
implementation(projects.feature.subscriptions)
69+
implementation(projects.feature.bookmarks)
6870

6971
// Compose
7072
implementation(compose.runtime)
@@ -107,6 +109,11 @@ kotlin {
107109
}
108110
}
109111

112+
val localProperties = Properties().apply {
113+
val file = rootProject.file("local.properties")
114+
if (file.exists()) file.inputStream().use { load(it) }
115+
}
116+
110117
android {
111118
namespace = "com.revenuecat.catpaywalls"
112119
compileSdk = libs.versions.android.compileSdk.get().toInt()
@@ -117,10 +124,18 @@ android {
117124
targetSdk = libs.versions.android.targetSdk.get().toInt()
118125
versionCode = 1
119126
versionName = "1.0"
127+
128+
// RevenueCat Test Store API key (loaded from local.properties)
129+
buildConfigField(
130+
"String",
131+
"REVENUECAT_TEST_API_KEY",
132+
"\"${localProperties.getProperty("revenuecat.test.api.key", "")}\"",
133+
)
120134
}
121135

122136
buildFeatures {
123137
compose = true
138+
buildConfig = true
124139
}
125140

126141
compileOptions {

composeApp/src/androidMain/kotlin/com/revenuecat/catpaywalls/CatArticlesApplication.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package com.revenuecat.catpaywalls
1717

1818
import android.app.Application
19+
import com.revenuecat.catpaywalls.core.data.di.installApplicationContext
1920
import com.revenuecat.catpaywalls.di.AppGraph
2021
import com.revenuecat.purchases.kmp.LogLevel
2122
import com.revenuecat.purchases.kmp.Purchases
@@ -29,12 +30,16 @@ class CatArticlesApplication : Application() {
2930
override fun onCreate() {
3031
super.onCreate()
3132

33+
installApplicationContext(this)
3234
appGraph = createGraph<AppGraph>()
3335

34-
// Initialize RevenueCat
36+
// Initialize RevenueCat. When BuildConfig.REVENUECAT_TEST_API_KEY is set in
37+
// local.properties (revenuecat.test.api.key=...), the Test Store sandbox key
38+
// takes precedence so the app exercises the Test Store purchase flow.
3539
Purchases.logLevel = LogLevel.DEBUG
40+
val apiKey = BuildConfig.REVENUECAT_TEST_API_KEY.takeIf { it.isNotBlank() } ?: REVENUECAT_API_KEY
3641
Purchases.configure(
37-
PurchasesConfiguration(apiKey = REVENUECAT_API_KEY) {
42+
PurchasesConfiguration(apiKey = apiKey) {
3843
appUserId = null // Anonymous user
3944
},
4045
)

composeApp/src/commonMain/kotlin/com/revenuecat/catpaywalls/di/AppGraph.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,20 @@ package com.revenuecat.catpaywalls.di
1717

1818
import androidx.compose.runtime.Stable
1919
import com.revenuecat.catpaywalls.core.data.ArticlesRepository
20+
import com.revenuecat.catpaywalls.core.data.BookmarksRepository
2021
import com.revenuecat.catpaywalls.core.data.PaywallsRepository
22+
import com.revenuecat.catpaywalls.core.data.ReadingTrackerRepository
2123
import com.revenuecat.catpaywalls.core.data.di.DataScope
2224
import com.revenuecat.catpaywalls.core.data.di.createArticlesRepository
25+
import com.revenuecat.catpaywalls.core.data.di.createBookmarksRepository
2326
import com.revenuecat.catpaywalls.core.data.di.createPaywallsRepository
27+
import com.revenuecat.catpaywalls.core.data.di.createReadingTrackerRepository
2428
import com.revenuecat.catpaywalls.core.network.CatArticlesService
2529
import com.revenuecat.catpaywalls.core.network.createHttpClient
2630
import com.revenuecat.catpaywalls.core.network.di.NetworkScope
2731
import com.revenuecat.catpaywalls.core.network.di.createCatArticlesService
2832
import com.revenuecat.catpaywalls.feature.article.CatArticlesDetailViewModel
33+
import com.revenuecat.catpaywalls.feature.bookmarks.BookmarksViewModel
2934
import com.revenuecat.catpaywalls.feature.home.CatArticlesViewModel
3035
import com.revenuecat.catpaywalls.feature.subscriptions.SubscriptionManagementViewModel
3136
import dev.zacsweers.metro.DependencyGraph
@@ -47,6 +52,7 @@ abstract class AppGraph {
4752
// ViewModels
4853
abstract val catArticlesViewModel: CatArticlesViewModel
4954
abstract val subscriptionManagementViewModel: SubscriptionManagementViewModel
55+
abstract val bookmarksViewModel: BookmarksViewModel
5056

5157
// Factory for ViewModels that need runtime parameters
5258
abstract val articleDetailViewModelFactory: CatArticlesDetailViewModel.Factory
@@ -68,4 +74,12 @@ abstract class AppGraph {
6874
@Provides
6975
@SingleIn(DataScope::class)
7076
fun providePaywallsRepository(): PaywallsRepository = createPaywallsRepository()
77+
78+
@Provides
79+
@SingleIn(DataScope::class)
80+
fun provideBookmarksRepository(): BookmarksRepository = createBookmarksRepository()
81+
82+
@Provides
83+
@SingleIn(DataScope::class)
84+
fun provideReadingTrackerRepository(): ReadingTrackerRepository = createReadingTrackerRepository()
7185
}

composeApp/src/commonMain/kotlin/com/revenuecat/catpaywalls/navigation/CatArticlesNavHost.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.revenuecat.catpaywalls.core.navigation.LocalComposeNavigator
2828
import com.revenuecat.catpaywalls.di.AppGraph
2929
import com.revenuecat.catpaywalls.feature.account.AccountScreen
3030
import com.revenuecat.catpaywalls.feature.article.CatArticlesDetail
31+
import com.revenuecat.catpaywalls.feature.bookmarks.BookmarksScreen
3132
import com.revenuecat.catpaywalls.feature.home.CatArticlesHome
3233
import com.revenuecat.catpaywalls.feature.paywalls.CatCustomPaywalls
3334
import com.revenuecat.catpaywalls.feature.subscriptions.SubscriptionManagementScreen
@@ -67,6 +68,10 @@ fun CatArticlesNavHost(appGraph: AppGraph) {
6768
composable<CatArticlesScreen.SubscriptionManagement> {
6869
SubscriptionManagementScreen(viewModel = appGraph.subscriptionManagementViewModel)
6970
}
71+
72+
composable<CatArticlesScreen.Bookmarks> {
73+
BookmarksScreen(viewModel = appGraph.bookmarksViewModel)
74+
}
7075
}
7176
}
7277
}

core/data/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ kotlin {
1616

1717
api(libs.purchases.kmp.core)
1818
api(libs.kotlinx.coroutines.core)
19+
20+
api(libs.androidx.datastore.preferences.core)
21+
implementation(libs.kotlinx.datetime)
1922
}
2023
}
2124
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.core.data.di
17+
18+
import android.content.Context
19+
import okio.Path
20+
import okio.Path.Companion.toOkioPath
21+
22+
private lateinit var applicationContext: Context
23+
24+
/** Must be called once from `Application.onCreate()` before the DI graph is created. */
25+
fun installApplicationContext(context: Context) {
26+
applicationContext = context.applicationContext
27+
}
28+
29+
internal actual fun dataStoreDirectory(): Path {
30+
check(::applicationContext.isInitialized) {
31+
"Application context not installed. Call installApplicationContext(this) in Application.onCreate()."
32+
}
33+
return applicationContext.filesDir.toOkioPath().resolve("datastore")
34+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.core.data
17+
18+
import kotlinx.coroutines.flow.Flow
19+
20+
interface BookmarksRepository {
21+
val bookmarkedArticleTitles: Flow<Set<String>>
22+
23+
suspend fun toggleBookmark(articleTitle: String)
24+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.core.data
17+
18+
import androidx.datastore.core.DataStore
19+
import androidx.datastore.preferences.core.Preferences
20+
import androidx.datastore.preferences.core.edit
21+
import androidx.datastore.preferences.core.stringSetPreferencesKey
22+
import kotlinx.coroutines.flow.Flow
23+
import kotlinx.coroutines.flow.map
24+
25+
internal class BookmarksRepositoryImpl(private val dataStore: DataStore<Preferences>) : BookmarksRepository {
26+
27+
override val bookmarkedArticleTitles: Flow<Set<String>> = dataStore.data.map { prefs ->
28+
prefs[BOOKMARKS_KEY] ?: emptySet()
29+
}
30+
31+
override suspend fun toggleBookmark(articleTitle: String) {
32+
dataStore.edit { prefs ->
33+
val current = prefs[BOOKMARKS_KEY] ?: emptySet()
34+
prefs[BOOKMARKS_KEY] =
35+
if (articleTitle in current) current - articleTitle else current + articleTitle
36+
}
37+
}
38+
39+
private companion object {
40+
val BOOKMARKS_KEY = stringSetPreferencesKey("bookmarked_articles")
41+
}
42+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.core.data
17+
18+
import kotlinx.coroutines.flow.Flow
19+
20+
interface ReadingTrackerRepository {
21+
val todayReadCount: Flow<Int>
22+
23+
suspend fun recordArticleRead(articleTitle: String)
24+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright (c) 2025 RevenueCat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.revenuecat.catpaywalls.core.data
17+
18+
import androidx.datastore.core.DataStore
19+
import androidx.datastore.preferences.core.Preferences
20+
import androidx.datastore.preferences.core.edit
21+
import androidx.datastore.preferences.core.stringPreferencesKey
22+
import androidx.datastore.preferences.core.stringSetPreferencesKey
23+
import kotlinx.coroutines.flow.Flow
24+
import kotlinx.coroutines.flow.map
25+
import kotlinx.datetime.TimeZone
26+
import kotlinx.datetime.toLocalDateTime
27+
import kotlin.time.Clock
28+
import kotlin.time.ExperimentalTime
29+
30+
@OptIn(ExperimentalTime::class)
31+
internal class ReadingTrackerRepositoryImpl(
32+
private val dataStore: DataStore<Preferences>,
33+
private val today: () -> String = {
34+
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
35+
},
36+
) : ReadingTrackerRepository {
37+
38+
override val todayReadCount: Flow<Int> = dataStore.data.map { prefs ->
39+
val today = today()
40+
val lastReset = prefs[LAST_RESET_DATE_KEY]
41+
if (lastReset != today) 0 else prefs[READ_ARTICLES_KEY]?.size ?: 0
42+
}
43+
44+
override suspend fun recordArticleRead(articleTitle: String) {
45+
dataStore.edit { prefs ->
46+
val today = today()
47+
val lastReset = prefs[LAST_RESET_DATE_KEY]
48+
if (lastReset != today) {
49+
prefs[LAST_RESET_DATE_KEY] = today
50+
prefs[READ_ARTICLES_KEY] = setOf(articleTitle)
51+
} else {
52+
val current = prefs[READ_ARTICLES_KEY] ?: emptySet()
53+
prefs[READ_ARTICLES_KEY] = current + articleTitle
54+
}
55+
}
56+
}
57+
58+
private companion object {
59+
val LAST_RESET_DATE_KEY = stringPreferencesKey("last_reset_date")
60+
val READ_ARTICLES_KEY = stringSetPreferencesKey("read_articles_today")
61+
}
62+
}

0 commit comments

Comments
 (0)