From d7cd01e57f05bc0efb381680c1cd91f418a65408 Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Tue, 23 Jun 2026 04:53:05 +0500 Subject: [PATCH 1/6] Setup paywall module --- feature/paywall/.gitignore | 1 + feature/paywall/build.gradle.kts | 71 +++++++++++++++++++ feature/paywall/consumer-rules.pro | 0 feature/paywall/proguard-rules.pro | 21 ++++++ .../paywall/ExampleInstrumentedTest.kt | 24 +++++++ feature/paywall/src/main/AndroidManifest.xml | 4 ++ .../rate/feature/paywall/ExampleUnitTest.kt | 17 +++++ settings.gradle.kts | 3 + 8 files changed, 141 insertions(+) create mode 100644 feature/paywall/.gitignore create mode 100644 feature/paywall/build.gradle.kts create mode 100644 feature/paywall/consumer-rules.pro create mode 100644 feature/paywall/proguard-rules.pro create mode 100644 feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt create mode 100644 feature/paywall/src/main/AndroidManifest.xml create mode 100644 feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt diff --git a/feature/paywall/.gitignore b/feature/paywall/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/paywall/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/paywall/build.gradle.kts b/feature/paywall/build.gradle.kts new file mode 100644 index 000000000..613f95431 --- /dev/null +++ b/feature/paywall/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") +} + +android { + namespace = "dev.arkbuilders.rate.feature.paywall" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + ksp { + arg("compose-destinations.moduleName", "paywall") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + kotlinOptions { + jvmTarget = "21" + } +} + +dependencies { + implementation(project(":core:presentation")) + + implementation(libs.androidx.core.ktx) + + implementation(libs.androidx.ui) + implementation(libs.navigation.compose) + implementation(libs.material3) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.constraintlayout.compose) + + implementation(libs.timber) + + implementation(libs.dagger) + ksp(libs.dagger.compiler) + + implementation(libs.orbit.compose) + implementation(libs.orbit.viewmodel) + + implementation(libs.arrow.core) + implementation(libs.arrow.fx.coroutines) + + implementation(libs.compose.destinations.core) + ksp(libs.compose.destinations.compiler) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/feature/paywall/consumer-rules.pro b/feature/paywall/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/feature/paywall/proguard-rules.pro b/feature/paywall/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/feature/paywall/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt b/feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..b58d0dfe5 --- /dev/null +++ b/feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package dev.arkbuilders.rate.feature.paywall + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("dev.arkbuilders.rate.feature.paywall.test", appContext.packageName) + } +} diff --git a/feature/paywall/src/main/AndroidManifest.xml b/feature/paywall/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/feature/paywall/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt b/feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt new file mode 100644 index 000000000..4550275d8 --- /dev/null +++ b/feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package dev.arkbuilders.rate.feature.paywall + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5090e0e26..6362ccb0f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,8 @@ import java.util.Properties +include(":feature:paywall") + + pluginManagement { repositories { gradlePluginPortal() From 3c95b53c961ae733c7b93d944a0ffc700aa9c7fc Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Tue, 23 Jun 2026 05:45:16 +0500 Subject: [PATCH 2/6] Simple paywall ui --- app/build.gradle.kts | 1 + .../rate/presentation/navigation/MainGraph.kt | 2 + .../rate/feature/paywall/PaywallScreen.kt | 744 ++++++++++++++++++ .../rate/feature/paywall/PaywallViewModel.kt | 32 + feature/settings/build.gradle.kts | 1 + .../settings/presentation/SettingsScreen.kt | 22 + .../presentation/SettingsViewModel.kt | 8 + settings.gradle.kts | 4 +- 8 files changed, 811 insertions(+), 3 deletions(-) create mode 100644 feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt create mode 100644 feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7bde0bf88..b009c222b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { implementation(project(":feature:portfolio")) implementation(project(":feature:search")) implementation(project(":feature:settings")) + implementation(project(":feature:paywall")) implementation(project(":fiaticons")) implementation(project(":cryptoicons")) diff --git a/app/src/main/java/dev/arkbuilders/rate/presentation/navigation/MainGraph.kt b/app/src/main/java/dev/arkbuilders/rate/presentation/navigation/MainGraph.kt index 14704f716..d03b0e274 100644 --- a/app/src/main/java/dev/arkbuilders/rate/presentation/navigation/MainGraph.kt +++ b/app/src/main/java/dev/arkbuilders/rate/presentation/navigation/MainGraph.kt @@ -6,6 +6,7 @@ import com.ramcosta.composedestinations.annotation.NavHostGraph import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickCalculationScreenDestination import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickScreenDestination import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingScreenDestination +import com.ramcosta.composedestinations.generated.paywall.destinations.PaywallScreenDestination import com.ramcosta.composedestinations.generated.portfolio.destinations.AddAssetScreenDestination import com.ramcosta.composedestinations.generated.portfolio.destinations.EditAssetScreenDestination import com.ramcosta.composedestinations.generated.portfolio.destinations.PortfolioScreenDestination @@ -30,5 +31,6 @@ annotation class MainGraph { @ExternalDestination @ExternalDestination @ExternalDestination + @ExternalDestination companion object Includes } diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt new file mode 100644 index 000000000..b0f6dbfab --- /dev/null +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt @@ -0,0 +1,744 @@ +package dev.arkbuilders.rate.feature.paywall + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.ExternalModuleGraph +import dev.arkbuilders.rate.core.presentation.CoreRDrawable +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Destination +@Composable +fun PaywallScreen() { + val viewModel: PaywallViewModel = viewModel() + + PaywallContent( + onCloseClick = viewModel::onCloseClick, + onRestoreClick = viewModel::onRestoreClick, + onYearlyClick = viewModel::onYearlyPlanClick, + onMonthlyClick = viewModel::onMonthlyPlanClick, + onPrimaryClick = viewModel::onPrimaryClick, + onContinueFreeClick = viewModel::onContinueFreeClick, + onTermsClick = viewModel::onTermsClick, + onPrivacyClick = viewModel::onPrivacyClick, + onRestorePurchasesClick = viewModel::onRestorePurchasesClick, + ) +} + +@Composable +private fun PaywallContent( + onCloseClick: () -> Unit, + onRestoreClick: () -> Unit, + onYearlyClick: () -> Unit, + onMonthlyClick: () -> Unit, + onPrimaryClick: () -> Unit, + onContinueFreeClick: () -> Unit, + onTermsClick: () -> Unit, + onPrivacyClick: () -> Unit, + onRestorePurchasesClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = + listOf( + Color.White, + ArkColor.BGSecondaryAlt, + ArkColor.BrandSecondary.copy(alpha = 0.08f), + ), + ), + ) + .statusBarsPadding() + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .widthIn(max = 420.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PaywallTopBar( + onCloseClick = onCloseClick, + onRestoreClick = onRestoreClick, + ) + Column( + modifier = Modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(16.dp)) + Illustration() + Spacer(Modifier.height(22.dp)) + Text( + text = "Unlock live updates", + color = ArkColor.TextPrimary, + fontSize = 28.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp), + text = "Get live exchange rates updated every 5-10 minutes and more with Premium.", + color = ArkColor.TextTertiary, + fontSize = 15.sp, + lineHeight = 22.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(28.dp)) + BenefitsCard() + Spacer(Modifier.height(26.dp)) + SectionDivider("CHOOSE YOUR PLAN") + Spacer(Modifier.height(16.dp)) + PlanCard( + title = "Yearly", + price = "\$19.99 / year", + description = "7 days free, then \$19.99 / year", + monthlyPrice = "\$1.67 / month", + badge = "Best value", + selected = true, + onClick = onYearlyClick, + ) + Spacer(Modifier.height(12.dp)) + PlanCard( + title = "Monthly", + price = "\$2.99 / month", + description = "7 days free, then \$2.99 / month", + monthlyPrice = null, + badge = null, + selected = false, + onClick = onMonthlyClick, + ) + Spacer(Modifier.height(28.dp)) + PrimaryButton(onClick = onPrimaryClick) + Spacer(Modifier.height(24.dp)) + TrustRow() + Spacer(Modifier.height(28.dp)) + SecondaryButton(onClick = onContinueFreeClick) + Spacer(Modifier.height(30.dp)) + Footer( + onTermsClick = onTermsClick, + onPrivacyClick = onPrivacyClick, + onRestorePurchasesClick = onRestorePurchasesClick, + ) + Spacer(Modifier.height(24.dp)) + } + } + } +} + +@Composable +private fun PaywallTopBar( + modifier: Modifier = Modifier, + onCloseClick: () -> Unit, + onRestoreClick: () -> Unit, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .clickable( + role = Role.Button, + onClick = onCloseClick, + ) + .padding(13.dp), + painter = painterResource(CoreRDrawable.ic_close), + contentDescription = null, + tint = ArkColor.TextPrimary, + ) + Spacer(Modifier.weight(1f)) + Text( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable( + role = Role.Button, + onClick = onRestoreClick, + ) + .padding(horizontal = 8.dp, vertical = 6.dp), + text = "Restore", + color = ArkColor.TextSecondary, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun Illustration() { + Box( + modifier = + Modifier + .size(width = 144.dp, height = 112.dp) + .clip(RoundedCornerShape(12.dp)) + .background(ArkColor.BGTertiary), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Icon / Illustration", + color = ArkColor.TextQuarterary, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun BenefitsCard() { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .border(1.dp, ArkColor.BorderSecondary, RoundedCornerShape(12.dp)), + ) { + BenefitRow( + iconType = BenefitIconType.Lightning, + title = "Live rate updates", + description = "Rates refresh every 5-10 minutes", + ) + BenefitDivider() + BenefitRow( + iconType = BenefitIconType.Coins, + title = "500+ crypto tokens", + description = "Access 500+ cryptocurrencies", + ) + BenefitDivider() + BenefitRow( + iconType = BenefitIconType.Pin, + title = "Unlimited pinned calculations", + description = "Pin and organize as many as you want", + ) + } +} + +@Composable +private fun BenefitRow( + iconType: BenefitIconType, + title: String, + description: String, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BenefitIcon(iconType) + Column( + modifier = + Modifier + .padding(start = 14.dp) + .weight(1f), + ) { + Text( + text = title, + color = ArkColor.TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + modifier = Modifier.padding(top = 3.dp), + text = description, + color = ArkColor.TextTertiary, + fontSize = 12.sp, + ) + } + CheckMark() + } +} + +@Composable +private fun BenefitIcon(iconType: BenefitIconType) { + Box( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .background(ArkColor.BGTertiary), + contentAlignment = Alignment.Center, + ) { + when (iconType) { + BenefitIconType.Pin -> + Icon( + modifier = Modifier.size(22.dp), + painter = painterResource(CoreRDrawable.ic_pin), + contentDescription = null, + tint = ArkColor.TextPrimary, + ) + + else -> + Canvas(modifier = Modifier.size(22.dp)) { + val stroke = + Stroke( + width = 2.1.dp.toPx(), + cap = StrokeCap.Round, + ) + when (iconType) { + BenefitIconType.Lightning -> { + val path = + Path().apply { + moveTo(size.width * 0.58f, 0f) + lineTo(size.width * 0.24f, size.height * 0.54f) + lineTo(size.width * 0.51f, size.height * 0.54f) + lineTo(size.width * 0.38f, size.height) + lineTo(size.width * 0.76f, size.height * 0.42f) + lineTo(size.width * 0.49f, size.height * 0.42f) + close() + } + drawPath(path, ArkColor.TextPrimary) + } + + BenefitIconType.Coins -> { + drawOval( + color = ArkColor.TextPrimary, + style = stroke, + ) + drawLine( + color = ArkColor.TextPrimary, + start = Offset(0f, size.height * 0.36f), + end = Offset(size.width, size.height * 0.36f), + strokeWidth = 2.1.dp.toPx(), + cap = StrokeCap.Round, + ) + drawLine( + color = ArkColor.TextPrimary, + start = Offset(0f, size.height * 0.64f), + end = Offset(size.width, size.height * 0.64f), + strokeWidth = 2.1.dp.toPx(), + cap = StrokeCap.Round, + ) + } + + BenefitIconType.Pin -> Unit + } + } + } + } +} + +@Composable +private fun BenefitDivider() { + Spacer( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(ArkColor.BorderSecondary), + ) +} + +@Composable +private fun CheckMark() { + Canvas(modifier = Modifier.size(22.dp)) { + drawLine( + color = ArkColor.Primary, + start = Offset(size.width * 0.22f, size.height * 0.52f), + end = Offset(size.width * 0.42f, size.height * 0.72f), + strokeWidth = 2.2.dp.toPx(), + cap = StrokeCap.Round, + ) + drawLine( + color = ArkColor.Primary, + start = Offset(size.width * 0.42f, size.height * 0.72f), + end = Offset(size.width * 0.8f, size.height * 0.28f), + strokeWidth = 2.2.dp.toPx(), + cap = StrokeCap.Round, + ) + } +} + +@Composable +private fun SectionDivider(title: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer( + modifier = + Modifier + .height(1.dp) + .weight(1f) + .background(ArkColor.BorderSecondary), + ) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = title, + color = ArkColor.TextQuarterary, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + ) + Spacer( + modifier = + Modifier + .height(1.dp) + .weight(1f) + .background(ArkColor.BorderSecondary), + ) + } +} + +@Composable +private fun PlanCard( + title: String, + price: String, + description: String, + monthlyPrice: String?, + badge: String?, + selected: Boolean, + onClick: () -> Unit, +) { + val borderColor = if (selected) ArkColor.Primary else ArkColor.BorderSecondary + val background = + if (selected) { + Brush.linearGradient( + colors = + listOf( + Color.White, + ArkColor.Primary.copy(alpha = 0.06f), + ), + ) + } else { + Brush.linearGradient(listOf(Color.White, Color.White)) + } + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(background) + .border(1.5.dp, borderColor, RoundedCornerShape(12.dp)) + .clickable( + role = Role.RadioButton, + onClick = onClick, + ) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioIndicator(selected = selected) + Column( + modifier = + Modifier + .padding(start = 14.dp) + .weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + color = ArkColor.TextPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + badge?.let { + Text( + modifier = + Modifier + .padding(start = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(ArkColor.Primary.copy(alpha = 0.1f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + text = it, + color = ArkColor.TextBrandSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } + } + Text( + modifier = Modifier.padding(top = 6.dp, end = 8.dp), + text = description, + color = ArkColor.TextTertiary, + fontSize = 12.sp, + maxLines = 1, + ) + } + Column( + modifier = Modifier.padding(start = 8.dp), + horizontalAlignment = Alignment.End, + ) { + Text( + text = price, + color = ArkColor.TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, + maxLines = 1, + ) + monthlyPrice?.let { + Text( + modifier = Modifier.padding(top = 6.dp), + text = it, + color = ArkColor.TextTertiary, + fontSize = 12.sp, + textAlign = TextAlign.End, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun RadioIndicator(selected: Boolean) { + Box( + modifier = + Modifier + .size(20.dp) + .clip(CircleShape) + .border( + width = 1.5.dp, + color = if (selected) ArkColor.Primary else ArkColor.Border, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Box( + modifier = + Modifier + .size(10.dp) + .clip(CircleShape) + .background(ArkColor.Primary), + ) + } + } +} + +@Composable +private fun PrimaryButton(onClick: () -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .height(60.dp) + .clip(RoundedCornerShape(10.dp)) + .background( + Brush.horizontalGradient( + colors = + listOf( + ArkColor.Primary, + ArkColor.BrandUtility, + ), + ), + ) + .clickable( + role = Role.Button, + onClick = onClick, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Try Premium Free", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = "7 days free, cancel anytime", + color = Color.White.copy(alpha = 0.82f), + fontSize = 12.sp, + ) + } +} + +@Composable +private fun SecondaryButton(onClick: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(9.dp)) + .background(Color.White) + .border(1.dp, ArkColor.Border, RoundedCornerShape(9.dp)) + .clickable( + role = Role.Button, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Continue using free version", + color = ArkColor.TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun TrustRow() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TrustItem( + modifier = Modifier.weight(1f), + icon = CoreRDrawable.ic_snackbar_done, + title = "Secure payment", + subtitle = "via App Store", + ) + TrustItem( + modifier = Modifier.weight(1f), + icon = CoreRDrawable.ic_refresh, + title = "Cancel anytime", + subtitle = "in Settings", + ) + TrustItem( + modifier = Modifier.weight(1f), + icon = CoreRDrawable.ic_info, + title = "Your data is safe", + subtitle = "and private", + ) + } +} + +@Composable +private fun TrustItem( + modifier: Modifier = Modifier, + icon: Int, + title: String, + subtitle: String, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(icon), + contentDescription = null, + tint = ArkColor.TextQuarterary, + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = title, + color = ArkColor.TextSecondary, + fontSize = 10.sp, + lineHeight = 13.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium, + ) + Text( + text = subtitle, + color = ArkColor.TextQuarterary, + fontSize = 10.sp, + lineHeight = 13.sp, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun Footer( + onTermsClick: () -> Unit, + onPrivacyClick: () -> Unit, + onRestorePurchasesClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + FooterLink("Terms of Use", onTermsClick) + FooterDot() + FooterLink("Privacy Policy", onPrivacyClick) + FooterDot() + FooterLink("Restore Purchases", onRestorePurchasesClick) + } +} + +@Composable +private fun FooterLink( + text: String, + onClick: () -> Unit, +) { + Text( + modifier = + Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable( + role = Role.Button, + onClick = onClick, + ) + .padding(horizontal = 6.dp, vertical = 4.dp), + text = text, + color = ArkColor.TextQuarterary, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) +} + +@Composable +private fun FooterDot() { + Box( + modifier = + Modifier + .size(3.dp) + .clip(CircleShape) + .background(ArkColor.TextQuarterary.copy(alpha = 0.8f)), + ) +} + +private enum class BenefitIconType { + Lightning, + Coins, + Pin, +} diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt new file mode 100644 index 000000000..b5d7dd285 --- /dev/null +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt @@ -0,0 +1,32 @@ +package dev.arkbuilders.rate.feature.paywall + +import androidx.lifecycle.ViewModel + +class PaywallViewModel : ViewModel() { + fun onCloseClick() { + } + + fun onRestoreClick() { + } + + fun onYearlyPlanClick() { + } + + fun onMonthlyPlanClick() { + } + + fun onPrimaryClick() { + } + + fun onContinueFreeClick() { + } + + fun onTermsClick() { + } + + fun onPrivacyClick() { + } + + fun onRestorePurchasesClick() { + } +} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index ac98800d2..e07043bba 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(project(":core:domain")) implementation(project(":core:presentation")) implementation(project(":core:db")) + implementation(project(":feature:paywall")) implementation(libs.androidx.core.ktx) diff --git a/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt index 7bb883106..499fb8c9e 100644 --- a/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.ExternalModuleGraph +import com.ramcosta.composedestinations.generated.paywall.destinations.PaywallScreenDestination import com.ramcosta.composedestinations.generated.settings.destinations.AboutScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import dev.arkbuilders.rate.core.presentation.CoreRDrawable @@ -73,6 +74,9 @@ fun SettingsScreen(navigator: DestinationsNavigator) { SettingsScreenEffect.NavigateToAbout -> navigator.navigate(AboutScreenDestination) + SettingsScreenEffect.NavigateToPaywall -> + navigator.navigate(PaywallScreenDestination) + SettingsScreenEffect.NavigateBack -> navigator.popBackStack() } } @@ -87,6 +91,7 @@ fun SettingsScreen(navigator: DestinationsNavigator) { onToggleLanguagePopup = viewModel::onToggleLanguagePopup, onChangeLanguage = viewModel::onChangeLanguage, onAboutClick = viewModel::onAboutClick, + onPaywallClick = viewModel::onPaywallClick, ) } } @@ -101,6 +106,7 @@ private fun Content( onToggleLanguagePopup: (Boolean) -> Unit, onChangeLanguage: (AppLanguage) -> Unit, onAboutClick: () -> Unit, + onPaywallClick: () -> Unit, ) { val ctx = LocalContext.current Column( @@ -214,6 +220,22 @@ private fun Content( } } } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onPaywallClick() } + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Premium", + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + color = ArkColor.TextTertiary, + ) + } + AppHorDiv16(modifier = Modifier) Row( modifier = Modifier diff --git a/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsViewModel.kt b/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsViewModel.kt index b2de2aff0..389cdc7bf 100644 --- a/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsViewModel.kt @@ -38,6 +38,8 @@ data class SettingsScreenState( sealed class SettingsScreenEffect() { data object NavigateToAbout : SettingsScreenEffect() + data object NavigateToPaywall : SettingsScreenEffect() + data object NavigateBack : SettingsScreenEffect() } @@ -134,6 +136,12 @@ class SettingsViewModel( postSideEffect(SettingsScreenEffect.NavigateToAbout) } + fun onPaywallClick() = + intent { + analyticsManager.logEvent("settings_paywall_clicked") + postSideEffect(SettingsScreenEffect.NavigateToPaywall) + } + fun onBackClick() = intent { analyticsManager.logEvent("settings_back_clicked") diff --git a/settings.gradle.kts b/settings.gradle.kts index 6362ccb0f..aef4ee7a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,5 @@ import java.util.Properties -include(":feature:paywall") - - pluginManagement { repositories { gradlePluginPortal() @@ -57,6 +54,7 @@ include(":feature:quickwidget") include(":feature:search") include(":feature:settings") include(":feature:onboarding") +include(":feature:paywall") fun getLocalProps(): Properties { val props = Properties() From 745391a5df001dea6de18dc568ba72ad45775c5d Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Fri, 26 Jun 2026 05:53:55 +0500 Subject: [PATCH 3/6] Add paywall viewmodel --- feature/paywall/build.gradle.kts | 3 + .../rate/feature/paywall/PaywallScreen.kt | 35 ++++++++-- .../rate/feature/paywall/PaywallViewModel.kt | 64 ++++++++++++++++--- .../feature/paywall/di/PaywallComponent.kt | 11 ++++ .../paywall/di/PaywallComponentHolder.kt | 18 ++++++ .../rate/feature/paywall/di/PaywallScope.kt | 7 ++ 6 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt create mode 100644 feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponentHolder.kt create mode 100644 feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallScope.kt diff --git a/feature/paywall/build.gradle.kts b/feature/paywall/build.gradle.kts index 613f95431..5d4fd346e 100644 --- a/feature/paywall/build.gradle.kts +++ b/feature/paywall/build.gradle.kts @@ -39,6 +39,9 @@ android { } dependencies { + implementation(project(":core:di")) + implementation(project(":core:db")) + implementation(project(":core:domain")) implementation(project(":core:presentation")) implementation(libs.androidx.core.ktx) diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt index b0f6dbfab..e98de3eac 100644 --- a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt @@ -1,5 +1,6 @@ package dev.arkbuilders.rate.feature.paywall +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -24,6 +25,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,6 +36,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight @@ -42,15 +46,37 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.ExternalModuleGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import dev.arkbuilders.rate.core.presentation.CoreRDrawable import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import dev.arkbuilders.rate.feature.paywall.di.PaywallComponentHolder +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect @Destination @Composable -fun PaywallScreen() { - val viewModel: PaywallViewModel = viewModel() +fun PaywallScreen(navigator: DestinationsNavigator) { + val ctx = LocalContext.current + val component = + remember { + PaywallComponentHolder.provide(ctx) + } + val viewModel: PaywallViewModel = + viewModel(factory = component.paywallVMFactory()) + val state by viewModel.collectAsState() + + BackHandler { + viewModel.onBackClick() + } + + viewModel.collectSideEffect { effect -> + when (effect) { + PaywallScreenEffect.NavigateBack -> navigator.popBackStack() + } + } PaywallContent( + state = state, onCloseClick = viewModel::onCloseClick, onRestoreClick = viewModel::onRestoreClick, onYearlyClick = viewModel::onYearlyPlanClick, @@ -65,6 +91,7 @@ fun PaywallScreen() { @Composable private fun PaywallContent( + state: PaywallScreenState, onCloseClick: () -> Unit, onRestoreClick: () -> Unit, onYearlyClick: () -> Unit, @@ -138,7 +165,7 @@ private fun PaywallContent( description = "7 days free, then \$19.99 / year", monthlyPrice = "\$1.67 / month", badge = "Best value", - selected = true, + selected = state.selectedPlan == PaywallPlan.Yearly, onClick = onYearlyClick, ) Spacer(Modifier.height(12.dp)) @@ -148,7 +175,7 @@ private fun PaywallContent( description = "7 days free, then \$2.99 / month", monthlyPrice = null, badge = null, - selected = false, + selected = state.selectedPlan == PaywallPlan.Monthly, onClick = onMonthlyClick, ) Spacer(Modifier.height(28.dp)) diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt index b5d7dd285..4feac30c7 100644 --- a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt @@ -1,25 +1,59 @@ package dev.arkbuilders.rate.feature.paywall import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dev.arkbuilders.rate.feature.paywall.di.PaywallScope +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject -class PaywallViewModel : ViewModel() { - fun onCloseClick() { - } +enum class PaywallPlan { + Yearly, + Monthly, +} + +data class PaywallScreenState( + val selectedPlan: PaywallPlan = PaywallPlan.Yearly, +) + +sealed class PaywallScreenEffect { + data object NavigateBack : PaywallScreenEffect() +} + +class PaywallViewModel : ViewModel(), ContainerHost { + override val container: Container = + container(PaywallScreenState()) + + fun onCloseClick() = + intent { + postSideEffect(PaywallScreenEffect.NavigateBack) + } fun onRestoreClick() { } - fun onYearlyPlanClick() { - } + fun onYearlyPlanClick() = + intent { + reduce { + state.copy(selectedPlan = PaywallPlan.Yearly) + } + } - fun onMonthlyPlanClick() { - } + fun onMonthlyPlanClick() = + intent { + reduce { + state.copy(selectedPlan = PaywallPlan.Monthly) + } + } fun onPrimaryClick() { } - fun onContinueFreeClick() { - } + fun onContinueFreeClick() = + intent { + postSideEffect(PaywallScreenEffect.NavigateBack) + } fun onTermsClick() { } @@ -29,4 +63,16 @@ class PaywallViewModel : ViewModel() { fun onRestorePurchasesClick() { } + + fun onBackClick() = + intent { + postSideEffect(PaywallScreenEffect.NavigateBack) + } +} + +@PaywallScope +class PaywallViewModelFactory @Inject constructor() : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return PaywallViewModel() as T + } } diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt new file mode 100644 index 000000000..e2708bb07 --- /dev/null +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt @@ -0,0 +1,11 @@ +package dev.arkbuilders.rate.feature.paywall.di + +import dagger.Component +import dev.arkbuilders.rate.core.di.CoreComponent +import dev.arkbuilders.rate.feature.paywall.PaywallViewModelFactory + +@PaywallScope +@Component(dependencies = [CoreComponent::class], modules = []) +interface PaywallComponent { + fun paywallVMFactory(): PaywallViewModelFactory +} diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponentHolder.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponentHolder.kt new file mode 100644 index 000000000..1c7119c7d --- /dev/null +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponentHolder.kt @@ -0,0 +1,18 @@ +package dev.arkbuilders.rate.feature.paywall.di + +import android.content.Context +import dev.arkbuilders.rate.core.di.CoreComponentProvider + +object PaywallComponentHolder { + private var component: PaywallComponent? = null + + fun provide(ctx: Context): PaywallComponent { + component ?: let { + val app = ctx.applicationContext + val coreComponent = (app as CoreComponentProvider).provideCoreComponent() + component = + DaggerPaywallComponent.builder().coreComponent(coreComponent).build() + } + return component!! + } +} diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallScope.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallScope.kt new file mode 100644 index 000000000..53951dfcb --- /dev/null +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallScope.kt @@ -0,0 +1,7 @@ +package dev.arkbuilders.rate.feature.paywall.di + +import javax.inject.Scope + +@Scope +@Retention(AnnotationRetention.SOURCE) +annotation class PaywallScope From a12c55de3809d3412a531a61a6440ea48cd813e1 Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Sat, 27 Jun 2026 18:33:01 +0500 Subject: [PATCH 4/6] Add premium manager --- app/build.gradle.kts | 28 ++++ .../dev/arkbuilders/rate/presentation/App.kt | 6 + core/data/build.gradle.kts | 1 + .../data/repo/PremiumManagerRevenueCatImpl.kt | 132 ++++++++++++++++++ .../arkbuilders/rate/core/di/CoreComponent.kt | 3 + .../rate/core/di/modules/RepoModule.kt | 13 ++ .../rate/core/domain/BuildConfigFields.kt | 1 + .../rate/core/domain/repo/PremiumManager.kt | 22 +++ .../rate/feature/paywall/PaywallScreen.kt | 11 ++ .../rate/feature/paywall/PaywallViewModel.kt | 55 ++++++-- .../feature/paywall/di/PaywallComponent.kt | 3 + gradle/libs.versions.toml | 2 + 12 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/PremiumManagerRevenueCatImpl.kt create mode 100644 core/domain/src/main/java/dev/arkbuilders/rate/core/domain/repo/PremiumManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b009c222b..9cd7c75aa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -9,6 +11,8 @@ plugins { alias(libs.plugins.compose.compiler) } +val localProps = getLocalProperties() + android { namespace = "dev.arkbuilders.rate" compileSdk = libs.versions.compileSdk.get().toInt() @@ -25,6 +29,15 @@ android { vectorDrawables { useSupportLibrary = true } + buildConfigField( + "String", + "REVENUECAT_API_KEY", + toBuildConfigString( + localProps.getProperty("revenuecat.apiKey") + ?: System.getenv("REVENUECAT_API_KEY") + ?: "", + ), + ) ksp { arg("room.schemaLocation", "$projectDir/schemas") @@ -201,3 +214,18 @@ fun collectCurrencyIcons(moduleDir: File): List { } } } + +fun getLocalProperties(): Properties { + val props = Properties() + val localPropsFile = File(rootDir, "local.properties") + + if (localPropsFile.exists()) { + localPropsFile.inputStream().use { stream -> + props.load(stream) + } + } + + return props +} + +fun toBuildConfigString(value: String): String = "\"$value\"" diff --git a/app/src/main/java/dev/arkbuilders/rate/presentation/App.kt b/app/src/main/java/dev/arkbuilders/rate/presentation/App.kt index 1c00eaa5f..7aba1d8b8 100644 --- a/app/src/main/java/dev/arkbuilders/rate/presentation/App.kt +++ b/app/src/main/java/dev/arkbuilders/rate/presentation/App.kt @@ -32,6 +32,7 @@ class App : Application(), Configuration.Provider, CoreComponentProvider { Timber.plant(Timber.DebugTree()) coreComponent = DaggerCoreComponent.factory().create(this, applicationContext) initBuildConfigFields() + initPremiumManager() instance = this initCrashlytics() @@ -48,11 +49,16 @@ class App : Application(), Configuration.Provider, CoreComponentProvider { versionCode = BuildConfig.VERSION_CODE, versionName = BuildConfig.VERSION_NAME, isGooglePlayBuild = BuildConfig.GOOGLE_PLAY_BUILD, + revenueCatApiKey = BuildConfig.REVENUECAT_API_KEY, availableIconCodes = BuildConfig.ICON_CODES.toSet(), ), ) } + private fun initPremiumManager() { + coreComponent.premiumManager() + } + private fun initCrashlytics() = CoroutineScope(Dispatchers.IO).launch { // Google Play will collect crashes in any case, so we will also send them to Firebase diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index c81f051ec..f9644bfad 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(libs.play.review) implementation(libs.play.review.ktx) + implementation(libs.revenuecat.purchases) implementation(libs.arrow.core) implementation(libs.arrow.fx.coroutines) diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/PremiumManagerRevenueCatImpl.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/PremiumManagerRevenueCatImpl.kt new file mode 100644 index 000000000..200514b0d --- /dev/null +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/PremiumManagerRevenueCatImpl.kt @@ -0,0 +1,132 @@ +package dev.arkbuilders.rate.core.data.repo + +import android.app.Activity +import android.content.Context +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.LogLevel +import com.revenuecat.purchases.ProductType +import com.revenuecat.purchases.PurchaseParams +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesConfiguration +import com.revenuecat.purchases.awaitCustomerInfo +import com.revenuecat.purchases.awaitGetProducts +import com.revenuecat.purchases.awaitPurchase +import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener +import dev.arkbuilders.rate.core.domain.BuildConfigFieldsProvider +import dev.arkbuilders.rate.core.domain.repo.PremiumManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber + +class PremiumManagerRevenueCatImpl( + private val context: Context, + private val buildConfigFieldsProvider: BuildConfigFieldsProvider, +) : PremiumManager { + private val _premiumState = MutableStateFlow(false) + + override val premiumState: StateFlow = _premiumState.asStateFlow() + + init { + configureRevenueCatIfNeeded() + setCustomerInfoListener() + } + + override suspend fun refresh() { + if (Purchases.isConfigured.not()) { + Timber.d("RevenueCat refresh skipped: SDK is not configured") + updatePremium(isPremium = false) + return + } + + runCatching { + Purchases.sharedInstance.awaitCustomerInfo() + }.onSuccess { customerInfo -> + updateCustomerInfo(customerInfo) + }.onFailure { error -> + Timber.d("RevenueCat customer info refresh failed: ${error.message}") + } + } + + override suspend fun purchase( + activity: Activity, + productId: String, + ): Boolean { + if (Purchases.isConfigured.not()) { + Timber.d("RevenueCat purchase skipped: SDK is not configured") + return false + } + + val product = + runCatching { + Purchases.sharedInstance + .awaitGetProducts( + productIds = listOf(productId), + type = ProductType.SUBS, + ).firstOrNull() + }.onFailure { error -> + Timber.d("RevenueCat product fetch failed: ${error.message}") + }.getOrNull() + ?: return false + + return runCatching { + Purchases.sharedInstance.awaitPurchase( + PurchaseParams.Builder(activity, product).build(), + ) + }.onSuccess { result -> + updateCustomerInfo(result.customerInfo) + }.onFailure { error -> + Timber.d("RevenueCat purchase failed: ${error.message}") + }.isSuccess + } + + private fun configureRevenueCatIfNeeded() { + if (Purchases.isConfigured) + return + + val buildConfigFields = buildConfigFieldsProvider.provide() + val apiKey = buildConfigFields.revenueCatApiKey + if (apiKey.isBlank()) { + Timber.d("RevenueCat configure skipped: API key is empty") + return + } + + Purchases.logLevel = + if (buildConfigFields.buildType == BUILD_TYPE_DEBUG) + LogLevel.DEBUG + else + LogLevel.INFO + + Purchases.configure( + PurchasesConfiguration.Builder(context, apiKey).build(), + ) + } + + private fun setCustomerInfoListener() { + if (Purchases.isConfigured.not()) + return + + Purchases.sharedInstance.updatedCustomerInfoListener = + UpdatedCustomerInfoListener { customerInfo -> + updateCustomerInfo(customerInfo) + } + } + + private fun updateCustomerInfo(customerInfo: CustomerInfo) { + updatePremium( + isPremium = + customerInfo + .entitlements[PREMIUM_ENTITLEMENT_ID] + ?.isActive == true, + ) + } + + private fun updatePremium(isPremium: Boolean) { + _premiumState.value = isPremium + } + + private companion object { + const val PREMIUM_ENTITLEMENT_ID = "premium" + const val BUILD_TYPE_DEBUG = "debug" + } +} diff --git a/core/di/src/main/java/dev/arkbuilders/rate/core/di/CoreComponent.kt b/core/di/src/main/java/dev/arkbuilders/rate/core/di/CoreComponent.kt index e382bd9ba..87aa2227e 100644 --- a/core/di/src/main/java/dev/arkbuilders/rate/core/di/CoreComponent.kt +++ b/core/di/src/main/java/dev/arkbuilders/rate/core/di/CoreComponent.kt @@ -15,6 +15,7 @@ import dev.arkbuilders.rate.core.domain.repo.GroupRepo import dev.arkbuilders.rate.core.domain.repo.InAppReviewManager import dev.arkbuilders.rate.core.domain.repo.NetworkStatus import dev.arkbuilders.rate.core.domain.repo.Prefs +import dev.arkbuilders.rate.core.domain.repo.PremiumManager import dev.arkbuilders.rate.core.domain.repo.TimestampRepo import dev.arkbuilders.rate.core.domain.usecase.CalcFrequentCurrUseCase import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase @@ -72,6 +73,8 @@ interface CoreComponent { fun inAppReviewManager(): InAppReviewManager + fun premiumManager(): PremiumManager + @Component.Factory interface Factory { fun create( diff --git a/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt b/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt index 8814fd382..a4d3c4155 100644 --- a/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt +++ b/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt @@ -10,6 +10,7 @@ import dev.arkbuilders.rate.core.data.repo.BuildConfigFieldsProviderImpl import dev.arkbuilders.rate.core.data.repo.CodeUseStatRepoImpl import dev.arkbuilders.rate.core.data.repo.GooglePlayInAppReviewManagerImpl import dev.arkbuilders.rate.core.data.repo.GroupRepoImpl +import dev.arkbuilders.rate.core.data.repo.PremiumManagerRevenueCatImpl import dev.arkbuilders.rate.core.data.repo.TimestampRepoImpl import dev.arkbuilders.rate.core.data.repo.currency.CryptoCurrencyDataSource import dev.arkbuilders.rate.core.data.repo.currency.CurrencyInfoDataSource @@ -29,6 +30,7 @@ import dev.arkbuilders.rate.core.domain.repo.GroupRepo import dev.arkbuilders.rate.core.domain.repo.InAppReviewManager import dev.arkbuilders.rate.core.domain.repo.NetworkStatus import dev.arkbuilders.rate.core.domain.repo.Prefs +import dev.arkbuilders.rate.core.domain.repo.PremiumManager import dev.arkbuilders.rate.core.domain.repo.TimestampRepo import dev.arkbuilders.rate.core.domain.usecase.DefaultGroupNameProvider import dev.arkbuilders.rate.core.presentation.utils.DefaultGroupNameProviderImpl @@ -88,6 +90,17 @@ class RepoModule { @Provides fun buildConfigFieldsProvider(): BuildConfigFieldsProvider = BuildConfigFieldsProviderImpl() + @Singleton + @Provides + fun premiumManager( + context: Context, + buildConfigFieldsProvider: BuildConfigFieldsProvider, + ): PremiumManager = + PremiumManagerRevenueCatImpl( + context, + buildConfigFieldsProvider, + ) + @Singleton @Provides fun defaultGroupNameProvider(context: Context): DefaultGroupNameProvider = diff --git a/core/domain/src/main/java/dev/arkbuilders/rate/core/domain/BuildConfigFields.kt b/core/domain/src/main/java/dev/arkbuilders/rate/core/domain/BuildConfigFields.kt index 116bf634b..5c7809f67 100644 --- a/core/domain/src/main/java/dev/arkbuilders/rate/core/domain/BuildConfigFields.kt +++ b/core/domain/src/main/java/dev/arkbuilders/rate/core/domain/BuildConfigFields.kt @@ -7,6 +7,7 @@ data class BuildConfigFields( val versionCode: Int, val versionName: String, val isGooglePlayBuild: Boolean, + val revenueCatApiKey: String, val availableIconCodes: Set, ) diff --git a/core/domain/src/main/java/dev/arkbuilders/rate/core/domain/repo/PremiumManager.kt b/core/domain/src/main/java/dev/arkbuilders/rate/core/domain/repo/PremiumManager.kt new file mode 100644 index 000000000..8f1d05085 --- /dev/null +++ b/core/domain/src/main/java/dev/arkbuilders/rate/core/domain/repo/PremiumManager.kt @@ -0,0 +1,22 @@ +package dev.arkbuilders.rate.core.domain.repo + +import android.app.Activity +import kotlinx.coroutines.flow.StateFlow + +interface PremiumManager { + val premiumState: StateFlow + + suspend fun refresh() + + suspend fun purchase( + activity: Activity, + productId: String, + ): Boolean + + fun isPremium(): Boolean = premiumState.value + + companion object { + const val TEST_YEARLY_PRODUCT_ID = "test_product_yearly" + const val TEST_MONTHLY_PRODUCT_ID = "test_product_monthly" + } +} diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt index e98de3eac..3007b8f89 100644 --- a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt @@ -49,6 +49,7 @@ import com.ramcosta.composedestinations.annotation.ExternalModuleGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator import dev.arkbuilders.rate.core.presentation.CoreRDrawable import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import dev.arkbuilders.rate.core.presentation.utils.findActivity import dev.arkbuilders.rate.feature.paywall.di.PaywallComponentHolder import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -57,12 +58,14 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun PaywallScreen(navigator: DestinationsNavigator) { val ctx = LocalContext.current + val activity = ctx.findActivity() val component = remember { PaywallComponentHolder.provide(ctx) } val viewModel: PaywallViewModel = viewModel(factory = component.paywallVMFactory()) + val premiumManager = component.premiumManager() val state by viewModel.collectAsState() BackHandler { @@ -72,6 +75,14 @@ fun PaywallScreen(navigator: DestinationsNavigator) { viewModel.collectSideEffect { effect -> when (effect) { PaywallScreenEffect.NavigateBack -> navigator.popBackStack() + + is PaywallScreenEffect.Purchase -> + activity?.let { + premiumManager.purchase( + activity = it, + productId = effect.productId, + ) + } } } diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt index 4feac30c7..981da691a 100644 --- a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallViewModel.kt @@ -2,7 +2,11 @@ package dev.arkbuilders.rate.feature.paywall import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dev.arkbuilders.rate.core.domain.repo.PremiumManager import dev.arkbuilders.rate.feature.paywall.di.PaywallScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -15,23 +19,46 @@ enum class PaywallPlan { data class PaywallScreenState( val selectedPlan: PaywallPlan = PaywallPlan.Yearly, + val isPremium: Boolean = false, ) sealed class PaywallScreenEffect { data object NavigateBack : PaywallScreenEffect() + + data class Purchase( + val productId: String, + ) : PaywallScreenEffect() } -class PaywallViewModel : ViewModel(), ContainerHost { +class PaywallViewModel( + private val premiumManager: PremiumManager, +) : ViewModel(), ContainerHost { override val container: Container = - container(PaywallScreenState()) + container(PaywallScreenState(isPremium = premiumManager.isPremium())) + + init { + intent { + premiumManager + .premiumState + .onEach { isPremium -> + reduce { + state.copy(isPremium = isPremium) + } + }.launchIn(viewModelScope) + + premiumManager.refresh() + } + } fun onCloseClick() = intent { postSideEffect(PaywallScreenEffect.NavigateBack) } - fun onRestoreClick() { - } + fun onRestoreClick() = + intent { + premiumManager.refresh() + } fun onYearlyPlanClick() = intent { @@ -47,8 +74,16 @@ class PaywallViewModel : ViewModel(), ContainerHost PremiumManager.TEST_YEARLY_PRODUCT_ID + PaywallPlan.Monthly -> PremiumManager.TEST_MONTHLY_PRODUCT_ID + } + + postSideEffect(PaywallScreenEffect.Purchase(productId)) + } fun onContinueFreeClick() = intent { @@ -71,8 +106,12 @@ class PaywallViewModel : ViewModel(), ContainerHost create(modelClass: Class): T { - return PaywallViewModel() as T + return PaywallViewModel( + premiumManager, + ) as T } } diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt index e2708bb07..4526798cb 100644 --- a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/di/PaywallComponent.kt @@ -2,10 +2,13 @@ package dev.arkbuilders.rate.feature.paywall.di import dagger.Component import dev.arkbuilders.rate.core.di.CoreComponent +import dev.arkbuilders.rate.core.domain.repo.PremiumManager import dev.arkbuilders.rate.feature.paywall.PaywallViewModelFactory @PaywallScope @Component(dependencies = [CoreComponent::class], modules = []) interface PaywallComponent { fun paywallVMFactory(): PaywallViewModelFactory + + fun premiumManager(): PremiumManager } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cae15d523..b05452a5a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ material = "1.12.0" gson = "2.11.0" reorderable = "2.4.3" playReview = "2.0.2" +revenueCat = "10.10.0" [libraries] ark-about = { module = "dev.arkbuilders.components:about", version.ref = "arkAbout" } @@ -71,6 +72,7 @@ gson = { module = "com.google.code.gson:gson", version.ref = "gson" } play-review = { group = "com.google.android.play", name = "review", version.ref = "playReview" } play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" } +revenuecat-purchases = { module = "com.revenuecat.purchases:purchases", version.ref = "revenueCat" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx", version = "" } From 499d1e1998d1d7e6aa214a1b806be6a06c2bd0eb Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Sun, 28 Jun 2026 08:07:59 +0500 Subject: [PATCH 5/6] Add premium limit for quick calculation pins --- .../rate/presentation/MainScreen.kt | 5 + .../src/main/res/drawable/ic_premium.xml | 5 + .../presentation/QuickExternalNavigator.kt | 2 + .../main/HandleQuickSideEffect.kt | 2 + .../quick/presentation/main/QuickScreen.kt | 160 +++++++++++++++++- .../quick/presentation/main/QuickViewModel.kt | 57 ++++++- .../quick/presentation/ui/QuickSwipeItem.kt | 7 +- .../settings/presentation/SettingsScreen.kt | 20 +++ 8 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 core/presentation/src/main/res/drawable/ic_premium.xml diff --git a/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt b/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt index f5c07b4c2..6f906fd4d 100644 --- a/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt +++ b/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt @@ -29,6 +29,7 @@ import com.ramcosta.composedestinations.generated.destinations.SplashScreenDesti import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickCalculationScreenDestination import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickScreenDestination import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingScreenDestination +import com.ramcosta.composedestinations.generated.paywall.destinations.PaywallScreenDestination import com.ramcosta.composedestinations.generated.portfolio.destinations.PortfolioScreenDestination import com.ramcosta.composedestinations.generated.quick.destinations.AddQuickScreenDestination import com.ramcosta.composedestinations.generated.quick.destinations.QuickScreenDestination @@ -186,6 +187,10 @@ fun MainScreen() { OnboardingQuickCalculationScreenDestination, ) } + + override fun navigateToPaywall() { + destinationsNavigator.navigate(PaywallScreenDestination) + } } } QuickScreen( diff --git a/core/presentation/src/main/res/drawable/ic_premium.xml b/core/presentation/src/main/res/drawable/ic_premium.xml new file mode 100644 index 000000000..c267ffc77 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_premium.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/QuickExternalNavigator.kt b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/QuickExternalNavigator.kt index eb75c5b50..7d736020b 100644 --- a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/QuickExternalNavigator.kt +++ b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/QuickExternalNavigator.kt @@ -2,4 +2,6 @@ package dev.arkbuilders.rate.feature.quick.presentation interface QuickExternalNavigator { fun navigateToCalcOnboarding() + + fun navigateToPaywall() } diff --git a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/HandleQuickSideEffect.kt b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/HandleQuickSideEffect.kt index 1434fd730..3e8c0850e 100644 --- a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/HandleQuickSideEffect.kt +++ b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/HandleQuickSideEffect.kt @@ -146,6 +146,8 @@ suspend fun handleQuickSideEffect( QuickScreenEffect.NavigateBack -> ctx.findActivity()?.finish() + QuickScreenEffect.NavigateToPaywall -> externalNavigator.navigateToPaywall() + QuickScreenEffect.LaunchInAppReview -> { CoroutineScope(coroutineContext).launch { val launchInAppReview = QuickComponentHolder.provide(ctx).launchInAppReview() diff --git a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickScreen.kt b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickScreen.kt index 5d0ad2fe1..f7531e4f0 100644 --- a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickScreen.kt +++ b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickScreen.kt @@ -1,33 +1,53 @@ package dev.arkbuilders.rate.feature.quick.presentation.main import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.ExternalModuleGraph @@ -60,6 +80,7 @@ import dev.arkbuilders.rate.feature.quick.presentation.ui.QuickOptionsBottomShee import dev.arkbuilders.rate.feature.quick.presentation.ui.QuickSwipeItem import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import dev.arkbuilders.rate.core.presentation.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Destination @@ -103,6 +124,13 @@ fun QuickScreen( fun getCurrentGroup() = state.pages.getOrNull(pagerState.currentPage)?.group + if (state.showUnlimitedPinDialog) { + UnlimitedPinPremiumDialog( + onDismiss = viewModel::onDismissUnlimitedPinDialog, + onTryPremiumClick = viewModel::onTryPremiumClick, + ) + } + HandleQuickSideEffects( viewModel = viewModel, state = state, @@ -157,7 +185,7 @@ fun QuickScreen( onLongClick = { viewModel.onShowGroupOptions(it) }, - onPin = viewModel::onPin, + onPinRequested = viewModel::onPinRequested, onUnpin = viewModel::onUnpin, onNewCode = { navigator @@ -175,7 +203,7 @@ fun QuickScreen( QuickOptionsBottomSheet( calculationOptionsSheetState, calculation = it.calculation, - onPin = viewModel::onPin, + onPin = { calculation -> viewModel.onPinRequested(calculation) }, onUnpin = viewModel::onUnpin, onEdit = viewModel::onEdit, onReuse = viewModel::onReuse, @@ -246,7 +274,7 @@ private fun Content( onDelete: (QuickCalculation) -> Unit, onClick: (QuickCalculation) -> Unit, onLongClick: (QuickCalculation) -> Unit = {}, - onPin: (QuickCalculation) -> Unit, + onPinRequested: (QuickCalculation) -> Boolean, onUnpin: (QuickCalculation) -> Unit, onNewCode: (CurrencyCode) -> Unit, ) { @@ -279,7 +307,7 @@ private fun Content( onDelete = onDelete, onClick = onClick, onLongClick = onLongClick, - onPin = onPin, + onPinRequested = onPinRequested, onUnpin = onUnpin, onNewCode = onNewCode, ) @@ -297,7 +325,7 @@ private fun Content( onDelete = onDelete, onClick = onClick, onLongClick = onLongClick, - onPin = onPin, + onPinRequested = onPinRequested, onUnpin = onUnpin, onNewCode = onNewCode, ) @@ -314,7 +342,7 @@ private fun GroupPage( pinned: List, notPinned: List, onDelete: (QuickCalculation) -> Unit, - onPin: (QuickCalculation) -> Unit, + onPinRequested: (QuickCalculation) -> Boolean, onUnpin: (QuickCalculation) -> Unit, onClick: (QuickCalculation) -> Unit = {}, onLongClick: (QuickCalculation) -> Unit = {}, @@ -369,7 +397,7 @@ private fun GroupPage( }, calculation = it, onDelete = { onDelete(it) }, - onPin = onPin, + onPinRequested = onPinRequested, ) AppHorDiv16() } @@ -391,6 +419,124 @@ private fun GroupPage( } } +@Composable +private fun UnlimitedPinPremiumDialog( + onDismiss: () -> Unit, + onTryPremiumClick: () -> Unit, +) { + val dialogShape = RoundedCornerShape(28.dp) + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(dialogShape) + .background(Color.White) + .border( + border = BorderStroke(1.dp, ArkColor.BorderSecondary), + shape = dialogShape, + ) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .size(84.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + ArkColor.BrandSecondary.copy(alpha = 0.16f), + ArkColor.Teal500.copy(alpha = 0.16f), + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(60.dp) + .clip(CircleShape) + .background(Color.White) + .border( + border = + BorderStroke( + width = 1.dp, + color = ArkColor.BorderSecondary, + ), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(CoreR.drawable.ic_premium), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + + IconButton( + modifier = + Modifier + .align(Alignment.TopEnd) + .size(40.dp), + onClick = onDismiss, + ) { + Icon( + modifier = Modifier.size(14.dp), + painter = painterResource(CoreR.drawable.ic_close), + contentDescription = stringResource(CoreRString.close), + tint = ArkColor.FGQuinary, + ) + } + } + + Spacer(Modifier.height(20.dp)) + Text( + text = "Unlimited pin in Premium", + color = ArkColor.TextPrimary, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp), + text = + "Pin as many quick calculations as you " + + "need and keep your favorites one tap away.", + color = ArkColor.TextTertiary, + fontSize = 15.sp, + lineHeight = 22.sp, + textAlign = TextAlign.Center, + ) + Button( + modifier = + Modifier + .padding(top = 24.dp) + .fillMaxWidth() + .height(48.dp), + onClick = onTryPremiumClick, + colors = ButtonDefaults.buttonColors(containerColor = ArkColor.Primary), + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = "Try Premium", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + @Preview(showBackground = true) @Composable private fun PreviewItem() { diff --git a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickViewModel.kt b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickViewModel.kt index d8ab103f3..ac3d687d0 100644 --- a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickViewModel.kt +++ b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/main/QuickViewModel.kt @@ -14,6 +14,7 @@ import dev.arkbuilders.rate.core.domain.repo.CurrencyRepo import dev.arkbuilders.rate.core.domain.repo.GroupRepo import dev.arkbuilders.rate.core.domain.repo.PreferenceKey import dev.arkbuilders.rate.core.domain.repo.Prefs +import dev.arkbuilders.rate.core.domain.repo.PremiumManager import dev.arkbuilders.rate.core.domain.repo.TimestampRepo import dev.arkbuilders.rate.core.domain.usecase.CalcFrequentCurrUseCase import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase @@ -51,6 +52,8 @@ data class QuickScreenState( val editGroupReorderSheetState: EditGroupReorderSheetState? = null, val editGroupOptionsSheetState: EditGroupOptionsSheetState? = null, val editGroupRenameSheetState: EditGroupRenameSheetState? = null, + val isPremium: Boolean = false, + val showUnlimitedPinDialog: Boolean = false, val initialized: Boolean = false, ) @@ -72,6 +75,8 @@ sealed class QuickScreenEffect { data object NavigateToCalculationOnboarding : QuickScreenEffect() data object NavigateBack : QuickScreenEffect() + + data object NavigateToPaywall : QuickScreenEffect() } class QuickViewModel( @@ -85,9 +90,10 @@ class QuickViewModel( private val searchUseCase: SearchUseCase, private val analyticsManager: AnalyticsManager, private val prefs: Prefs, + private val premiumManager: PremiumManager, ) : ViewModel(), ContainerHost { override val container: Container = - container(QuickScreenState()) + container(QuickScreenState(isPremium = premiumManager.isPremium())) init { init() @@ -106,6 +112,16 @@ class QuickViewModel( } }.launchIn(viewModelScope) + premiumManager.premiumState.onEach { isPremium -> + intent { + reduce { + state.copy( + isPremium = isPremium, + ) + } + } + }.launchIn(viewModelScope) + groupRepo.allFlow(GroupFeatureType.Quick).drop(1).onEach { intent { val pages = mapCalculationsToPages(quickRepo.getAll()) @@ -184,13 +200,48 @@ class QuickViewModel( reduce { state.copy(calculationOptionsData = null) } } - fun onPin(calculation: QuickCalculation) = + fun onPinRequested(calculation: QuickCalculation): Boolean { + val currentState = container.stateFlow.value + val canPin = currentState.isPremium || currentState.pages.all { it.pinned.isEmpty() } + if (canPin) { + onPin(calculation) + } else { + onPinLimitReached() + } + return canPin + } + + private fun onPin(calculation: QuickCalculation) = intent { analyticsManager.logEvent("quick_calculation_pinned") val newCalculation = calculation.copy(pinnedDate = OffsetDateTime.now()) quickRepo.insert(newCalculation) } + private fun onPinLimitReached() = + intent { + analyticsManager.logEvent("quick_unlimited_pin_premium_dialog_shown") + reduce { + state.copy( + calculationOptionsData = null, + showUnlimitedPinDialog = true, + ) + } + } + + fun onDismissUnlimitedPinDialog() = + intent { + analyticsManager.logEvent("quick_unlimited_pin_premium_dialog_closed") + reduce { state.copy(showUnlimitedPinDialog = false) } + } + + fun onTryPremiumClick() = + intent { + analyticsManager.logEvent("quick_unlimited_pin_try_premium_clicked") + reduce { state.copy(showUnlimitedPinDialog = false) } + postSideEffect(QuickScreenEffect.NavigateToPaywall) + } + fun onUnpin(calculation: QuickCalculation) = intent { analyticsManager.logEvent("quick_calculation_unpinned") @@ -413,6 +464,7 @@ class QuickViewModelFactory @AssistedInject constructor( private val searchUseCase: SearchUseCase, private val analyticsManager: AnalyticsManager, private val prefs: Prefs, + private val premiumManager: PremiumManager, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return QuickViewModel( @@ -426,6 +478,7 @@ class QuickViewModelFactory @AssistedInject constructor( searchUseCase, analyticsManager, prefs, + premiumManager, ) as T } diff --git a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/ui/QuickSwipeItem.kt b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/ui/QuickSwipeItem.kt index 7fb3f8da5..7019e0c6c 100644 --- a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/ui/QuickSwipeItem.kt +++ b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/ui/QuickSwipeItem.kt @@ -70,17 +70,14 @@ fun PinnedQuickSwipeItem( fun QuickSwipeItem( content: @Composable () -> Unit, calculation: QuickCalculation, - onPin: (QuickCalculation) -> Unit, + onPinRequested: (QuickCalculation) -> Boolean, onDelete: () -> Unit, ) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { when (it) { - SwipeToDismissBoxValue.StartToEnd -> { - onPin(calculation) - true - } + SwipeToDismissBoxValue.StartToEnd -> onPinRequested(calculation) SwipeToDismissBoxValue.EndToStart -> { onDelete() diff --git a/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt b/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt index 499fb8c9e..596d23238 100644 --- a/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt +++ b/feature/settings/src/main/java/dev/arkbuilders/rate/feature/settings/presentation/SettingsScreen.kt @@ -2,6 +2,7 @@ package dev.arkbuilders.rate.feature.settings.presentation import android.content.Context import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,7 +11,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -23,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -228,7 +232,23 @@ private fun Content( .padding(horizontal = 16.dp, vertical = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { + Box( + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .background(ArkColor.BrandSecondary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(18.dp), + painter = painterResource(CoreRDrawable.ic_premium), + contentDescription = null, + tint = Color.Unspecified, + ) + } Text( + modifier = Modifier.padding(start = 10.dp), text = "Premium", fontWeight = FontWeight.SemiBold, fontSize = 14.sp, From 59e65ca1391947bef2474b0fc1a437f04b863e19 Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Sun, 5 Jul 2026 06:43:16 +0500 Subject: [PATCH 6/6] Add premium crypto banner to search screen --- .../rate/presentation/MainScreen.kt | 26 ++++ feature/paywall/build.gradle.kts | 2 +- .../paywall/ExampleInstrumentedTest.kt | 6 +- .../rate/feature/paywall/PaywallScreen.kt | 6 +- .../rate/feature/paywall/ExampleUnitTest.kt | 3 +- .../presentation/SearchCurrencyScreen.kt | 143 ++++++++++++++++++ .../presentation/SearchExternalNavigator.kt | 5 + .../search/presentation/SearchViewModel.kt | 8 + 8 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchExternalNavigator.kt diff --git a/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt b/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt index 6f906fd4d..7a7730b54 100644 --- a/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt +++ b/app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt @@ -33,10 +33,13 @@ import com.ramcosta.composedestinations.generated.paywall.destinations.PaywallSc import com.ramcosta.composedestinations.generated.portfolio.destinations.PortfolioScreenDestination import com.ramcosta.composedestinations.generated.quick.destinations.AddQuickScreenDestination import com.ramcosta.composedestinations.generated.quick.destinations.QuickScreenDestination +import com.ramcosta.composedestinations.generated.search.destinations.SearchCurrencyScreenDestination +import com.ramcosta.composedestinations.generated.search.navtype.searchNavResultNavType import com.ramcosta.composedestinations.generated.settings.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.manualcomposablecalls.composable import com.ramcosta.composedestinations.navargs.primitives.longNavType import com.ramcosta.composedestinations.rememberNavHostEngine +import com.ramcosta.composedestinations.scope.resultBackNavigator import com.ramcosta.composedestinations.scope.resultRecipient import com.ramcosta.composedestinations.utils.startDestination import dev.arkbuilders.rate.core.domain.repo.AnalyticsManager @@ -53,6 +56,8 @@ import dev.arkbuilders.rate.feature.quick.presentation.QuickExternalNavigator import dev.arkbuilders.rate.feature.quick.presentation.main.QuickScreen import dev.arkbuilders.rate.feature.quickwidget.presentation.action.AddNewCalculationAction.Companion.ADD_NEW_CALCULATION import dev.arkbuilders.rate.feature.quickwidget.presentation.action.AddNewCalculationAction.Companion.ADD_NEW_CALCULATION_GROUP_KEY +import dev.arkbuilders.rate.feature.search.presentation.SearchCurrencyScreen +import dev.arkbuilders.rate.feature.search.presentation.SearchExternalNavigator import dev.arkbuilders.rate.presentation.navigation.AnimatedRateBottomNavigation import kotlinx.coroutines.flow.drop @@ -199,6 +204,27 @@ fun MainScreen() { externalNavigator = externalNavigator, ) } + + composable(SearchCurrencyScreenDestination) { + val args = navArgs + val externalNavigator = + remember { + object : SearchExternalNavigator { + override fun navigateToPaywall() { + destinationsNavigator.navigate(PaywallScreenDestination) + } + } + } + + SearchCurrencyScreen( + title = args.title, + navKey = args.navKey, + navPos = args.navPos, + prohibitedCodes = args.prohibitedCodes, + resultNavigator = resultBackNavigator(searchNavResultNavType), + externalNavigator = externalNavigator, + ) + } } } } diff --git a/feature/paywall/build.gradle.kts b/feature/paywall/build.gradle.kts index 5d4fd346e..dbb9fd56e 100644 --- a/feature/paywall/build.gradle.kts +++ b/feature/paywall/build.gradle.kts @@ -25,7 +25,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } diff --git a/feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt b/feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt index b58d0dfe5..9e9dc3cad 100644 --- a/feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt +++ b/feature/paywall/src/androidTest/java/dev/arkbuilders/rate/feature/paywall/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package dev.arkbuilders.rate.feature.paywall -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * diff --git a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt index 3007b8f89..3ddbfb186 100644 --- a/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt +++ b/feature/paywall/src/main/java/dev/arkbuilders/rate/feature/paywall/PaywallScreen.kt @@ -159,7 +159,9 @@ private fun PaywallContent( ) Text( modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp), - text = "Get live exchange rates updated every 5-10 minutes and more with Premium.", + text = + "Get live exchange rates " + + "updated every 5-10 minutes and more with Premium.", color = ArkColor.TextTertiary, fontSize = 15.sp, lineHeight = 22.sp, @@ -404,7 +406,7 @@ private fun BenefitIcon(iconType: BenefitIconType) { BenefitIconType.Pin -> Unit } } - } + } } } diff --git a/feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt b/feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt index 4550275d8..3e3665936 100644 --- a/feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt +++ b/feature/paywall/src/test/java/dev/arkbuilders/rate/feature/paywall/ExampleUnitTest.kt @@ -1,9 +1,8 @@ package dev.arkbuilders.rate.feature.paywall +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * diff --git a/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchCurrencyScreen.kt b/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchCurrencyScreen.kt index 3b071294c..c13924708 100644 --- a/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchCurrencyScreen.kt +++ b/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchCurrencyScreen.kt @@ -3,20 +3,41 @@ package dev.arkbuilders.rate.feature.search.presentation import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.ExternalModuleGraph @@ -24,8 +45,10 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import dev.arkbuilders.rate.core.domain.model.CurrencyCode import dev.arkbuilders.rate.core.domain.model.CurrencyInfo import dev.arkbuilders.rate.core.presentation.CoreRString +import dev.arkbuilders.rate.core.presentation.theme.ArkColor import dev.arkbuilders.rate.core.presentation.ui.AppHorDiv import dev.arkbuilders.rate.core.presentation.ui.AppTopBarBack +import dev.arkbuilders.rate.core.presentation.ui.CurrIcon import dev.arkbuilders.rate.core.presentation.ui.InfoDialog import dev.arkbuilders.rate.core.presentation.ui.ListHeader import dev.arkbuilders.rate.core.presentation.ui.LoadingScreen @@ -43,6 +66,7 @@ fun SearchCurrencyScreen( navPos: Int? = null, prohibitedCodes: Array? = null, resultNavigator: ResultBackNavigator, + externalNavigator: SearchExternalNavigator, ) { val ctx = LocalContext.current val component = @@ -67,6 +91,8 @@ fun SearchCurrencyScreen( resultNavigator.navigateBack(effect.result) SearchScreenEffect.NavigateBack -> resultNavigator.navigateBack() + + SearchScreenEffect.NavigateToPaywall -> externalNavigator.navigateToPaywall() } } @@ -96,6 +122,7 @@ fun SearchCurrencyScreen( all = state.all, topResultsFiltered = state.topResultsFiltered, onClick = viewModel::onClick, + onTryPremiumClick = viewModel::onTryPremiumClick, ) } else { LoadingScreen() @@ -128,6 +155,7 @@ private fun Results( all: List, topResultsFiltered: List, onClick: (CurrencyInfo) -> Unit, + onTryPremiumClick: () -> Unit, ) { when { filter.isNotEmpty() -> { @@ -148,6 +176,7 @@ private fun Results( else -> { LazyColumn { + item { CryptoPremiumBanner(onTryPremiumClick) } if (frequent.isNotEmpty()) { item { ListHeader(stringResource(CoreRString.frequent_currencies)) } items(frequent) { model -> @@ -168,3 +197,117 @@ private fun Results( } } } + +@Composable +private fun CryptoPremiumBanner(onTryPremiumClick: () -> Unit) { + Box( + modifier = + Modifier + .padding(start = 16.dp, end = 16.dp, top = 24.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.horizontalGradient( + listOf( + Color(0xFFF1E8FF), + Color(0xFFE8ECFF), + ), + ), + ) + .border( + border = BorderStroke(1.dp, Color(0xFFD8C7FF)), + shape = RoundedCornerShape(12.dp), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CryptoTokenStack() + Text( + modifier = + Modifier + .padding(start = 12.dp, end = 12.dp) + .weight(1f), + text = "500+ Crypto Tokens", + color = Color(0xFF3B1688), + fontSize = 14.sp, + lineHeight = 16.sp, + maxLines = 2, + fontWeight = FontWeight.SemiBold, + ) + Button( + modifier = Modifier.height(36.dp), + onClick = onTryPremiumClick, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6D3DF5)), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Text( + text = "Try Premium", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun CryptoTokenStack() { + val iconCodes = listOf("BTC", "ETH", "SOL") + + Box( + modifier = + Modifier + .width(88.dp) + .height(32.dp), + ) { + repeat(4) { index -> + Box( + modifier = + Modifier + .offset(x = (index * 20).dp) + .size(32.dp) + .clip(CircleShape) + .background(Color.White) + .border( + border = BorderStroke(1.dp, ArkColor.BorderSecondary), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + iconCodes.getOrNull(index)?.let { code -> + CurrIcon( + modifier = Modifier.size(24.dp), + code = code, + ) + } + } + } + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .size(24.dp) + .clip(CircleShape) + .background(Color(0xFFE7DDFF)) + .border( + border = BorderStroke(1.dp, Color.White), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+500", + color = Color(0xFF6D3DF5), + fontSize = 8.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchExternalNavigator.kt b/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchExternalNavigator.kt new file mode 100644 index 000000000..6d6cebe1f --- /dev/null +++ b/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchExternalNavigator.kt @@ -0,0 +1,5 @@ +package dev.arkbuilders.rate.feature.search.presentation + +interface SearchExternalNavigator { + fun navigateToPaywall() +} diff --git a/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchViewModel.kt b/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchViewModel.kt index 1523c7790..1b1018b02 100644 --- a/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchViewModel.kt +++ b/feature/search/src/main/java/dev/arkbuilders/rate/feature/search/presentation/SearchViewModel.kt @@ -30,6 +30,8 @@ sealed class SearchScreenEffect { data class NavigateBackWithResult(val result: SearchNavResult) : SearchScreenEffect() data object NavigateBack : SearchScreenEffect() + + data object NavigateToPaywall : SearchScreenEffect() } class SearchViewModel( @@ -96,6 +98,12 @@ class SearchViewModel( reduce { state.copy(showCodeProhibitedDialog = false) } } + fun onTryPremiumClick() = + intent { + analyticsManager.logEvent("search_crypto_banner_try_premium_clicked") + postSideEffect(SearchScreenEffect.NavigateToPaywall) + } + fun onBackClick() = intent { analyticsManager.logEvent("search_back_clicked")