diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index eeeccd39c..603876d54 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -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) @@ -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) @@ -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" @@ -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" } } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index dba5727d5..403f50d0c 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -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 @@ -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 @@ -69,12 +72,18 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { ResourceManager(get()) } single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } + single { CalendarManager(get(), get(), get()) } - single { GsonBuilder().create() } + single { + GsonBuilder() + .registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer()) + .create() + } single { AppNotifier() } single { CourseNotifier() } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 8391a0e03..a69c5f01e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -161,7 +161,10 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), + get(), + get(), ) } viewModel { (courseId: String) -> @@ -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) -> diff --git a/core/src/main/java/org/openedx/core/data/model/AppConfig.kt b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt new file mode 100644 index 000000000..4fcbe3d89 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt @@ -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(), + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt b/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt new file mode 100644 index 000000000..bfd09b3d3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt @@ -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, +) diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt index 1c10cfa92..89ecdcab4 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -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 { + 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() + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 11f21c661..48999ab4e 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -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 { @@ -9,6 +10,7 @@ interface CorePreferences { var accessTokenExpiresAt: Long var user: User? var videoSettings: VideoSettings + var appConfig: AppConfig fun clear() } diff --git a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt new file mode 100644 index 000000000..596fd0619 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt @@ -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 diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 58a8eef26..343398782 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -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 +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/CalendarSyncEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/CalendarSyncEvent.kt new file mode 100644 index 000000000..f33c8d921 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CalendarSyncEvent.kt @@ -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, + val dialogType: String, + val checkOutOfSync: Boolean, + ) : CalendarSyncEvent() + + class CheckCalendarSyncEvent(val isSynced: Boolean) : CalendarSyncEvent() +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index 3b5c48099..ddd338540 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -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) - -} \ No newline at end of file + suspend fun send(event: CalendarSyncEvent) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index e85397491..d77a1ab5e 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -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 +} diff --git a/course/src/main/AndroidManifest.xml b/course/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5c18ebdbf --- /dev/null +++ b/course/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/course/src/main/java/org/openedx/course/data/storage/CoursePreferences.kt b/course/src/main/java/org/openedx/course/data/storage/CoursePreferences.kt new file mode 100644 index 000000000..8190378de --- /dev/null +++ b/course/src/main/java/org/openedx/course/data/storage/CoursePreferences.kt @@ -0,0 +1,6 @@ +package org.openedx.course.data.storage + +interface CoursePreferences { + fun setCalendarSyncEventsDialogShown(courseName: String) + fun isCalendarSyncEventsDialogShown(courseName: String): Boolean +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt new file mode 100644 index 000000000..dcae8e0c2 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarManager.kt @@ -0,0 +1,398 @@ +package org.openedx.course.presentation.calendarsync + +import android.annotation.SuppressLint +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.Cursor +import android.net.Uri +import android.provider.CalendarContract +import androidx.core.content.ContextCompat +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.system.ResourceManager +import org.openedx.core.utils.Logger +import org.openedx.core.utils.toCalendar +import org.openedx.course.R +import java.util.Calendar +import java.util.TimeZone +import java.util.concurrent.TimeUnit +import org.openedx.core.R as CoreR + +class CalendarManager( + private val context: Context, + private val corePreferences: CorePreferences, + private val resourceManager: ResourceManager, +) { + private val logger = Logger(TAG) + + val permissions = arrayOf( + android.Manifest.permission.WRITE_CALENDAR, + android.Manifest.permission.READ_CALENDAR + ) + + private val accountName: String + get() = getUserAccountForSync() + + /** + * Check if the app has the calendar READ/WRITE permissions or not + */ + fun hasPermissions(): Boolean = permissions.all { + PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(context, it) + } + + /** + * Check if the calendar is already existed in mobile calendar app or not + */ + fun isCalendarExists(calendarTitle: String): Boolean { + if (hasPermissions()) { + return getCalendarId(calendarTitle) != CALENDAR_DOES_NOT_EXIST + } + return false + } + + /** + * Create or update the calendar if it is already existed in mobile calendar app + */ + fun createOrUpdateCalendar( + calendarTitle: String + ): Long { + val calendarId = getCalendarId( + calendarTitle = calendarTitle + ) + + if (calendarId != CALENDAR_DOES_NOT_EXIST) { + deleteCalendar(calendarId = calendarId) + } + + return createCalendar( + calendarTitle = calendarTitle + ) + } + + /** + * Method to create a separate calendar based on course name in mobile calendar app + */ + private fun createCalendar( + calendarTitle: String + ): Long { + val contentValues = ContentValues() + contentValues.put(CalendarContract.Calendars.NAME, calendarTitle) + contentValues.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, calendarTitle) + contentValues.put(CalendarContract.Calendars.ACCOUNT_NAME, accountName) + contentValues.put( + CalendarContract.Calendars.ACCOUNT_TYPE, + CalendarContract.ACCOUNT_TYPE_LOCAL + ) + contentValues.put(CalendarContract.Calendars.OWNER_ACCOUNT, accountName) + contentValues.put( + CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, + CalendarContract.Calendars.CAL_ACCESS_ROOT + ) + contentValues.put(CalendarContract.Calendars.SYNC_EVENTS, 1) + contentValues.put(CalendarContract.Calendars.VISIBLE, 1) + contentValues.put( + CalendarContract.Calendars.CALENDAR_COLOR, + ContextCompat.getColor(context, org.openedx.core.R.color.primary) + ) + val creationUri: Uri? = asSyncAdapter( + Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), + accountName + ) + creationUri?.let { + val calendarData: Uri? = context.contentResolver.insert(creationUri, contentValues) + calendarData?.let { + val id = calendarData.lastPathSegment?.toLong() + logger.d { "Calendar ID $id" } + return id ?: CALENDAR_DOES_NOT_EXIST + } + } + return CALENDAR_DOES_NOT_EXIST + } + + /** + * Method to check if the calendar with the course name exist in the mobile calendar app or not + */ + @SuppressLint("Range") + fun getCalendarId(calendarTitle: String): Long { + var calendarId = CALENDAR_DOES_NOT_EXIST + val projection = arrayOf( + CalendarContract.Calendars._ID, + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.NAME + ) + val calendarContentResolver = context.contentResolver + val cursor = calendarContentResolver.query( + CalendarContract.Calendars.CONTENT_URI, projection, + CalendarContract.Calendars.ACCOUNT_NAME + "=? and (" + + CalendarContract.Calendars.NAME + "=? or " + + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + "=?)", arrayOf( + accountName, calendarTitle, + calendarTitle + ), null + ) + if (cursor?.moveToFirst() == true) { + if (cursor.getString(cursor.getColumnIndex(CalendarContract.Calendars.NAME)) + .equals(calendarTitle) + ) { + calendarId = + cursor.getInt(cursor.getColumnIndex(CalendarContract.Calendars._ID)).toLong() + } + } + cursor?.close() + return calendarId + } + + /** + * Method to add important dates of course as calendar event into calendar of mobile app + */ + fun addEventsIntoCalendar( + calendarId: Long, + courseId: String, + courseName: String, + courseDateBlock: CourseDateBlock + ) { + val date = courseDateBlock.date.toCalendar() + // start time of the event, adjusted 1 hour earlier for a 1-hour duration + val startMillis: Long = date.timeInMillis - TimeUnit.HOURS.toMillis(1) + // end time of the event added to the calendar + val endMillis: Long = date.timeInMillis + + val values = ContentValues().apply { + put(CalendarContract.Events.DTSTART, startMillis) + put(CalendarContract.Events.DTEND, endMillis) + put( + CalendarContract.Events.TITLE, + "${resourceManager.getString(R.string.course_assignment_due_tag)} : $courseName" + ) + put( + CalendarContract.Events.DESCRIPTION, + getEventDescription( + courseId = courseId, + courseDateBlock = courseDateBlock, + isDeeplinkEnabled = corePreferences.appConfig.courseDatesCalendarSync.isDeepLinkEnabled + ) + ) + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + } + val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) + uri?.let { addReminderToEvent(uri = it) } + } + + /** + * Method to generate & add deeplink into event description + * + * @return event description with deeplink for assignment block else block title + */ + private fun getEventDescription( + courseId: String, + courseDateBlock: CourseDateBlock, + isDeeplinkEnabled: Boolean + ): String { + val eventDescription = courseDateBlock.title + // The following code for branch and deep links will be enabled after implementation + /* + if (isDeeplinkEnabled && !TextUtils.isEmpty(courseDateBlock.blockId)) { + val metaData = ContentMetadata() + .addCustomMetadata(DeepLink.Keys.SCREEN_NAME, Screen.COURSE_COMPONENT) + .addCustomMetadata(DeepLink.Keys.COURSE_ID, courseId) + .addCustomMetadata(DeepLink.Keys.COMPONENT_ID, courseDateBlock.blockId) + + val branchUniversalObject = BranchUniversalObject() + .setCanonicalIdentifier("${Screen.COURSE_COMPONENT}\n${courseDateBlock.blockId}") + .setTitle(courseDateBlock.title) + .setContentDescription(courseDateBlock.title) + .setContentMetadata(metaData) + + val linkProperties = LinkProperties() + .addControlParameter("\$desktop_url", courseDateBlock.link) + + eventDescription += "\n" + branchUniversalObject.getShortUrl(context, linkProperties) + } + */ + return eventDescription + } + + /** + * Method to add a reminder to the given calendar events + * + * @param uri Calendar event Uri + */ + private fun addReminderToEvent(uri: Uri) { + val eventId: Long? = uri.lastPathSegment?.toLong() + logger.d { "Event ID $eventId" } + + // Adding reminder on the start of event + val eventValues = ContentValues().apply { + put(CalendarContract.Reminders.MINUTES, 0) + put(CalendarContract.Reminders.EVENT_ID, eventId) + put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) + } + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) + // Adding reminder 24 hours before the event get started + eventValues.apply { + put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(1)) + } + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) + // Adding reminder 48 hours before the event get started + eventValues.apply { + put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(2)) + } + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) + } + + /** + * Method to query the events for the given calendar id + * + * @param calendarId calendarId to query the events + * + * @return [Cursor] + * + * */ + private fun getCalendarEvents(calendarId: Long): Cursor? { + val calendarContentResolver = context.contentResolver + val projection = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.DTEND, + CalendarContract.Events.DESCRIPTION + ) + val selection = CalendarContract.Events.CALENDAR_ID + "=?" + return calendarContentResolver.query( + CalendarContract.Events.CONTENT_URI, + projection, + selection, + arrayOf(calendarId.toString()), + null + ) + } + + /** + * Method to compare the calendar events with course dates + * @return true if the events are the same as calendar dates otherwise false + */ + @SuppressLint("Range") + private fun compareEvents( + calendarId: Long, + courseDateBlocks: List + ): Boolean { + val cursor = getCalendarEvents(calendarId) ?: return false + + val datesList = ArrayList(courseDateBlocks) + val dueDateColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DTEND) + val descriptionColumnIndex = cursor.getColumnIndex(CalendarContract.Events.DESCRIPTION) + + while (cursor.moveToNext()) { + val dueDateInMillis = cursor.getLong(dueDateColumnIndex) + + val description = cursor.getString(descriptionColumnIndex) + if (description != null) { + val matchedDate = datesList.find { unit -> + description.contains(unit.title, ignoreCase = true) + } + + matchedDate?.let { unit -> + if (unit.date.toCalendar().timeInMillis == dueDateInMillis) { + datesList.remove(unit) + } else { + // If any single value isn't matched, return false + cursor.close() + return false + } + } + } + } + + cursor.close() + return datesList.isEmpty() + } + + /** + * Method to delete the course calendar from the mobile calendar app + */ + fun deleteCalendar(calendarId: Long) { + context.contentResolver.delete( + Uri.parse("content://com.android.calendar/calendars/$calendarId"), + null, + null + ) + } + + /** + * Helper method used to return a URI for use with a sync adapter (how an application and a + * sync adapter access the Calendar Provider) + * + * @param uri URI to access the calendar + * @param account Name of the calendar owner + * + * @return URI of the calendar + * + */ + private fun asSyncAdapter(uri: Uri, account: String): Uri? { + return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.SyncState.ACCOUNT_NAME, account) + .appendQueryParameter( + CalendarContract.SyncState.ACCOUNT_TYPE, + CalendarContract.ACCOUNT_TYPE_LOCAL + ).build() + } + + fun openCalendarApp() { + val builder: Uri.Builder = CalendarContract.CONTENT_URI.buildUpon() + .appendPath("time") + ContentUris.appendId(builder, Calendar.getInstance().timeInMillis) + val intent = Intent(Intent.ACTION_VIEW).setData(builder.build()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + /** + * Helper method used to check that the calendar if outdated for the course or not + * + * @param calendarTitle Title for the course Calendar + * @param courseDateBlocks Course dates events + * + * @return Calendar Id if Calendar is outdated otherwise -1 or CALENDAR_DOES_NOT_EXIST + * + */ + fun isCalendarOutOfDate( + calendarTitle: String, + courseDateBlocks: List + ): Long { + if (isCalendarExists(calendarTitle)) { + val calendarId = getCalendarId(calendarTitle) + if (compareEvents(calendarId, courseDateBlocks).not()) { + return calendarId + } + } + return CALENDAR_DOES_NOT_EXIST + } + + /** + * Method to get the current user account as the Calendar owner + * + * @return calendar owner account or "local_user" + */ + private fun getUserAccountForSync(): String { + return corePreferences.user?.email ?: LOCAL_USER + } + + /** + * Method to create the Calendar title for the platform against the course + * + * @param courseName Name of the course for that creating the Calendar events. + * + * @return title of the Calendar against the course + */ + fun getCourseCalendarTitle(courseName: String): String { + return "${resourceManager.getString(id = CoreR.string.platform_name)} - $courseName" + } + + companion object { + const val CALENDAR_DOES_NOT_EXIST = -1L + private const val TAG = "CalendarManager" + private const val LOCAL_USER = "local_user" + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt new file mode 100644 index 000000000..59be5999b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialog.kt @@ -0,0 +1,228 @@ +package org.openedx.course.presentation.calendarsync + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.AlertDialog +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.presentation.global.app_upgrade.TransparentTextButton +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.course.R +import androidx.compose.ui.window.DialogProperties as AlertDialogProperties +import org.openedx.core.R as CoreR + +@Composable +fun CalendarSyncDialog( + syncDialogType: CalendarSyncDialogType, + calendarTitle: String, + syncDialogAction: (CalendarSyncDialogType) -> Unit, + dismissSyncDialog: () -> Unit, +) { + when (syncDialogType) { + CalendarSyncDialogType.SYNC_DIALOG, + CalendarSyncDialogType.UN_SYNC_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = stringResource(syncDialogType.titleResId), + message = stringResource(syncDialogType.messageResId, calendarTitle), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) } + ), + onDismiss = dismissSyncDialog, + ) + } + + CalendarSyncDialogType.PERMISSION_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = stringResource( + syncDialogType.titleResId, + stringResource(CoreR.string.platform_name) + ), + message = stringResource( + syncDialogType.messageResId, + stringResource(CoreR.string.platform_name), + stringResource(CoreR.string.platform_name) + ), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) } + ), + onDismiss = dismissSyncDialog + ) + } + + CalendarSyncDialogType.EVENTS_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = "", + message = stringResource(syncDialogType.messageResId, calendarTitle), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) }, + ), + onDismiss = dismissSyncDialog + ) + } + + CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { + CalendarAlertDialog( + dialogProperties = DialogProperties( + title = stringResource(syncDialogType.titleResId, calendarTitle), + message = stringResource(syncDialogType.messageResId), + positiveButton = stringResource(syncDialogType.positiveButtonResId), + negativeButton = stringResource(syncDialogType.negativeButtonResId), + positiveAction = { syncDialogAction(syncDialogType) }, + negativeAction = { syncDialogAction(CalendarSyncDialogType.UN_SYNC_DIALOG) } + ), + onDismiss = dismissSyncDialog + ) + } + + CalendarSyncDialogType.LOADING_DIALOG -> { + SyncDialog() + } + + CalendarSyncDialogType.NONE -> { + } + } +} + +@Composable +private fun CalendarAlertDialog(dialogProperties: DialogProperties, onDismiss: () -> Unit) { + AlertDialog( + modifier = Modifier.background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ), + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.background, + + properties = AlertDialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + onDismissRequest = onDismiss, + + title = dialogProperties.title.takeIfNotEmpty()?.let { + @Composable { + Text( + text = dialogProperties.title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + }, + text = { + Text( + text = dialogProperties.message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + confirmButton = { + TransparentTextButton( + text = dialogProperties.positiveButton + ) { + onDismiss() + dialogProperties.positiveAction.invoke() + } + }, + dismissButton = { + TransparentTextButton( + text = dialogProperties.negativeButton + ) { + onDismiss() + dialogProperties.negativeAction.invoke() + } + }, + ) +} + +@Composable +private fun SyncDialog() { + Dialog( + onDismissRequest = { }, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + content = { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape), + shape = MaterialTheme.appShapes.cardShape, + color = MaterialTheme.appColors.background, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(id = R.string.course_title_syncing_calendar), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } + } + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun CalendarSyncDialogsPreview( + @PreviewParameter(CalendarSyncDialogTypeProvider::class) dialogType: CalendarSyncDialogType +) { + OpenEdXTheme { + CalendarSyncDialog( + syncDialogType = dialogType, + calendarTitle = "Hello to OpenEdx", + syncDialogAction = {}, + dismissSyncDialog = {}, + ) + } +} + +private class CalendarSyncDialogTypeProvider : PreviewParameterProvider { + override val values = CalendarSyncDialogType.values().dropLast(1).asSequence() +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt new file mode 100644 index 000000000..57d6c0dac --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncDialogType.kt @@ -0,0 +1,45 @@ +package org.openedx.course.presentation.calendarsync + +import org.openedx.course.R +import org.openedx.core.R as CoreR + +enum class CalendarSyncDialogType( + val titleResId: Int = 0, + val messageResId: Int = 0, + val positiveButtonResId: Int = 0, + val negativeButtonResId: Int = 0, +) { + SYNC_DIALOG( + titleResId = R.string.course_title_add_course_calendar, + messageResId = R.string.course_message_add_course_calendar, + positiveButtonResId = CoreR.string.core_ok, + negativeButtonResId = CoreR.string.core_cancel + ), + UN_SYNC_DIALOG( + titleResId = R.string.course_title_remove_course_calendar, + messageResId = R.string.course_message_remove_course_calendar, + positiveButtonResId = R.string.course_label_remove, + negativeButtonResId = CoreR.string.core_cancel + ), + PERMISSION_DIALOG( + titleResId = R.string.course_title_request_calendar_permission, + messageResId = R.string.course_message_request_calendar_permission, + positiveButtonResId = CoreR.string.core_ok, + negativeButtonResId = R.string.course_label_do_not_allow + ), + EVENTS_DIALOG( + messageResId = R.string.course_message_course_calendar_added, + positiveButtonResId = R.string.course_label_view_events, + negativeButtonResId = R.string.course_label_done + ), + OUT_OF_SYNC_DIALOG( + titleResId = R.string.course_title_calendar_out_of_date, + messageResId = R.string.course_message_calendar_out_of_date, + positiveButtonResId = R.string.course_label_update_now, + negativeButtonResId = R.string.course_label_remove_course_calendar, + ), + LOADING_DIALOG( + titleResId = R.string.course_title_syncing_calendar + ), + NONE; +} diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt new file mode 100644 index 000000000..24d2212e2 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/CalendarSyncUIState.kt @@ -0,0 +1,14 @@ +package org.openedx.course.presentation.calendarsync + +import org.openedx.core.domain.model.CourseDateBlock +import java.util.concurrent.atomic.AtomicReference + +data class CalendarSyncUIState( + val isCalendarSyncEnabled: Boolean = false, + val calendarTitle: String = "", + val courseDates: List = listOf(), + val dialogType: CalendarSyncDialogType = CalendarSyncDialogType.NONE, + val isSynced: Boolean = false, + val checkForOutOfSync: AtomicReference = AtomicReference(false), + val uiMessage: AtomicReference = AtomicReference(""), +) diff --git a/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt b/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt new file mode 100644 index 000000000..cefded76c --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/calendarsync/DialogProperties.kt @@ -0,0 +1,10 @@ +package org.openedx.course.presentation.calendarsync + +data class DialogProperties( + val title: String, + val message: String, + val positiveButton: String, + val negativeButton: String, + val positiveAction: () -> Unit, + val negativeAction: () -> Unit = {}, +) diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 936e75294..a5f22084b 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -2,6 +2,11 @@ package org.openedx.course.presentation.container import android.os.Bundle import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -11,10 +16,14 @@ import com.google.android.material.tabs.TabLayoutMediator import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.calendarsync.CalendarSyncDialog +import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.container.CourseContainerTab import org.openedx.course.presentation.dates.CourseDatesFragment import org.openedx.course.presentation.handouts.HandoutsFragment @@ -37,6 +46,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private var adapter: CourseContainerAdapter? = null + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { isGranted -> + if (!isGranted.containsValue(false)) { + viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.SYNC_DIALOG) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.preloadCourseStructure() @@ -47,6 +64,9 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupToolbar(viewModel.courseName) + if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { + setUpCourseCalendar() + } observe() } @@ -109,7 +129,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) addFragment( Tabs.DATES, - CourseDatesFragment.newInstance(viewModel.courseId, viewModel.isSelfPaced) + CourseDatesFragment.newInstance( + viewModel.courseId, + viewModel.courseName, + viewModel.isSelfPaced + ) ) addFragment( Tabs.HANDOUTS, @@ -141,6 +165,69 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } + private fun setUpCourseCalendar() { + binding.composeContainer.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val syncState by viewModel.calendarSyncUIState.collectAsState() + + LaunchedEffect(key1 = syncState.checkForOutOfSync) { + if (syncState.isCalendarSyncEnabled && syncState.checkForOutOfSync.get()) { + viewModel.checkIfCalendarOutOfDate() + } + } + + LaunchedEffect(syncState.uiMessage.get()) { + syncState.uiMessage.get().takeIfNotEmpty()?.let { + Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() + syncState.uiMessage.set("") + } + } + + CalendarSyncDialog( + syncDialogType = syncState.dialogType, + calendarTitle = syncState.calendarTitle, + syncDialogAction = { dialog -> + when (dialog) { + CalendarSyncDialogType.SYNC_DIALOG -> { + viewModel.addOrUpdateEventsInCalendar( + updatedEvent = false, + ) + } + + CalendarSyncDialogType.UN_SYNC_DIALOG -> { + viewModel.deleteCourseCalendar() + } + + CalendarSyncDialogType.PERMISSION_DIALOG -> { + permissionLauncher.launch(viewModel.calendarPermissions) + } + + CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { + viewModel.addOrUpdateEventsInCalendar( + updatedEvent = true, + ) + } + + CalendarSyncDialogType.EVENTS_DIALOG -> { + viewModel.openCalendarApp() + } + + CalendarSyncDialogType.LOADING_DIALOG, + CalendarSyncDialogType.NONE -> { + } + } + }, + dismissSyncDialog = { + viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) + } + ) + } + } + } + } + fun updateCourseStructure(withSwipeRefresh: Boolean) { viewModel.updateData(withSwipeRefresh) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index f0d9a9507..886c63319 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -1,33 +1,53 @@ package org.openedx.course.presentation.container +import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel -import org.openedx.core.R import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +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.calendarsync.CalendarManager +import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType +import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import java.util.Date +import java.util.concurrent.atomic.AtomicReference +import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, private val config: Config, private val interactor: CourseInteractor, + private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, private val notifier: CourseNotifier, private val networkConnection: NetworkConnection, - private val analytics: CourseAnalytics + private val analytics: CourseAnalytics, + private val corePreferences: CorePreferences, + private val coursePreferences: CoursePreferences, ) : BaseViewModel() { val isCourseTopTabBarEnabled get() = config.isCourseTopTabBarEnabled() @@ -48,12 +68,39 @@ class CourseContainerViewModel( val isSelfPaced: Boolean get() = _isSelfPaced + val calendarPermissions: Array + get() = calendarManager.permissions + + private val _calendarSyncUIState = MutableStateFlow( + CalendarSyncUIState( + isCalendarSyncEnabled = isCalendarSyncEnabled(), + calendarTitle = calendarManager.getCourseCalendarTitle(courseName), + courseDates = emptyList(), + dialogType = CalendarSyncDialogType.NONE, + checkForOutOfSync = AtomicReference(false), + uiMessage = AtomicReference(""), + ) + ) + val calendarSyncUIState: StateFlow = + _calendarSyncUIState.asStateFlow() + init { viewModelScope.launch { notifier.notifier.collect { event -> if (event is CourseCompletionSet) { updateData(false) } + + if (event is CreateCalendarSyncEvent) { + _calendarSyncUIState.update { + val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) + it.copy( + courseDates = event.courseDates, + dialogType = dialogType, + checkForOutOfSync = AtomicReference(event.checkOutOfSync) + ) + } + } } } } @@ -80,10 +127,10 @@ class CourseContainerViewModel( } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(CoreR.string.core_error_no_connection) } else { _errorMessage.value = - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(CoreR.string.core_error_unknown_error) } } _showProgress.value = false @@ -98,10 +145,10 @@ class CourseContainerViewModel( } catch (e: Exception) { if (e.isInternetError()) { _errorMessage.value = - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(CoreR.string.core_error_no_connection) } else { _errorMessage.value = - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(CoreR.string.core_error_unknown_error) } } _showProgress.value = false @@ -119,6 +166,122 @@ class CourseContainerViewModel( } } + fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { + val currentState = _calendarSyncUIState.value + if (currentState.dialogType != dialogType) { + _calendarSyncUIState.value = currentState.copy(dialogType = dialogType) + } + } + + fun addOrUpdateEventsInCalendar( + updatedEvent: Boolean, + ) { + setCalendarSyncDialogType(CalendarSyncDialogType.LOADING_DIALOG) + + val startSyncTime = TimeUtils.getCurrentTime() + val calendarId = getCalendarId() + + if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { + setUiMessage(R.string.course_snackbar_course_calendar_error) + setCalendarSyncDialogType(CalendarSyncDialogType.NONE) + + return + } + + viewModelScope.launch(Dispatchers.IO) { + val courseDates = _calendarSyncUIState.value.courseDates + if (courseDates.isNotEmpty()) { + courseDates.forEach { courseDateBlock -> + calendarManager.addEventsIntoCalendar( + calendarId = calendarId, + courseId = courseId, + courseName = courseName, + courseDateBlock = courseDateBlock + ) + } + } + val elapsedSyncTime = TimeUtils.getCurrentTime() - startSyncTime + val delayRemaining = maxOf(0, 1000 - elapsedSyncTime) + + // Ensure minimum 1s delay to prevent flicker for rapid event creation + if (delayRemaining > 0) { + delay(delayRemaining) + } + + setCalendarSyncDialogType(CalendarSyncDialogType.NONE) + updateCalendarSyncState() + + if (updatedEvent) { + setUiMessage(R.string.course_snackbar_course_calendar_updated) + } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { + setUiMessage(R.string.course_snackbar_course_calendar_added) + } else { + coursePreferences.setCalendarSyncEventsDialogShown(courseName) + setCalendarSyncDialogType(CalendarSyncDialogType.EVENTS_DIALOG) + } + } + } + + private fun updateCalendarSyncState() { + viewModelScope.launch { + val isCalendarSynced = calendarManager.isCalendarExists( + calendarTitle = _calendarSyncUIState.value.calendarTitle + ) + notifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) + } + } + + fun checkIfCalendarOutOfDate() { + val courseDates = _calendarSyncUIState.value.courseDates + if (courseDates.isNotEmpty()) { + _calendarSyncUIState.value.checkForOutOfSync.set(false) + val outdatedCalendarId = calendarManager.isCalendarOutOfDate( + calendarTitle = _calendarSyncUIState.value.calendarTitle, + courseDateBlocks = courseDates + ) + if (outdatedCalendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { + setCalendarSyncDialogType(CalendarSyncDialogType.OUT_OF_SYNC_DIALOG) + } + } + } + + fun deleteCourseCalendar() { + if (calendarManager.hasPermissions()) { + viewModelScope.launch(Dispatchers.IO) { + val calendarId = getCalendarId() + if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { + calendarManager.deleteCalendar( + calendarId = calendarId, + ) + } + updateCalendarSyncState() + } + setUiMessage(R.string.course_snackbar_course_calendar_removed) + } + } + + fun openCalendarApp() { + calendarManager.openCalendarApp() + } + + private fun setUiMessage(@StringRes stringResId: Int) { + _calendarSyncUIState.update { + it.copy(uiMessage = AtomicReference(resourceManager.getString(stringResId))) + } + } + + private fun getCalendarId(): Long { + return calendarManager.createOrUpdateCalendar( + calendarTitle = _calendarSyncUIState.value.calendarTitle + ) + } + + private fun isCalendarSyncEnabled(): Boolean { + val calendarSync = corePreferences.appConfig.courseDatesCalendarSync + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || + (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + } + private fun courseTabClickedEvent() { analytics.courseTabClickedEvent(courseId, courseName) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index 98d4b4d62..39a342634 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -15,6 +15,7 @@ import androidx.compose.animation.shrinkVertically 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.IntrinsicSize @@ -37,6 +38,8 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowRight @@ -46,6 +49,7 @@ import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -88,18 +92,20 @@ import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize -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.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.R import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet +import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as coreR class CourseDatesFragment : Fragment() { @@ -107,11 +113,18 @@ class CourseDatesFragment : Fragment() { private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_COURSE_NAME, ""), requireArguments().getBoolean(ARG_IS_SELF_PACED, true), ) } private val router by inject() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewModel.updateAndFetchCalendarSyncState() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -121,9 +134,10 @@ class CourseDatesFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState() + val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) + val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() CourseDatesScreen( windowSize = windowSize, @@ -132,6 +146,7 @@ class CourseDatesFragment : Fragment() { refreshing = refreshing, isSelfPaced = viewModel.isSelfPaced, hasInternetConnection = viewModel.hasInternetConnection, + calendarSyncUIState = calendarSyncUIState, onReloadClick = { viewModel.getCourseDates() }, @@ -162,6 +177,9 @@ class CourseDatesFragment : Fragment() { } } }, + onCalendarSyncSwitch = { isChecked -> + viewModel.handleCalendarSyncState(isChecked) + }, ) } } @@ -173,15 +191,19 @@ class CourseDatesFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" + private const val ARG_COURSE_NAME = "courseName" private const val ARG_IS_SELF_PACED = "selfPaced" + fun newInstance( courseId: String, + courseName: String, isSelfPaced: Boolean, ): CourseDatesFragment { val fragment = CourseDatesFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, + ARG_COURSE_NAME to courseName, ARG_IS_SELF_PACED to isSelfPaced, ) return fragment @@ -193,15 +215,17 @@ class CourseDatesFragment : Fragment() { @Composable internal fun CourseDatesScreen( windowSize: WindowSize, - uiState: DatesUIState?, + uiState: DatesUIState, uiMessage: UIMessage?, refreshing: Boolean, isSelfPaced: Boolean, hasInternetConnection: Boolean, + calendarSyncUIState: CalendarSyncUIState, onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, onItemClick: (String) -> Unit, onSyncDates: () -> Unit, + onCalendarSyncSwitch: (Boolean) -> Unit = {}, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -240,7 +264,6 @@ internal fun CourseDatesScreen( modifier = Modifier .fillMaxSize() .padding(it) - .statusBarsInset() .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter ) { Surface( @@ -252,89 +275,98 @@ internal fun CourseDatesScreen( .fillMaxWidth() .pullRefresh(pullRefreshState) ) { - uiState?.let { - when (uiState) { - is DatesUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } + when (uiState) { + is DatesUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) } + } - is DatesUIState.Dates -> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - contentPadding = listBottomPadding - ) { - val courseBanner = uiState.courseDatesResult.courseBanner - val datesSection = uiState.courseDatesResult.datesSection - - if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { - item { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - modifier = Modifier.padding(bottom = 16.dp), - banner = courseBanner, - resetDates = onSyncDates, - ) - } else { - CourseDatesBanner( - modifier = Modifier.padding(bottom = 16.dp), - banner = courseBanner, - resetDates = onSyncDates - ) - } + is DatesUIState.Dates -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = listBottomPadding + ) { + val courseBanner = uiState.courseDatesResult.courseBanner + val datesSection = uiState.courseDatesResult.datesSection + + if (calendarSyncUIState.isCalendarSyncEnabled) { + item { + CalendarSyncCard( + modifier = Modifier.padding(top = 24.dp), + checked = calendarSyncUIState.isSynced, + onCalendarSync = onCalendarSyncSwitch + ) + } + } + + if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { + item { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + modifier = Modifier.padding(top = 16.dp), + banner = courseBanner, + resetDates = onSyncDates, + ) + } else { + CourseDatesBanner( + modifier = Modifier.padding(top = 16.dp), + banner = courseBanner, + resetDates = onSyncDates + ) } } + } - // Handle DatesSection.COMPLETED separately - datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> + // Handle DatesSection.COMPLETED separately + datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> + item { + ExpandableView( + sectionKey = DatesSection.COMPLETED, + sectionDates = section, + onItemClick = onItemClick, + ) + } + } + + // Handle other sections + val sectionsKey = + datesSection.keys.minus(DatesSection.COMPLETED).toList() + sectionsKey.forEach { sectionKey -> + datesSection[sectionKey]?.isNotEmptyThenLet { section -> item { - ExpandableView( - sectionKey = DatesSection.COMPLETED, + CourseDateBlockSection( + sectionKey = sectionKey, sectionDates = section, onItemClick = onItemClick, ) } } - - // Handle other sections - val sectionsKey = - datesSection.keys.minus(DatesSection.COMPLETED).toList() - sectionsKey.forEach { sectionKey -> - datesSection[sectionKey]?.isNotEmptyThenLet { section -> - item { - CourseDateBlockSection( - sectionKey = sectionKey, - sectionDates = section, - onItemClick = onItemClick, - ) - } - } - } } } + } - DatesUIState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.course_dates_unavailable_message), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - textAlign = TextAlign.Center - ) - } + DatesUIState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.course_dates_unavailable_message), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) } } } + PullRefreshIndicator( refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) ) @@ -357,6 +389,68 @@ internal fun CourseDatesScreen( } } +@Composable +fun CalendarSyncCard( + modifier: Modifier = Modifier, + checked: Boolean, + onCalendarSync: (Boolean) -> Unit, +) { + val cardModifier = modifier + .background( + MaterialTheme.appColors.cardViewBackground, + MaterialTheme.appShapes.material.medium + ) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.material.medium + ) + .padding(16.dp) + + Column(modifier = cardModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Icon( + painter = painterResource(id = R.drawable.course_ic_calenday_sync), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Text( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + .weight(1f), + text = stringResource(id = R.string.course_header_sync_to_calendar), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Switch( + checked = checked, + onCheckedChange = onCalendarSync, + modifier = Modifier.size(48.dp), + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.primary, + checkedTrackColor = MaterialTheme.appColors.primary + ) + ) + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(40.dp), + text = stringResource(id = R.string.course_body_sync_to_calendar), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark, + ) + } +} + @Composable fun ExpandableView( sectionKey: DatesSection = DatesSection.NONE, @@ -382,6 +476,7 @@ fun ExpandableView( Box( modifier = Modifier .fillMaxWidth() + .padding(top = 16.dp) .background(MaterialTheme.appColors.cardViewBackground, MaterialTheme.shapes.medium) .border(0.75.dp, MaterialTheme.appColors.cardViewBorder, MaterialTheme.shapes.medium) ) { @@ -618,13 +713,15 @@ private fun CourseDatesScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, + refreshing = false, isSelfPaced = true, hasInternetConnection = true, - refreshing = false, - onSwipeRefresh = {}, + calendarSyncUIState = mockCalendarSyncUIState, onReloadClick = {}, + onSwipeRefresh = {}, onItemClick = {}, onSyncDates = {}, + onCalendarSyncSwitch = {}, ) } } @@ -638,13 +735,15 @@ private fun CourseDatesScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, + refreshing = false, isSelfPaced = true, hasInternetConnection = true, - refreshing = false, - onSwipeRefresh = {}, + calendarSyncUIState = mockCalendarSyncUIState, onReloadClick = {}, + onSwipeRefresh = {}, onItemClick = {}, onSyncDates = {}, + onCalendarSyncSwitch = {}, ) } } @@ -730,3 +829,9 @@ private val mockedResponse: LinkedHashMap> = ) ) ) + +val mockCalendarSyncUIState = CalendarSyncUIState( + isCalendarSyncEnabled = true, + isSynced = true, + checkForOutOfSync = AtomicReference() +) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 8d643de9d..2380dbab4 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -3,25 +3,40 @@ package org.openedx.course.presentation.dates import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel -import org.openedx.core.R import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType +import org.openedx.course.presentation.calendarsync.CalendarSyncUIState +import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, + var courseName: String, val isSelfPaced: Boolean, + private val notifier: CourseNotifier, private val interactor: CourseInteractor, + private val calendarManager: CalendarManager, private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, + private val corePreferences: CorePreferences, ) : BaseViewModel() { private val _uiState = MutableLiveData(DatesUIState.Loading) @@ -32,6 +47,16 @@ class CourseDatesViewModel( val uiMessage: LiveData get() = _uiMessage + private val _calendarSyncUIState = MutableStateFlow( + CalendarSyncUIState( + isCalendarSyncEnabled = isCalendarSyncEnabled(), + calendarTitle = calendarManager.getCourseCalendarTitle(courseName), + isSynced = false, + ) + ) + val calendarSyncUIState: StateFlow = + _calendarSyncUIState.asStateFlow() + private val _updating = MutableLiveData() val updating: LiveData get() = _updating @@ -41,6 +66,13 @@ class CourseDatesViewModel( init { getCourseDates() + viewModelScope.launch { + notifier.notifier.collect { event -> + if (event is CheckCalendarSyncEvent) { + _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } + } + } + } } fun getCourseDates(swipeToRefresh: Boolean = false) { @@ -59,14 +91,15 @@ class CourseDatesViewModel( _uiState.value = DatesUIState.Empty } else { _uiState.value = DatesUIState.Dates(datesResponse) + checkIfCalendarOutOfDate() } } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error)) } } _updating.value = false @@ -82,10 +115,10 @@ class CourseDatesViewModel( } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) } else { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error)) } onResetDates(false) } @@ -110,4 +143,58 @@ class CourseDatesViewModel( null } } + + fun handleCalendarSyncState(isChecked: Boolean) { + setCalendarSyncDialogType( + when { + isChecked && calendarManager.hasPermissions() -> CalendarSyncDialogType.SYNC_DIALOG + isChecked -> CalendarSyncDialogType.PERMISSION_DIALOG + else -> CalendarSyncDialogType.UN_SYNC_DIALOG + } + ) + } + + fun updateAndFetchCalendarSyncState(): Boolean { + val isCalendarSynced = calendarManager.isCalendarExists( + calendarTitle = _calendarSyncUIState.value.calendarTitle + ) + _calendarSyncUIState.update { it.copy(isSynced = isCalendarSynced) } + return isCalendarSynced + } + + private fun setCalendarSyncDialogType(dialog: CalendarSyncDialogType) { + val value = _uiState.value + if (value is DatesUIState.Dates) { + viewModelScope.launch { + notifier.send( + CreateCalendarSyncEvent( + courseDates = value.courseDatesResult.datesSection.values.flatten(), + dialogType = dialog.name, + checkOutOfSync = false, + ) + ) + } + } + } + + private fun checkIfCalendarOutOfDate() { + val value = _uiState.value + if (value is DatesUIState.Dates) { + viewModelScope.launch { + notifier.send( + CreateCalendarSyncEvent( + courseDates = value.courseDatesResult.datesSection.values.flatten(), + dialogType = CalendarSyncDialogType.NONE.name, + checkOutOfSync = true, + ) + ) + } + } + } + + private fun isCalendarSyncEnabled(): Boolean { + val calendarSync = corePreferences.appConfig.courseDatesCalendarSync + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || + (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 361e04be0..cf83fd041 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -13,7 +13,9 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError @@ -22,10 +24,12 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.R as courseR class CourseOutlineViewModel( @@ -169,17 +173,24 @@ class CourseOutlineViewModel( CourseComponentStatus("") } - val datesBannerInfo = if (networkConnection.isOnline()) { - interactor.getDatesBannerInfo(courseId) + val courseDatesResult = if (networkConnection.isOnline()) { + interactor.getCourseDates(courseId) } else { - CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false + CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ) ) } + val datesBannerInfo = courseDatesResult.courseBanner + + checkIfCalendarOutOfDate(courseDatesResult.datesSection.values.flatten()) + setBlocks(blocks) courseSubSections.clear() courseSubSectionUnit.clear() @@ -296,4 +307,16 @@ class CourseOutlineViewModel( analytics.verticalClickedEvent(courseId, courseTitle, blockId, blockName) } } -} \ No newline at end of file + + private fun checkIfCalendarOutOfDate(courseDates: List) { + viewModelScope.launch { + notifier.send( + CreateCalendarSyncEvent( + courseDates = courseDates, + dialogType = CalendarSyncDialogType.NONE.name, + checkOutOfSync = true, + ) + ) + } + } +} diff --git a/course/src/main/res/drawable/course_ic_calenday_sync.xml b/course/src/main/res/drawable/course_ic_calenday_sync.xml new file mode 100644 index 000000000..32a1bf361 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_calenday_sync.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/layout/fragment_course_container.xml b/course/src/main/res/layout/fragment_course_container.xml index 878087ee0..9990fd80d 100644 --- a/course/src/main/res/layout/fragment_course_container.xml +++ b/course/src/main/res/layout/fragment_course_container.xml @@ -58,4 +58,13 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - \ No newline at end of file + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 9b22e4c95..63e1555de 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -55,6 +55,41 @@ Course dates are not currently available. + + Sync to calendar + Automatically sync all deadlines and due dates for this course to your calendar. + + \“%s\” Would Like to Access Your Calendar + %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. + Don’t allow + + Add Course Dates to Calendar + Would you like to add \“%s\” dates to your calendar? \n\nYou can edit or remove your course dates at any time from your calendar or settings. + + Syncing calendar… + + \“%s\” has been added to your phone\'s calendar. + View Events + Done + + Remove Course Dates from Calendar + Would you like to remove the \“%s\” dates from your calendar? + Remove + + Your course calendar is out of date + Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. + Update Now + Remove Course Calendar + + Your course calendar has been added. + Your course calendar has been removed. + Your course calendar has been updated. + Error Adding Calendar, Please try later + + Assignment Due + + + Header image for %1$s Play video diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 06c6ebb3d..a060753ab 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -1,26 +1,41 @@ package org.openedx.course.presentation.container import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.R -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureUpdated +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.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date @@ -35,13 +50,32 @@ class CourseContainerViewModelTest { private val resourceManager = mockk() private val config = mockk() private val interactor = mockk() + private val calendarManager = mockk() private val networkConnection = mockk() private val notifier = spyk() private val analytics = mockk() + private val corePreferences = mockk() + private val coursePreferences = mockk() + private val openEdx = "OpenEdx" + private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val user = User( + id = 0, + username = "", + email = "", + name = "", + ) + private val appConfig = AppConfig( + CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) private val courseStructure = CourseStructure( root = "", blockData = listOf(), @@ -69,8 +103,13 @@ class CourseContainerViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { resourceManager.getString(id = R.string.platform_name) } returns openEdx every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { corePreferences.user } returns user + every { corePreferences.appConfig } returns appConfig + every { notifier.notifier } returns emptyFlow() + every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle } @After @@ -85,10 +124,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() @@ -110,10 +152,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() @@ -135,10 +180,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit @@ -160,10 +208,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit @@ -186,10 +237,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -210,10 +264,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) coEvery { interactor.preloadCourseStructure(any()) } throws Exception() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -234,10 +291,13 @@ class CourseContainerViewModelTest { "", config, interactor, + calendarManager, resourceManager, notifier, networkConnection, - analytics + analytics, + corePreferences, + coursePreferences, ) coEvery { interactor.preloadCourseStructure(any()) } returns Unit coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -250,4 +310,4 @@ class CourseContainerViewModelTest { assert(viewModel.showProgress.value == false) } -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index 6419ed2cd..df7becbc3 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -7,6 +7,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -21,15 +22,22 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType +import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date @@ -41,12 +49,31 @@ class CourseDatesViewModelTest { private val dispatcher = StandardTestDispatcher() private val resourceManager = mockk() + private val notifier = mockk() private val interactor = mockk() + private val calendarManager = mockk() private val networkConnection = mockk() + private val corePreferences = mockk() + private val openEdx = "OpenEdx" + private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val user = User( + id = 0, + username = "", + email = "", + name = "", + ) + private val appConfig = AppConfig( + CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) private val dateBlock = CourseDateBlock( complete = false, date = Date(), @@ -105,9 +132,15 @@ class CourseDatesViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { resourceManager.getString(id = R.string.platform_name) } returns openEdx every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { interactor.getCourseStructureFromCache() } returns courseStructure + every { corePreferences.user } returns user + every { corePreferences.appConfig } returns appConfig + every { notifier.notifier } returns emptyFlow() + every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle + coEvery { notifier.send(any()) } returns Unit } @After @@ -117,7 +150,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates no internet connection exception`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() advanceUntilIdle() @@ -133,7 +176,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates unknown exception`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws Exception() advanceUntilIdle() @@ -149,7 +202,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with internet`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult @@ -164,7 +227,17 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with EmptyList`() = runTest { - val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel( + "", + "", + true, + notifier, + interactor, + calendarManager, + networkConnection, + resourceManager, + corePreferences + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index 6f129106c..6683b9f3d 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -4,12 +4,21 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -20,10 +29,23 @@ import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config +import org.openedx.core.data.model.DateType import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DatesSection import org.openedx.core.module.DownloadWorkerController -import org.openedx.core.module.db.* +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -31,7 +53,7 @@ import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException -import java.util.* +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseOutlineViewModelTest { @@ -106,7 +128,7 @@ class CourseOutlineViewModelTest { ) ) - val courseStructure = CourseStructure( + private val courseStructure = CourseStructure( root = "", blockData = blocks, id = "id", @@ -130,6 +152,26 @@ class CourseOutlineViewModelTest { isSelfPaced = false ) + private val dateBlock = CourseDateBlock( + complete = false, + date = Date(), + dateType = DateType.TODAY_DATE, + description = "Mocked Course Date Description" + ) + private val mockDateBlocks = linkedMapOf( + Pair( + DatesSection.COMPLETED, + listOf(dateBlock, dateBlock) + ), + Pair( + DatesSection.PAST_DUE, + listOf(dateBlock, dateBlock) + ), + Pair( + DatesSection.TODAY, + listOf(dateBlock, dateBlock) + ) + ) private val mockCourseDatesBannerInfo = CourseDatesBannerInfo( missedDeadlines = true, missedGatedContent = false, @@ -137,6 +179,10 @@ class CourseOutlineViewModelTest { contentTypeGatingEnabled = false, hasEnded = true, ) + private val mockedCourseDatesResult = CourseDatesResult( + datesSection = mockDateBlocks, + courseBanner = mockCourseDatesBannerInfo, + ) private val downloadModel = DownloadModel( "id", @@ -156,6 +202,8 @@ class CourseOutlineViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload every { config.getApiHostURL() } returns "http://localhost:8000" + + coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult } @After @@ -237,7 +285,6 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { config.isCourseNestedListEnabled() } returns false - coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -316,7 +363,6 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { config.isCourseNestedListEnabled() } returns false - coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -389,7 +435,6 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false - coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -421,7 +466,6 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false - coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", diff --git a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt index 72cb9f380..c85390fa1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt @@ -18,8 +18,11 @@ class DashboardRepository( username = user?.username ?: "", page = page ) + preferencesManager.appConfig = result.configs.mapToDomain() + if (page == 1) dao.clearCachedData() - dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() }.toTypedArray()) + dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() } + .toTypedArray()) return result.enrollments.mapToDomain() } @@ -27,4 +30,4 @@ class DashboardRepository( val list = dao.readAllData() return list.map { it.mapToDomain() } } -} \ No newline at end of file +}