From 70a4d4bf6348fa94b7f3d09d2f6758ab09cdb8f2 Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Fri, 2 Jan 2026 10:45:40 +0530 Subject: [PATCH 1/2] add browser redirect for paid course purchase --- .../java/org/openedx/app/room/AppDatabase.kt | 2 +- .../openedx/app/room/DatabaseMigrations.kt | 19 +- .../discovery/data/model/CourseDetails.kt | 5 +- .../discovery/data/model/room/CourseEntity.kt | 6 +- .../openedx/discovery/domain/model/Course.kt | 3 +- .../detail/CourseDetailsFragment.kt | 175 +++++++++++++++++- discovery/src/main/res/values/strings.xml | 4 + 7 files changed, 204 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index 3be9b3ee5..d07a88d74 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -18,7 +18,7 @@ import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 2 +const val DATABASE_VERSION = 3 const val DATABASE_NAME = "OpenEdX_db" @Database( diff --git a/app/src/main/java/org/openedx/app/room/DatabaseMigrations.kt b/app/src/main/java/org/openedx/app/room/DatabaseMigrations.kt index b2e4455ac..2d73e3e40 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseMigrations.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseMigrations.kt @@ -39,6 +39,20 @@ val MIGRATION_1_2 = object : Migration(1, 2) { } } +/** + * Migration from version 2 to version 3 + * + * Changes: + * - Adds new field to course_discovery_table: + * - purchaseLink: URL to purchase paid courses + */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add purchaseLink column to course_discovery_table (nullable field) + db.execSQL("ALTER TABLE course_discovery_table ADD COLUMN purchaseLink TEXT DEFAULT NULL") + } +} + /** * All migrations in chronological order. * This array is used by the database builder to apply migrations automatically. @@ -48,9 +62,10 @@ val MIGRATION_1_2 = object : Migration(1, 2) { * - Add it to this array: arrayOf(MIGRATION_1_2, MIGRATION_2_3, ...) */ val ALL_MIGRATIONS = arrayOf( - MIGRATION_1_2 + MIGRATION_1_2, + MIGRATION_2_3 // Add future migrations here, e.g.: - // MIGRATION_2_3, // MIGRATION_3_4, + // MIGRATION_4_5, ) diff --git a/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt b/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt index f37dadf45..ed851dca7 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/model/CourseDetails.kt @@ -58,6 +58,8 @@ data class CourseDetails( val learningOutcomes: List?, @SerializedName("instructors") val instructors: List?, + @SerializedName("purchase_link") + val purchaseLink: String?, ) { fun mapToDomain(): Course { @@ -87,7 +89,8 @@ data class CourseDetails( courseRequirement = courseRequirement.orEmpty(), description = description.orEmpty(), learningOutcomes = learningOutcomes?.joinToString(", ") ?: "", - instructorsList = instructors ?: emptyList() + instructorsList = instructors ?: emptyList(), + purchaseLink = purchaseLink ) } diff --git a/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt b/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt index 438188f70..cd5ad0944 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/model/room/CourseEntity.kt @@ -64,6 +64,8 @@ data class CourseEntity( val learningOutcomes: String, @ColumnInfo("instructors") val instructors: String, + @ColumnInfo("purchaseLink") + val purchaseLink: String?, ) { fun mapToDomain(): Course { @@ -93,7 +95,8 @@ data class CourseEntity( courseRequirement = courseRequirement, description = description, learningOutcomes = learningOutcomes, - instructorsList = emptyList() + instructorsList = emptyList(), + purchaseLink = purchaseLink ) } @@ -126,6 +129,7 @@ data class CourseEntity( description = model.description.orEmpty(), learningOutcomes = model.learningOutcomes?.joinToString(", ") ?: "", instructors = model.instructors?.mapNotNull { it.name }?.joinToString(", ") ?: "", + purchaseLink = model.purchaseLink ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt b/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt index aa56cd966..0ddccb2c5 100644 --- a/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt +++ b/discovery/src/main/java/org/openedx/discovery/domain/model/Course.kt @@ -29,5 +29,6 @@ data class Course( val courseRequirement: String, val description: String, val learningOutcomes: String, - val instructorsList: List = emptyList() + val instructorsList: List = emptyList(), + val purchaseLink: String? = null ) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index d4d01face..55adb4732 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -46,11 +46,13 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Assignment import androidx.compose.material.icons.automirrored.filled.Rule +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Domain import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material.icons.filled.TrackChanges import androidx.compose.material.icons.outlined.Report import androidx.compose.material.rememberScaffoldState @@ -82,6 +84,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -89,7 +92,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog import androidx.compose.ui.zIndex +import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import coil.compose.AsyncImage @@ -108,6 +113,7 @@ import org.openedx.core.ui.isPreview import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.EmailUtil import org.openedx.discovery.R @@ -510,10 +516,15 @@ private fun CourseDetailNativeContent( val buttonText = if (course.isEnrolled) { stringResource(id = R.string.discovery_view_course) + } else if (!course.purchaseLink.isNullOrBlank()) { + stringResource(id = R.string.discovery_buy_course) } else { stringResource(id = R.string.discovery_enroll_now) } + var showPurchaseDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val durationText = if (course.duration.isBlank()) { stringResource(id = R.string.course_duration_unspecified) } else { @@ -576,10 +587,61 @@ private fun CourseDetailNativeContent( ) if (!(enrollmentEnd != null && Date() > enrollmentEnd)) { Spacer(Modifier.height(32.dp)) - OpenEdXButton( - modifier = buttonWidth, - text = buttonText, - onClick = onButtonClick + if (course.isEnrolled) { + // User is enrolled - show regular View Course button + OpenEdXButton( + modifier = buttonWidth, + text = buttonText, + onClick = onButtonClick + ) + } else if (!course.purchaseLink.isNullOrBlank()) { + // User not enrolled and purchase link exists - show Buy Course button with cart icon + OpenEdXButton( + modifier = buttonWidth, + text = buttonText, + onClick = { + showPurchaseDialog = true + }, + content = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.ShoppingCart, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = buttonText, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + } + ) + } else { + // User not enrolled and no purchase link - show regular Enroll Now button + OpenEdXButton( + modifier = buttonWidth, + text = buttonText, + onClick = onButtonClick + ) + } + } + + // Show purchase confirmation dialog + if (showPurchaseDialog) { + PurchaseConfirmationDialog( + onDismiss = { showPurchaseDialog = false }, + onContinue = { + val intent = Intent(Intent.ACTION_VIEW, course.purchaseLink?.toUri()) + context.startActivity(intent) + showPurchaseDialog = false + } ) } Text( @@ -958,6 +1020,111 @@ private fun CourseDescription( }) } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PurchaseConfirmationDialog( + onDismiss: () -> Unit, + onContinue: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + content = { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .background( + MaterialTheme.appColors.background, + MaterialTheme.appShapes.cardShape + ) + .clip(MaterialTheme.appShapes.cardShape) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) + .padding(horizontal = 40.dp, vertical = 36.dp) + .semantics { testTagsAsResourceId = true }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Close button at top right + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + IconButton( + modifier = Modifier + .testTag("ib_close_purchase_dialog") + .size(24.dp), + onClick = onDismiss + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = CoreR.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + + // Shopping cart icon + Icon( + modifier = Modifier + .width(88.dp) + .height(85.dp), + imageVector = Icons.Filled.ShoppingCart, + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + + Spacer(Modifier.size(36.dp)) + + // Title + Text( + modifier = Modifier.testTag("txt_purchase_dialog_title"), + text = stringResource(id = R.string.discovery_purchase_confirmation_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.size(16.dp)) + + // Message + Text( + modifier = Modifier.testTag("txt_purchase_dialog_message"), + text = stringResource(id = R.string.discovery_purchase_confirmation_message), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.size(36.dp)) + + // Continue button with primary color + OpenEdXButton( + modifier = Modifier + .testTag("btn_continue_purchase") + .fillMaxWidth(), + text = stringResource(id = R.string.discovery_continue), + onClick = onContinue, + backgroundColor = MaterialTheme.appColors.primary, + content = { + Text( + modifier = Modifier + .testTag("txt_continue_purchase") + .fillMaxWidth(), + text = stringResource(id = R.string.discovery_continue), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge, + textAlign = TextAlign.Center + ) + } + ) + } + } + ) +} + @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable diff --git a/discovery/src/main/res/values/strings.xml b/discovery/src/main/res/values/strings.xml index 70fac189f..8bc352c83 100644 --- a/discovery/src/main/res/values/strings.xml +++ b/discovery/src/main/res/values/strings.xml @@ -9,6 +9,10 @@ Course details Enroll now View course + Buy Course + Complete Purchase + You\'ll be redirected to your browser to complete the purchase. + Continue You cannot enroll in this course because the enrollment date is over. To enroll in this course, please make sure you are connected to the internet. You have been successfully enrolled in this course. From 2de2d4e1656e709c4aa2426c033632ff8b9c34e1 Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Fri, 2 Jan 2026 10:46:28 +0530 Subject: [PATCH 2/2] check on user return if user has completed payment and enrolled in course --- .../discovery/presentation/detail/CourseDetailsFragment.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 55adb4732..f527efd06 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -139,6 +139,13 @@ class CourseDetailsFragment : Fragment() { } private val router by inject() + override fun onResume() { + super.onResume() + // Refresh course details when returning from browser purchase + // This ensures enrollment status is updated if user completed purchase + viewModel.getCourseDetail() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?,