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 3c8ea881e..1cc4c9662 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 @@ -8,6 +8,7 @@ import org.openedx.core.data.storage.CalendarPreferences 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.CalendarType import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings import org.openedx.core.system.CalendarManager @@ -69,6 +70,7 @@ class PreferencesManager(context: Context) : override fun clearCalendarPreferences() { sharedPreferences.edit().apply { remove(CALENDAR_ID) + remove(CALENDAR_TYPE) remove(IS_CALENDAR_SYNC_ENABLED) remove(HIDE_INACTIVE_COURSES) }.apply() @@ -104,6 +106,17 @@ class PreferencesManager(context: Context) : } get() = getLong(CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) + override var calendarType: CalendarType + set(value) { + saveString(CALENDAR_TYPE, value.name) + } + get() { + val storedType = getString(CALENDAR_TYPE, CalendarType.LOCAL.name) + return runCatching { + CalendarType.valueOf(storedType) + }.getOrDefault(CalendarType.LOCAL) + } + override var user: User? set(value) { val userJson = Gson().toJson(value) @@ -234,6 +247,7 @@ class PreferencesManager(context: Context) : private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" private const val APP_CONFIG = "app_config" private const val CALENDAR_ID = "CALENDAR_ID" + private const val CALENDAR_TYPE = "CALENDAR_TYPE" private const val RESET_APP_DIRECTORY = "reset_app_directory" private const val IS_CALENDAR_SYNC_ENABLED = "IS_CALENDAR_SYNC_ENABLED" private const val IS_RELATIVE_DATES_ENABLED = "IS_RELATIVE_DATES_ENABLED" diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index 4a0db245c..dee9bde38 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -37,6 +37,7 @@ import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CalendarType import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier @@ -91,6 +92,7 @@ class SignInViewModelTest { every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() every { calendarPreferences.calendarUser } returns "" + every { calendarPreferences.calendarType } returns CalendarType.LOCAL every { calendarPreferences.clearCalendarPreferences() } returns Unit coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit every { analytics.logScreenEvent(any(), any()) } returns Unit diff --git a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt index 91e38b35c..57724d8f0 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt @@ -1,8 +1,11 @@ package org.openedx.core.data.storage +import org.openedx.core.domain.model.CalendarType + interface CalendarPreferences { var calendarId: Long var calendarUser: String + var calendarType: CalendarType var isCalendarSyncEnabled: Boolean var isHideInactiveCourses: Boolean diff --git a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt index da84dba1a..91935ad61 100644 --- a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt +++ b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt @@ -22,6 +22,10 @@ class CalendarInteractor( return repository.getCourseCalendarEventsByIdFromCache(courseId) } + suspend fun getAllCourseCalendarEventsFromCache(): List { + return repository.getAllCourseCalendarEventsFromCache() + } + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { repository.deleteCourseCalendarEntitiesByIdFromCache(courseId) } diff --git a/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt b/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt new file mode 100644 index 000000000..327a9f651 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +enum class CalendarType { + LOCAL, + GOOGLE, +} diff --git a/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt b/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt new file mode 100644 index 000000000..ed40a38db --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class UserCalendar( + val id: Long, + val title: String, + val color: Int +) diff --git a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt index 686009b92..bc14efa88 100644 --- a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -21,6 +21,9 @@ interface CalendarDao { @Query("SELECT * FROM course_calendar_event_table WHERE course_id=:courseId") suspend fun readCourseCalendarEventsById(courseId: String): List + @Query("SELECT * FROM course_calendar_event_table") + suspend fun readAllCourseCalendarEvents(): List + @Query("DELETE FROM course_calendar_event_table") suspend fun clearCourseCalendarEventsCachedData() diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt index 726709d8a..f2555584d 100644 --- a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -30,6 +30,10 @@ class CalendarRepository( return calendarDao.readCourseCalendarEventsById(courseId).map { it.mapToDomain() } } + suspend fun getAllCourseCalendarEventsFromCache(): List { + return calendarDao.readAllCourseCalendarEvents().map { it.mapToDomain() } + } + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { calendarDao.deleteCourseCalendarEntitiesById(courseId) } diff --git a/core/src/main/java/org/openedx/core/system/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt index c1a393767..431112641 100644 --- a/core/src/main/java/org/openedx/core/system/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -3,6 +3,7 @@ package org.openedx.core.system 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 @@ -11,9 +12,13 @@ import androidx.core.content.ContextCompat import io.branch.indexing.BranchUniversalObject import io.branch.referral.util.ContentMetadata import io.branch.referral.util.LinkProperties +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CalendarData +import org.openedx.core.domain.model.CalendarType import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.UserCalendar import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar import java.util.TimeZone @@ -25,6 +30,8 @@ class CalendarManager( ) { private val logger = Logger(TAG) + private data class CalendarAccount(val name: String, val type: String) + val permissions = arrayOf( android.Manifest.permission.WRITE_CALENDAR, android.Manifest.permission.READ_CALENDAR @@ -33,16 +40,10 @@ class CalendarManager( 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 isCalendarExist(calendarId: Long): Boolean { val projection = arrayOf(CalendarContract.Calendars._ID) val selection = "${CalendarContract.Calendars._ID} = ?" @@ -62,109 +63,255 @@ class CalendarManager( return exists } - /** - * Create or update the calendar if it is already existed in mobile calendar app - */ fun createOrUpdateCalendar( calendarId: Long = CALENDAR_DOES_NOT_EXIST, calendarTitle: String, - calendarColor: Long + calendarColor: Long, + calendarType: CalendarType ): Long { - if (calendarId != CALENDAR_DOES_NOT_EXIST) { + if (calendarId != CALENDAR_DOES_NOT_EXIST && calendarType == CalendarType.LOCAL) { deleteCalendar(calendarId = calendarId) } return createCalendar( calendarTitle = calendarTitle, - calendarColor = calendarColor + calendarColor = calendarColor, + calendarType = calendarType ) } - /** - * Method to create a separate calendar based on course name in mobile calendar app - */ private fun createCalendar( calendarTitle: String, - calendarColor: Long + calendarColor: Long, + calendarType: CalendarType ): 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 + if (calendarType == CalendarType.GOOGLE) { + val existingGoogleCalendar = findOrCreateGoogleCalendar() + if (existingGoogleCalendar != CALENDAR_DOES_NOT_EXIST) { + return existingGoogleCalendar + } + } + + val calendarAccount = when (calendarType) { + CalendarType.LOCAL -> CalendarAccount(accountName, CalendarContract.ACCOUNT_TYPE_LOCAL) + CalendarType.GOOGLE -> getCalendarOwnerAccount() + } + val contentValues = ContentValues().apply { + put(CalendarContract.Calendars.NAME, calendarTitle) + put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, calendarTitle) + put(CalendarContract.Calendars.ACCOUNT_NAME, calendarAccount.name) + put(CalendarContract.Calendars.ACCOUNT_TYPE, calendarAccount.type) + put(CalendarContract.Calendars.OWNER_ACCOUNT, calendarAccount.name) + put( + CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, + CalendarContract.Calendars.CAL_ACCESS_ROOT + ) + put(CalendarContract.Calendars.SYNC_EVENTS, 1) + put(CalendarContract.Calendars.VISIBLE, 1) + put( + CalendarContract.Calendars.CALENDAR_COLOR, + calendarColor.toInt() + ) + } + + val calendarData = context.contentResolver.insert( + CalendarContract.Calendars.CONTENT_URI, + contentValues ) - contentValues.put(CalendarContract.Calendars.OWNER_ACCOUNT, accountName) - contentValues.put( - CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, - CalendarContract.Calendars.CAL_ACCESS_ROOT + + return calendarData?.lastPathSegment?.toLong()?.also { + logger.d { "Calendar ID $it created" } + } ?: CALENDAR_DOES_NOT_EXIST + } + + private fun findOrCreateGoogleCalendar(): Long { + return findPrimaryGoogleCalendar()?.also { + logger.d { "Using existing primary Google Calendar ID $it" } + } ?: findWritableGoogleCalendar()?.also { + logger.d { "Using existing Google Calendar ID $it" } + } ?: run { + logger.d { "No Google Calendar found, will create local calendar" } + CALENDAR_DOES_NOT_EXIST + } + } + + private fun findPrimaryGoogleCalendar(): Long? { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.IS_PRIMARY} = 1 AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1" + val selectionArgs = arrayOf(GOOGLE_ACCOUNT_TYPE) + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null ) - contentValues.put(CalendarContract.Calendars.SYNC_EVENTS, 1) - contentValues.put(CalendarContract.Calendars.VISIBLE, 1) - contentValues.put( - CalendarContract.Calendars.CALENDAR_COLOR, - calendarColor.toInt() + + return cursor?.use { + if (it.moveToFirst()) { + it.getLong(it.getColumnIndexOrThrow(CalendarContract.Calendars._ID)) + } else { + null + } + } + } + + private fun findWritableGoogleCalendar(): Long? { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1 AND " + + "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ?" + val selectionArgs = arrayOf( + GOOGLE_ACCOUNT_TYPE, + CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR.toString() ) - val creationUri: Uri? = asSyncAdapter( - Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), - accountName + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + "${CalendarContract.Calendars.IS_PRIMARY} DESC" ) - 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 cursor?.use { + if (it.moveToFirst()) { + it.getLong(it.getColumnIndexOrThrow(CalendarContract.Calendars._ID)) + } else { + null } } - return CALENDAR_DOES_NOT_EXIST } - /** - * Method to add important dates of course as calendar event into calendar of mobile app - */ + fun getGoogleCalendars(): List { + val projection = arrayOf( + CalendarContract.Calendars._ID, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.CALENDAR_COLOR + ) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1 AND " + + "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ?" + val selectionArgs = arrayOf( + GOOGLE_ACCOUNT_TYPE, + CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR.toString() + ) + val sortOrder = + "${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME} ASC" + + return try { + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + ) + + cursor?.use { + val idIndex = it.getColumnIndexOrThrow(CalendarContract.Calendars._ID) + val titleIndex = + it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME) + val colorIndex = it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR) + + buildList { + while (it.moveToNext()) { + add( + UserCalendar( + id = it.getLong(idIndex), + title = it.getString(titleIndex), + color = it.getInt(colorIndex) + ) + ) + } + } + } ?: emptyList() + } catch (e: SecurityException) { + logger.d { "Failed to load Google calendars: ${e.message}" } + emptyList() + } + } + + fun hasAlternativeCalendarApp(): Boolean { + val intent = Intent(Intent.ACTION_INSERT).setData(CalendarContract.Events.CONTENT_URI) + val activities = context.packageManager.queryIntentActivities(intent, 0) + return activities.any { it.activityInfo.packageName != GOOGLE_CALENDAR_PACKAGE } + } + fun addEventsIntoCalendar( calendarId: Long, courseId: String, courseName: String, courseDateBlock: CourseDateBlock ): Long { - 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, - "${courseDateBlock.title} : $courseName" - ) - put( - CalendarContract.Events.DESCRIPTION, - getEventDescription( - courseId = courseId, - courseDateBlock = courseDateBlock, - isDeeplinkEnabled = corePreferences.appConfig.courseDatesCalendarSync.isDeepLinkEnabled + repeat(EVENT_ATTEMPTS) { attemptIndex -> + val attemptNumber = attemptIndex + 1 + val eventId = + tryCreateEvent(calendarId, courseId, courseName, courseDateBlock, attemptNumber) + if (eventId != EVENT_DOES_NOT_EXIST) { + return eventId + } + if (attemptNumber < EVENT_ATTEMPTS) { + runBlocking { delay(ACTION_RETRY_DELAY) } + } + } + logger.d { "Failed to create event after $EVENT_ATTEMPTS attempts" } + return EVENT_DOES_NOT_EXIST + } + + private fun tryCreateEvent( + calendarId: Long, + courseId: String, + courseName: String, + courseDateBlock: CourseDateBlock, + attemptNumber: Int + ): Long { + return try { + val date = courseDateBlock.date.toCalendar() + val startMillis = date.timeInMillis - TimeUnit.HOURS.toMillis(1) + val endMillis = date.timeInMillis + + val values = ContentValues().apply { + put(CalendarContract.Events.DTSTART, startMillis) + put(CalendarContract.Events.DTEND, endMillis) + put( + CalendarContract.Events.TITLE, + "${courseDateBlock.title} : $courseName" ) - ) - put(CalendarContract.Events.CALENDAR_ID, calendarId) - put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + 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) + val insertedEventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST + + if (insertedEventId != EVENT_DOES_NOT_EXIST && isEventExists(insertedEventId)) { + uri?.let { addReminderToEvent(uri = it) } + logger.d { "Event created successfully: $insertedEventId (attempt $attemptNumber)" } + insertedEventId + } else { + logger.d { "Event creation failed, retrying... (attempt $attemptNumber/$EVENT_ATTEMPTS)" } + EVENT_DOES_NOT_EXIST + } + } catch (e: Exception) { + logger.d { "Event creation error on attempt $attemptNumber: ${e.message}" } + EVENT_DOES_NOT_EXIST } - val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) - uri?.let { addReminderToEvent(uri = it) } - val eventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST - return eventId } - /** - * 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, @@ -194,69 +341,169 @@ class CalendarManager( 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() + val eventId = uri.lastPathSegment?.toLong() ?: return 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)) + + listOf(0, TimeUnit.DAYS.toMinutes(1), TimeUnit.DAYS.toMinutes(2)).forEach { minutes -> + eventValues.put(CalendarContract.Reminders.MINUTES, minutes) + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - /** - * 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, + val calendarAccount = getCalendarAccountById(calendarId) + if (calendarAccount?.type == GOOGLE_ACCOUNT_TYPE) { + logger.d { "Cannot delete Google Calendar" } + return + } + + val calendarUri = ContentUris.withAppendedId( + CalendarContract.Calendars.CONTENT_URI, + calendarId + ) + val rowsDeleted = context.contentResolver.delete(calendarUri, null, null) + logger.d { + if (rowsDeleted > 0) { + "Calendar $calendarId deleted successfully" + } else { + "Calendar $calendarId deletion failed or calendar doesn't exist" + } + } + } + + suspend fun deleteEvents(eventIds: List) { + val deletedCount = eventIds.count { eventId -> + var deleted = false + var attempts = 0 + + while (!deleted && attempts < EVENT_ATTEMPTS) { + attempts++ + try { + deleted = deleteEventWithRetry(eventId) + if (!deleted && attempts < EVENT_ATTEMPTS) { + delay(ACTION_RETRY_DELAY) + } + } catch (e: Exception) { + logger.d { "Failed to delete event $eventId on attempt $attempts: ${e.message}" } + if (attempts < EVENT_ATTEMPTS) { + delay(ACTION_RETRY_DELAY) + } + } + } + + if (!deleted) { + logger.d { "Failed to delete event $eventId after $EVENT_ATTEMPTS attempts" } + } + + deleted + } + logger.d { "Successfully deleted $deletedCount out of ${eventIds.size} events" } + } + + private fun deleteEventWithRetry(eventId: Long): Boolean { + val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + val rows = context.contentResolver.delete(deleteUri, null, null) + + val deleted = if (rows > 0) { + val stillExists = isEventExists(eventId) + if (!stillExists) { + logger.d { "Event $eventId deleted successfully" } + true + } else { + logger.d { "Event $eventId deletion reported success but event still exists" } + false + } + } else { + val exists = isEventExists(eventId) + if (!exists) { + logger.d { "Event $eventId doesn't exist (already deleted)" } + true + } else { + logger.d { "Event $eventId deletion failed" } + false + } + } + return deleted + } + + private fun getCalendarOwnerAccount(): CalendarAccount { + return getSyncedAccountByType(GOOGLE_ACCOUNT_TYPE) + ?: getFirstSyncedAccount(excludeLocal = true) + ?: CalendarAccount(accountName, CalendarContract.ACCOUNT_TYPE_LOCAL) + } + + private fun getFirstSyncedAccount(excludeLocal: Boolean): CalendarAccount? { + val selection = buildString { + append("${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND ${CalendarContract.Calendars.VISIBLE} = 1") + if (excludeLocal) { + append(" AND ${CalendarContract.Calendars.ACCOUNT_TYPE} != ?") + } + } + val selectionArgs = if (excludeLocal) { + arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL) + } else { null + } + + return queryCalendarAccount(selection, selectionArgs) + } + + private fun getSyncedAccountByType(accountType: String): CalendarAccount? { + val selection = + "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1" + val selectionArgs = arrayOf(accountType) + + return queryCalendarAccount(selection, selectionArgs) + } + + private fun queryCalendarAccount( + selection: String, + selectionArgs: Array? + ): CalendarAccount? { + val projection = arrayOf( + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.ACCOUNT_TYPE, + CalendarContract.Calendars.IS_PRIMARY + ) + val sortOrder = "${CalendarContract.Calendars.IS_PRIMARY} DESC" + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder ) + + return cursor?.use { + if (it.moveToFirst()) { + val accountName = it.getString( + it.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_NAME) + ) + val accountType = it.getString( + it.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_TYPE) + ) + CalendarAccount(accountName, accountType) + } else { + 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() + private fun getCalendarAccountById(calendarId: Long): CalendarAccount? { + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + return queryCalendarAccount(selection, selectionArgs) } - /** - * 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 } @@ -279,8 +526,10 @@ class CalendarManager( return cursor?.use { if (it.moveToFirst()) { - val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) - val color = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) + val title = + it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) + val color = + it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) CalendarData( title = title, color = color @@ -294,17 +543,44 @@ class CalendarManager( fun deleteEvent(eventId: Long) { val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) val rows = context.contentResolver.delete(deleteUri, null, null) - if (rows > 0) { - logger.d { "Event deleted successfully" } - } else { - logger.d { "Event deletion failed" } + logger.d { + if (rows > 0) { + "Event deleted successfully" + } else { + "Event deletion failed" + } } } + private fun isEventExists(eventId: Long): Boolean { + if (eventId == EVENT_DOES_NOT_EXIST) return false + + val projection = arrayOf(CalendarContract.Events._ID) + val selection = "${CalendarContract.Events._ID} = ?" + val selectionArgs = arrayOf(eventId.toString()) + + val cursor = context.contentResolver.query( + CalendarContract.Events.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + return cursor?.use { + it.count > 0 + } ?: false + } + companion object { const val CALENDAR_DOES_NOT_EXIST = -1L const val EVENT_DOES_NOT_EXIST = -1L private const val TAG = "CalendarManager" private const val LOCAL_USER = "local_user" + private const val GOOGLE_ACCOUNT_TYPE = "com.google" + private const val GOOGLE_CALENDAR_PACKAGE = "com.google.android.calendar" + + private const val ACTION_RETRY_DELAY = 500L + private const val EVENT_ATTEMPTS = 3 } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt index 45ca74658..dcc31d04e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -124,10 +124,16 @@ class CalendarViewModel( } private fun getCalendarData() { - if (calendarManager.hasPermissions()) { - val calendarData = calendarManager.getCalendarData(calendarId = calendarPreferences.calendarId) - _uiState.update { it.copy(calendarData = calendarData) } + if (!calendarManager.hasPermissions()) return + + val calendarId = calendarPreferences.calendarId + if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { + _uiState.update { it.copy(calendarData = null) } + return } + + val calendarData = calendarManager.getCalendarData(calendarId = calendarId) + _uiState.update { it.copy(calendarData = calendarData) } } private fun updateSyncedCoursesCount() { diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt index 8a71410b1..4920360bc 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt @@ -18,9 +18,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,18 +59,28 @@ class DisableCalendarSyncDialogFragment : DialogFragment() { savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.setCancelable(false) + dialog?.setCanceledOnTouchOutside(false) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { val viewModel: DisableCalendarSyncDialogViewModel = koinViewModel() + val isDeleting by viewModel.deletionState.collectAsState() + + LaunchedEffect(isDeleting) { + if (isDeleting == DeletionState.DELETED) { + dismiss() + } + } + DisableCalendarSyncDialogView( calendarData = requireArguments().parcelable(ARG_CALENDAR_DATA), + isDeleting = isDeleting == DeletionState.DELETING, onCancelClick = { dismiss() }, onDisableSyncingClick = { viewModel.disableSyncingClick() - dismiss() } ) } @@ -93,13 +107,18 @@ class DisableCalendarSyncDialogFragment : DialogFragment() { private fun DisableCalendarSyncDialogView( modifier: Modifier = Modifier, calendarData: CalendarData?, + isDeleting: Boolean, onCancelClick: () -> Unit, onDisableSyncingClick: () -> Unit ) { val scrollState = rememberScrollState() DefaultDialogBox( modifier = modifier, - onDismissClick = onCancelClick + onDismissClick = { + if (!isDeleting) { + onCancelClick() + } + } ) { Column( modifier = Modifier @@ -159,23 +178,42 @@ private fun DisableCalendarSyncDialogView( style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textDark ) - OpenEdXOutlinedButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.profile_disable_syncing), - backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.primaryButtonBackground, - textColor = MaterialTheme.appColors.primaryButtonBackground, - onClick = { - onDisableSyncingClick() - } - ) - OpenEdXButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), - onClick = { - onCancelClick() + + if (isDeleting) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.appColors.primary + ) + Text( + text = stringResource(id = R.string.profile_deleting_events), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) } - ) + } else { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_disable_syncing), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onDisableSyncingClick() + } + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + onClick = { + onCancelClick() + } + ) + } } } } @@ -187,8 +225,9 @@ private fun DisableCalendarSyncDialogPreview() { OpenEdXTheme { DisableCalendarSyncDialogView( calendarData = CalendarData("calendar", Color.GREEN), + isDeleting = true, onCancelClick = { }, - onDisableSyncingClick = { } + onDisableSyncingClick = { }, ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt index b29c3394c..3d0cf94a8 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt @@ -1,9 +1,15 @@ package org.openedx.profile.presentation.calendar import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CalendarType import org.openedx.core.system.CalendarManager import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled @@ -16,12 +22,35 @@ class DisableCalendarSyncDialogViewModel( private val calendarInteractor: CalendarInteractor, ) : BaseViewModel() { + private val _deletionState = MutableStateFlow(null) + val deletionState: StateFlow = _deletionState.asStateFlow() + fun disableSyncingClick() { viewModelScope.launch { - calendarInteractor.clearCalendarCachedData() - calendarManager.deleteCalendar(calendarPreferences.calendarId) - calendarPreferences.clearCalendarPreferences() - calendarNotifier.send(CalendarSyncDisabled) + try { + withContext(NonCancellable) { + _deletionState.value = DeletionState.DELETING + val allEvents = calendarInteractor.getAllCourseCalendarEventsFromCache() + val eventIds = allEvents.map { it.eventId } + calendarManager.deleteEvents(eventIds) + _deletionState.value = DeletionState.DELETED + calendarInteractor.clearCalendarCachedData() + val calendarId = calendarPreferences.calendarId + if (calendarPreferences.calendarType == CalendarType.LOCAL) { + calendarManager.deleteCalendar(calendarId) + } + calendarPreferences.clearCalendarPreferences() + calendarNotifier.send(CalendarSyncDisabled) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _deletionState.value = null + } } } } + +enum class DeletionState { + DELETING, DELETED +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt index 857af17d0..162f0d10b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,6 +66,7 @@ import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.koin.androidx.compose.koinViewModel +import org.openedx.core.domain.model.UserCalendar import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -108,14 +111,24 @@ class NewCalendarDialogFragment : DialogFragment() { } } + val googleCalendars by viewModel.googleCalendars.collectAsState() + val showLocalCalendarSection by viewModel.showLocalCalendarSection.collectAsState() + NewCalendarDialog( - newCalendarDialogType = requireArguments().parcelable(ARG_DIALOG_TYPE) + newCalendarDialogType = requireArguments().parcelable( + ARG_DIALOG_TYPE + ) ?: NewCalendarDialogType.CREATE_NEW, + googleCalendars = googleCalendars, + showLocalCalendarSection = showLocalCalendarSection, onCancelClick = { dismiss() }, onBeginSyncingClick = { calendarTitle, calendarColor -> viewModel.createCalendar(calendarTitle, calendarColor) + }, + onGoogleCalendarClick = { + viewModel.syncWithGoogleCalendar(it) } ) } @@ -147,8 +160,11 @@ class NewCalendarDialogFragment : DialogFragment() { private fun NewCalendarDialog( modifier: Modifier = Modifier, newCalendarDialogType: NewCalendarDialogType, + googleCalendars: List, + showLocalCalendarSection: Boolean, onCancelClick: () -> Unit, - onBeginSyncingClick: (calendarTitle: String, calendarColor: CalendarColor) -> Unit + onBeginSyncingClick: (calendarTitle: String, calendarColor: CalendarColor) -> Unit, + onGoogleCalendarClick: (Long) -> Unit ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -162,6 +178,7 @@ private fun NewCalendarDialog( var calendarColor by rememberSaveable { mutableStateOf(CalendarColor.ACCENT) } + var selectedCalendar by remember { mutableStateOf(null) } DefaultDialogBox( modifier = modifier, onDismissClick = onCancelClick @@ -186,51 +203,199 @@ private fun NewCalendarDialog( Icon( modifier = Modifier .size(24.dp) - .clickable { - onCancelClick() - }, + .clickable { onCancelClick() }, imageVector = Icons.Default.Close, contentDescription = null, tint = MaterialTheme.appColors.primary ) } - CalendarTitleTextField( - onValueChanged = { - calendarTitle = it - } - ) - ColorDropdown( - onValueChanged = { - calendarColor = it + CalendarDropdown( + calendars = googleCalendars, + showLocalCalendarOption = showLocalCalendarSection, + selectedCalendar = selectedCalendar, + onLocalCalendarClick = { selectedCalendar = SelectedCalendar.Local }, + onGoogleCalendarClick = { + selectedCalendar = SelectedCalendar.Google(it) } ) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.profile_new_calendar_description), - style = MaterialTheme.appTypography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.appColors.textDark - ) + if (googleCalendars.isEmpty() && !showLocalCalendarSection) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_no_google_calendars), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textFieldHint + ) + } + if (selectedCalendar == SelectedCalendar.Local) { + LocalCalendarSection( + onCalendarTitleChange = { calendarTitle = it }, + onCalendarColorChange = { calendarColor = it }, + ) + } + if (selectedCalendar != null) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_new_calendar_description), + style = MaterialTheme.appTypography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.appColors.textDark + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_begin_syncing), + onClick = { + when (val selectedCalendar = selectedCalendar) { + is SelectedCalendar.Google -> { + onGoogleCalendarClick(selectedCalendar.calendar.id) + } + + SelectedCalendar.Local -> { + onBeginSyncingClick( + calendarTitle.ifEmpty { + NewCalendarDialogFragment.getDefaultCalendarTitle(context) + }, + calendarColor + ) + } + + else -> {} + } + } + ) + } OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), text = stringResource(id = CoreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, - onClick = { - onCancelClick() - } + onClick = onCancelClick ) - OpenEdXButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.profile_begin_syncing), - onClick = { - onBeginSyncingClick( - calendarTitle.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarTitle(context) }, - calendarColor + } + } +} + +@Composable +private fun LocalCalendarSection( + onCalendarTitleChange: (String) -> Unit, + onCalendarColorChange: (CalendarColor) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CalendarTitleTextField( + onValueChanged = onCalendarTitleChange + ) + ColorDropdown( + onValueChanged = onCalendarColorChange + ) + } +} + +@Composable +private fun CalendarDropdown( + calendars: List, + showLocalCalendarOption: Boolean, + selectedCalendar: SelectedCalendar?, + onLocalCalendarClick: () -> Unit, + onGoogleCalendarClick: (UserCalendar) -> Unit +) { + val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } + var dropdownWidth by remember { mutableStateOf(300.dp) } + + val selectedLabel = when (selectedCalendar) { + SelectedCalendar.Local -> stringResource(id = R.string.profile_local_calendar_option) + is SelectedCalendar.Google -> selectedCalendar.calendar.title + null -> stringResource(id = R.string.profile_select_calendar) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(MaterialTheme.appShapes.textFieldShape) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .onSizeChanged { dropdownWidth = with(density) { it.width.toDp() } } + .clickable { expanded = true }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + text = selectedLabel, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.bodyMedium + ) + Icon( + modifier = Modifier + .padding(end = 16.dp) + .rotate(if (expanded) 180f else 0f), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy(MaterialTheme.appShapes.textFieldShape) + ) { + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .height(180.dp) + .width(dropdownWidth) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .crop(vertical = 8.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + if (showLocalCalendarOption) { + CalendarOptionItem( + text = stringResource(id = R.string.profile_local_calendar_option), + contentColor = MaterialTheme.appColors.textDark + ) { + expanded = false + onLocalCalendarClick() + } + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider ) } - ) + + calendars.forEachIndexed { index, calendar -> + CalendarOptionItem( + text = calendar.title, + contentColor = MaterialTheme.appColors.textDark, + leadingColor = ComposeColor(calendar.color) + ) { + expanded = false + onGoogleCalendarClick(calendar) + } + if (index < calendars.lastIndex) { + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + } + } } } } @@ -421,6 +586,37 @@ private fun ColorCircle( ) } +@Composable +private fun CalendarOptionItem( + text: String, + contentColor: ComposeColor, + leadingColor: ComposeColor? = null, + onClick: () -> Unit +) { + DropdownMenuItem( + modifier = Modifier.background(MaterialTheme.appColors.background), + onClick = onClick + ) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + leadingColor?.let { ColorCircle(color = it) } + Text( + text = text, + style = MaterialTheme.appTypography.titleSmall, + color = contentColor + ) + } + } +} + +private sealed class SelectedCalendar { + object Local : SelectedCalendar() + data class Google(val calendar: UserCalendar) : SelectedCalendar() +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -428,8 +624,14 @@ private fun NewCalendarDialogPreview() { OpenEdXTheme { NewCalendarDialog( newCalendarDialogType = NewCalendarDialogType.CREATE_NEW, + googleCalendars = listOf( + UserCalendar(1, "Work", CalendarColor.BLUE.color.toInt()), + UserCalendar(2, "Personal", CalendarColor.GREEN.color.toInt()) + ), + showLocalCalendarSection = true, onCancelClick = { }, - onBeginSyncingClick = { _, _ -> } + onBeginSyncingClick = { _, _ -> }, + onGoogleCalendarClick = { } ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt index 20fbdbf23..eb95d1650 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt @@ -1,13 +1,20 @@ package org.openedx.profile.presentation.calendar import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.core.R import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CalendarType +import org.openedx.core.domain.model.UserCalendar import org.openedx.core.system.CalendarManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.calendar.CalendarCreated @@ -32,6 +39,28 @@ class NewCalendarDialogViewModel( val isSuccess: SharedFlow get() = _isSuccess.asSharedFlow() + private val _googleCalendars = MutableStateFlow>(emptyList()) + val googleCalendars: StateFlow> + get() = _googleCalendars.asStateFlow() + + private val _showLocalCalendarSection = + MutableStateFlow(calendarManager.hasAlternativeCalendarApp()) + val showLocalCalendarSection: StateFlow + get() = _showLocalCalendarSection.asStateFlow() + + init { + loadGoogleCalendars() + } + + private fun loadGoogleCalendars() { + viewModelScope.launch { + val calendars = withContext(Dispatchers.IO) { + calendarManager.getGoogleCalendars() + } + _googleCalendars.emit(calendars) + } + } + fun createCalendar( calendarTitle: String, calendarColor: CalendarColor, @@ -39,14 +68,19 @@ class NewCalendarDialogViewModel( viewModelScope.launch { if (networkConnection.isOnline()) { calendarInteractor.resetChecksums() + val currentCalendarType = calendarPreferences.calendarType val calendarId = calendarManager.createOrUpdateCalendar( - calendarId = calendarPreferences.calendarId, + calendarId = calendarPreferences.calendarId.takeIf { + currentCalendarType == CalendarType.LOCAL + } ?: CalendarManager.CALENDAR_DOES_NOT_EXIST, calendarTitle = calendarTitle, - calendarColor = calendarColor.color + calendarColor = calendarColor.color, + calendarType = CalendarType.LOCAL ) if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { calendarPreferences.calendarId = calendarId calendarPreferences.calendarUser = calendarManager.accountName + calendarPreferences.calendarType = CalendarType.LOCAL viewModelScope.launch { calendarNotifier.send(CalendarCreated) } @@ -59,4 +93,32 @@ class NewCalendarDialogViewModel( } } } + + fun syncWithGoogleCalendar(calendarId: Long) { + viewModelScope.launch { + if (!networkConnection.isOnline()) { + _uiMessage.emit(resourceManager.getString(R.string.core_error_no_connection)) + return@launch + } + + if (!calendarManager.isCalendarExist(calendarId)) { + _uiMessage.emit(resourceManager.getString(R.string.core_error_unknown_error)) + return@launch + } + + calendarInteractor.resetChecksums() + if (calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST && + calendarPreferences.calendarId != calendarId && + calendarPreferences.calendarType == CalendarType.LOCAL + ) { + calendarManager.deleteCalendar(calendarPreferences.calendarId) + } + + calendarPreferences.calendarId = calendarId + calendarPreferences.calendarUser = calendarManager.accountName + calendarPreferences.calendarType = CalendarType.GOOGLE + calendarNotifier.send(CalendarCreated) + _isSuccess.emit(true) + } + } } diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 1de55c683..d2ee6951e 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -69,9 +69,15 @@ Disable Calendar Sync Disabling calendar sync will delete the calendar “%1$s.” You can turn calendar sync back on at any time. Disable Syncing + Deleting events, please wait… No %1$s Courses No courses are currently being synced to your calendar. No courses match the current filter. Show full dates like “%1$s” + Select Google Calendar + Change Google Calendar + No Google calendars available + Select calendar + Local calendar