Skip to content

Commit

Permalink
feat: Sync Course Dates to Calendar (openedx#228)
Browse files Browse the repository at this point in the history
Calendar Sync Integration:
- Integrated "Sync to Calendar" switch on Course Dates tab.
- Dynamically show/hide switch based on Remote config.
- Added user permission prompt for calendar access.
- Added confirmation alert for initial course additions.
- Included course dates as events in the local app calendar.
- Added AlertDialog Loader for event creation/update.
- Implemented Calendar preference for streamlined alert management.
- Added user prompt for updating or removing the calendar.
- Added a prompt for out-of-sync calendar situations.
- Update calendar events on 'Shift Due Dates' CTA.

Remote Config for Calendar Feature:
- Retrieve remote config from LMS within the enrollments API.
 Store the configuration in CorePreferences, ensuring it is updated
 with each enrollments API call.

- The CalendarSync Config now manages specific values pertinent to
 the Calendar Sync integration on both the Course Home and Dates
 tab.

Fixes: LEARNER-9801
  • Loading branch information
HamzaIsrar12 authored Feb 26, 2024
1 parent fc5c648 commit 92d697f
Show file tree
Hide file tree
Showing 31 changed files with 1,731 additions and 144 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import org.openedx.app.BuildConfig
import org.openedx.core.data.model.User
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.data.storage.InAppReviewPreferences
import org.openedx.core.domain.model.AppConfig
import org.openedx.core.domain.model.VideoQuality
import org.openedx.core.domain.model.VideoSettings
import org.openedx.core.extension.replaceSpace
import org.openedx.course.data.storage.CoursePreferences
import org.openedx.profile.data.model.Account
import org.openedx.profile.data.storage.ProfilePreferences
import org.openedx.whatsnew.data.storage.WhatsNewPreferences

class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences,
WhatsNewPreferences, InAppReviewPreferences {
WhatsNewPreferences, InAppReviewPreferences, CoursePreferences {

private val sharedPreferences =
context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE)
Expand Down Expand Up @@ -113,6 +116,16 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences
)
}

override var appConfig: AppConfig
set(value) {
val appConfigJson = Gson().toJson(value)
saveString(APP_CONFIG, appConfigJson)
}
get() {
val appConfigString = getString(APP_CONFIG)
return Gson().fromJson(appConfigString, AppConfig::class.java)
}

override var lastWhatsNewVersion: String
set(value) {
saveString(LAST_WHATS_NEW_VERSION, value)
Expand All @@ -133,13 +146,19 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences
?: InAppReviewPreferences.VersionName.default
}


override var wasPositiveRated: Boolean
set(value) {
saveBoolean(APP_WAS_POSITIVE_RATED, value)
}
get() = getBoolean(APP_WAS_POSITIVE_RATED)

override fun setCalendarSyncEventsDialogShown(courseName: String) {
saveBoolean(courseName.replaceSpace("_"), true)
}

override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean =
getBoolean(courseName.replaceSpace("_"))

companion object {
private const val ACCESS_TOKEN = "access_token"
private const val REFRESH_TOKEN = "refresh_token"
Expand All @@ -152,5 +171,6 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences
private const val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = "video_settings_wifi_download_only"
private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality"
private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality"
private const val APP_CONFIG = "app_config"
}
}
17 changes: 13 additions & 4 deletions app/src/main/java/org/openedx/app/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.openedx.auth.presentation.sso.GoogleAuthHelper
import org.openedx.auth.presentation.sso.MicrosoftAuthHelper
import org.openedx.auth.presentation.sso.OAuthHelper
import org.openedx.core.config.Config
import org.openedx.core.data.model.CourseEnrollments
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.data.storage.InAppReviewPreferences
import org.openedx.core.interfaces.EnrollInCourseInteractor
Expand All @@ -40,14 +41,16 @@ import org.openedx.core.system.ResourceManager
import org.openedx.core.system.connection.NetworkConnection
import org.openedx.core.system.notifier.AppUpgradeNotifier
import org.openedx.core.system.notifier.CourseNotifier
import org.openedx.course.domain.interactor.CourseInteractor
import org.openedx.dashboard.notifier.DashboardNotifier
import org.openedx.core.system.notifier.DownloadNotifier
import org.openedx.core.system.notifier.VideoNotifier
import org.openedx.course.data.storage.CoursePreferences
import org.openedx.course.domain.interactor.CourseInteractor
import org.openedx.course.presentation.CourseAnalytics
import org.openedx.course.presentation.CourseRouter
import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics
import org.openedx.course.presentation.calendarsync.CalendarManager
import org.openedx.dashboard.notifier.DashboardNotifier
import org.openedx.dashboard.presentation.DashboardRouter
import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics
import org.openedx.discovery.presentation.DiscoveryAnalytics
import org.openedx.discovery.presentation.DiscoveryRouter
import org.openedx.discussion.presentation.DiscussionAnalytics
Expand All @@ -69,12 +72,18 @@ val appModule = module {
single<ProfilePreferences> { get<PreferencesManager>() }
single<WhatsNewPreferences> { get<PreferencesManager>() }
single<InAppReviewPreferences> { get<PreferencesManager>() }
single<CoursePreferences> { get<PreferencesManager>() }

single { ResourceManager(get()) }
single { AppCookieManager(get(), get()) }
single { ReviewManagerFactory.create(get()) }
single { CalendarManager(get(), get(), get()) }

single<Gson> { GsonBuilder().create() }
single<Gson> {
GsonBuilder()
.registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer())
.create()
}

single { AppNotifier() }
single { CourseNotifier() }
Expand Down
13 changes: 10 additions & 3 deletions app/src/main/java/org/openedx/app/di/ScreenModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ val screenModule = module {
get(),
get(),
get(),
get()
get(),
get(),
get(),
get(),
)
}
viewModel { (courseId: String) ->
Expand Down Expand Up @@ -229,13 +232,17 @@ val screenModule = module {
get()
)
}
viewModel { (courseId: String, isSelfPaced: Boolean) ->
viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean) ->
CourseDatesViewModel(
courseId,
courseName,
isSelfPaced,
get(),
get(),
get()
get(),
get(),
get(),
get(),
)
}
viewModel { (courseId: String, handoutsType: String) ->
Expand Down
15 changes: 15 additions & 0 deletions core/src/main/java/org/openedx/core/data/model/AppConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.openedx.core.data.model

import com.google.gson.annotations.SerializedName
import org.openedx.core.domain.model.AppConfig as DomainAppConfig

data class AppConfig(
@SerializedName("course_dates_calendar_sync")
val calendarSyncConfig: CalendarSyncConfig = CalendarSyncConfig(),
) {
fun mapToDomain(): DomainAppConfig {
return DomainAppConfig(
courseDatesCalendarSync = calendarSyncConfig.mapToDomain(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.openedx.core.data.model

import com.google.gson.annotations.SerializedName
import org.openedx.core.domain.model.CourseDatesCalendarSync

data class CalendarSyncConfig(
@SerializedName("android")
val platformConfig: CalendarSyncPlatform = CalendarSyncPlatform(),
) {
fun mapToDomain(): CourseDatesCalendarSync {
return CourseDatesCalendarSync(
isEnabled = platformConfig.enabled,
isSelfPacedEnabled = platformConfig.selfPacedEnabled,
isInstructorPacedEnabled = platformConfig.instructorPacedEnabled,
isDeepLinkEnabled = platformConfig.deepLinksEnabled,
)
}
}

data class CalendarSyncPlatform(
@SerializedName("enabled")
val enabled: Boolean = false,
@SerializedName("self_paced_enabled")
val selfPacedEnabled: Boolean = false,
@SerializedName("instructor_paced_enabled")
val instructorPacedEnabled: Boolean = false,
@SerializedName("deep_links_enabled")
val deepLinksEnabled: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -1,8 +1,69 @@
package org.openedx.core.data.model

import com.google.gson.Gson
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.annotations.SerializedName
import java.lang.reflect.Type

data class CourseEnrollments(
@SerializedName("enrollments")
val enrollments: DashboardCourseList
)
val enrollments: DashboardCourseList,

@SerializedName("config")
val configs: AppConfig,
) {
class Deserializer : JsonDeserializer<CourseEnrollments> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): CourseEnrollments {
val enrollments = deserializeEnrollments(json)
val appConfig = deserializeAppConfig(json)

return CourseEnrollments(enrollments, appConfig)
}

private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList {
return try {
Gson().fromJson(
(json as JsonObject).get("enrollments"),
DashboardCourseList::class.java
)
} catch (ex: Exception) {
DashboardCourseList(
next = null,
previous = null,
count = 0,
numPages = 0,
currentPage = 0,
results = listOf()
)
}
}

/**
* To remove dependency on the backend, all the data related to Remote Config
* will be received under the `configs` key. The `config` is the key under
* 'configs` which defines the data that is related to the configuration of the
* app.
*/
private fun deserializeAppConfig(json: JsonElement?): AppConfig {
return try {
val config = (json as JsonObject)
.getAsJsonObject("configs")
.getAsJsonPrimitive("config")

Gson().fromJson(
config.asString,
AppConfig::class.java
)
} catch (ex: Exception) {
AppConfig()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.openedx.core.data.storage

import org.openedx.core.data.model.User
import org.openedx.core.domain.model.AppConfig
import org.openedx.core.domain.model.VideoSettings

interface CorePreferences {
Expand All @@ -9,6 +10,7 @@ interface CorePreferences {
var accessTokenExpiresAt: Long
var user: User?
var videoSettings: VideoSettings
var appConfig: AppConfig

fun clear()
}
14 changes: 14 additions & 0 deletions core/src/main/java/org/openedx/core/domain/model/AppConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.openedx.core.domain.model

import java.io.Serializable

data class AppConfig(
val courseDatesCalendarSync: CourseDatesCalendarSync,
) : Serializable

data class CourseDatesCalendarSync(
val isEnabled: Boolean,
val isSelfPacedEnabled: Boolean,
val isInstructorPacedEnabled: Boolean,
val isDeepLinkEnabled: Boolean,
) : Serializable
4 changes: 4 additions & 0 deletions core/src/main/java/org/openedx/core/extension/StringExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ fun String.replaceLinkTags(isDarkTheme: Boolean): String {
fun String.replaceSpace(target: String = ""): String = this.replace(" ", target)

fun String.tagId(): String = this.replaceSpace("_").lowercase(Locale.getDefault())

fun String.takeIfNotEmpty(): String? {
return if (this.isEmpty().not()) this else null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.openedx.core.system.notifier

import org.openedx.core.domain.model.CourseDateBlock

sealed class CalendarSyncEvent : CourseEvent {
class CreateCalendarSyncEvent(
val courseDates: List<CourseDateBlock>,
val dialogType: String,
val checkOutOfSync: Boolean,
) : CalendarSyncEvent()

class CheckCalendarSyncEvent(val isSynced: Boolean) : CalendarSyncEvent()
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ class CourseNotifier {
suspend fun send(event: CourseSubtitleLanguageChanged) = channel.emit(event)
suspend fun send(event: CourseSectionChanged) = channel.emit(event)
suspend fun send(event: CourseCompletionSet) = channel.emit(event)

}
suspend fun send(event: CalendarSyncEvent) = channel.emit(event)
}
6 changes: 6 additions & 0 deletions core/src/main/java/org/openedx/core/utils/TimeUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,9 @@ fun Date.isTimeLessThan24Hours(): Boolean {
val timeInMillis = (calendar.timeInMillis - TimeUtils.getCurrentTime()).unaryPlus()
return timeInMillis < TimeUnit.DAYS.toMillis(1)
}

fun Date.toCalendar(): Calendar {
val calendar = Calendar.getInstance()
calendar.time = this
return calendar
}
5 changes: 5 additions & 0 deletions course/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.openedx.course.data.storage

interface CoursePreferences {
fun setCalendarSyncEventsDialogShown(courseName: String)
fun isCalendarSyncEventsDialogShown(courseName: String): Boolean
}
Loading

0 comments on commit 92d697f

Please sign in to comment.