diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a3921ac64..831fe4a86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -114,6 +114,7 @@ + diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index e03d9f2cd..c12e23bf8 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -29,6 +29,7 @@ import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.utils.Logger +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.profile.presentation.ProfileRouter import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -50,6 +51,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private val whatsNewManager by inject() private val corePreferencesManager by inject() private val profileRouter by inject() + private val calendarSyncScheduler by inject() private val branchLogger = Logger(BRANCH_TAG) @@ -160,6 +162,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { viewModel.logoutUser.observe(this) { profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } + + calendarSyncScheduler.scheduleDailySync() } override fun onStart() { diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 99eb919dc..0b64fb94f 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -8,6 +8,7 @@ import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment +import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter @@ -46,6 +47,7 @@ import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment import org.openedx.profile.presentation.calendar.CalendarFragment +import org.openedx.profile.presentation.calendar.CoursesToSyncFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.manageaccount.ManageAccountFragment @@ -56,7 +58,7 @@ import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, DiscussionRouter, - ProfileRouter, AppUpgradeRouter, WhatsNewRouter { + ProfileRouter, AppUpgradeRouter, WhatsNewRouter, CalendarRouter { //region AuthRouter override fun navigateToMain( @@ -411,6 +413,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToCalendarSettings(fm: FragmentManager) { replaceFragmentWithBackStack(fm, CalendarFragment()) } + + override fun navigateToCoursesToSync(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, CoursesToSyncFragment()) + } //endregion fun getVisibleFragment(fm: FragmentManager): Fragment? { diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 20b3b0c97..43faf506f 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -104,7 +104,7 @@ class AppViewModel( if (System.currentTimeMillis() - logoutHandledAt > 5000) { if (event.isForced) { logoutHandledAt = System.currentTimeMillis() - preferencesManager.clear() + preferencesManager.clearCorePreferences() withContext(dispatcher) { room.clearAllTables() } 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 473340beb..ae36968d2 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 @@ -4,19 +4,21 @@ import android.content.Context import com.google.gson.Gson import org.openedx.app.BuildConfig import org.openedx.core.data.model.User +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.VideoQuality import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.replaceSpace +import org.openedx.core.system.CalendarManager 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, CoursePreferences { + WhatsNewPreferences, InAppReviewPreferences, CoursePreferences, CalendarPreferences { private val sharedPreferences = context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) @@ -37,7 +39,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getLong(key: String): Long = sharedPreferences.getLong(key, 0L) + private fun getLong(key: String, defValue: Long = 0): Long = sharedPreferences.getLong(key, defValue) private fun saveBoolean(key: String, value: Boolean) { sharedPreferences.edit().apply { @@ -49,7 +51,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences return sharedPreferences.getBoolean(key, defValue) } - override fun clear() { + override fun clearCorePreferences() { sharedPreferences.edit().apply { remove(ACCESS_TOKEN) remove(REFRESH_TOKEN) @@ -59,6 +61,14 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } + override fun clearCalendarPreferences() { + sharedPreferences.edit().apply { + remove(CALENDAR_ID) + remove(IS_CALENDAR_SYNC_ENABLED) + remove(HIDE_INACTIVE_COURSES) + }.apply() + } + override var accessToken: String set(value) { saveString(ACCESS_TOKEN, value) @@ -83,6 +93,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getLong(EXPIRES_IN) + override var calendarId: Long + set(value) { + saveLong(CALENDAR_ID, value) + } + get() = getLong(CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) + override var user: User? set(value) { val userJson = Gson().toJson(value) @@ -165,6 +181,24 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getBoolean(RESET_APP_DIRECTORY, true) + override var isCalendarSyncEnabled: Boolean + set(value) { + saveBoolean(IS_CALENDAR_SYNC_ENABLED, value) + } + get() = getBoolean(IS_CALENDAR_SYNC_ENABLED, true) + + override var calendarUser: String + set(value) { + saveString(CALENDAR_USER, value) + } + get() = getString(CALENDAR_USER) + + override var isHideInactiveCourses: Boolean + set(value) { + saveBoolean(HIDE_INACTIVE_COURSES, value) + } + get() = getBoolean(HIDE_INACTIVE_COURSES, true) + override fun setCalendarSyncEventsDialogShown(courseName: String) { saveBoolean(courseName.replaceSpace("_"), true) } @@ -186,6 +220,10 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences 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" + private const val CALENDAR_ID = "CALENDAR_ID" private const val RESET_APP_DIRECTORY = "reset_app_directory" + private const val IS_CALENDAR_SYNC_ENABLED = "IS_CALENDAR_SYNC_ENABLED" + private const val HIDE_INACTIVE_COURSES = "HIDE_INACTIVE_COURSES" + private const val CALENDAR_USER = "CALENDAR_USER" } } 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 9e3a1709d..795049d31 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -12,12 +12,13 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics -import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppRouter import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME +import org.openedx.app.room.DatabaseManager import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -25,9 +26,11 @@ import org.openedx.auth.presentation.sso.FacebookAuthHelper 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.CalendarRouter import org.openedx.core.ImageProcessor import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments +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.module.DownloadWorkerController @@ -48,7 +51,9 @@ import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.utils.FileUtil +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter @@ -62,11 +67,12 @@ import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.profile.ProfileNotifier import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.presentation.WhatsNewAnalytics +import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { @@ -77,11 +83,14 @@ 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 { CalendarManager(get(), get()) } + single { DatabaseManager(get(), get(), get(), get()) } + single { get() } single { ImageProcessor(get()) } @@ -98,6 +107,7 @@ val appModule = module { single { DownloadNotifier() } single { VideoNotifier() } single { DiscoveryNotifier() } + single { CalendarNotifier() } single { AppRouter() } single { get() } @@ -109,6 +119,7 @@ val appModule = module { single { get() } single { get() } single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } + single { get() } single { NetworkConnection(get()) } @@ -150,6 +161,11 @@ val appModule = module { room.downloadDao() } + single { + val room = get() + room.calendarDao() + } + single { FileDownloader() } @@ -184,4 +200,6 @@ val appModule = module { factory { OAuthHelper(get(), get(), get()) } factory { FileUtil(get()) } + + single { CalendarSyncScheduler(get()) } } 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 429d048b9..ae550922c 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,8 +12,10 @@ import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel import org.openedx.core.presentation.settings.video.VideoQualityViewModel +import org.openedx.core.repository.CalendarRepository import org.openedx.core.ui.WindowSize import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor @@ -58,6 +60,9 @@ import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel import org.openedx.profile.presentation.calendar.CalendarViewModel +import org.openedx.profile.presentation.calendar.CoursesToSyncViewModel +import org.openedx.profile.presentation.calendar.DisableCalendarSyncDialogViewModel +import org.openedx.profile.presentation.calendar.NewCalendarDialogViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel @@ -109,6 +114,8 @@ val screenModule = module { get(), get(), get(), + get(), + get(), courseId, infoType, ) @@ -190,14 +197,21 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } - viewModel { CalendarViewModel(get()) } + viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { CoursesToSyncViewModel(get(), get(), get(), get()) } + viewModel { NewCalendarDialogViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get()) } + factory { CalendarRepository(get(), get(), get()) } + factory { CalendarInteractor(get()) } single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( pathId, @@ -221,7 +235,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String, courseTitle: String, enrollmentMode: String, resumeBlockId: String) -> @@ -239,7 +254,6 @@ val screenModule = module { get(), get(), get(), - get(), get() ) } @@ -327,10 +341,9 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> + viewModel { (courseId: String, enrollmentMode: String) -> CourseDatesViewModel( courseId, - courseTitle, enrollmentMode, get(), get(), @@ -339,7 +352,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String, handoutsType: String) -> diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index be320bae7..1728dfe9b 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -3,8 +3,11 @@ package org.openedx.app.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.module.db.CalendarDao import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter @@ -22,7 +25,9 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEntity::class, EnrolledCourseEntity::class, CourseStructureEntity::class, - DownloadModelEntity::class + DownloadModelEntity::class, + CourseCalendarEventEntity::class, + CourseCalendarStateEntity::class ], version = DATABASE_VERSION, exportSchema = false @@ -33,4 +38,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun courseDao(): CourseDao abstract fun dashboardDao(): DashboardDao abstract fun downloadDao(): DownloadDao + abstract fun calendarDao(): CalendarDao } diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt new file mode 100644 index 000000000..f373e0e42 --- /dev/null +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -0,0 +1,26 @@ +package org.openedx.app.room + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.openedx.core.DatabaseManager +import org.openedx.core.module.db.DownloadDao +import org.openedx.course.data.storage.CourseDao +import org.openedx.dashboard.data.DashboardDao +import org.openedx.discovery.data.storage.DiscoveryDao + +class DatabaseManager( + private val courseDao: CourseDao, + private val dashboardDao: DashboardDao, + private val downloadDao: DownloadDao, + private val discoveryDao: DiscoveryDao +) : DatabaseManager { + override fun clearTables() { + CoroutineScope(Dispatchers.Main).launch { + courseDao.clearCachedData() + dashboardDao.clearCachedData() + downloadDao.clearCachedData() + discoveryDao.clearCachedData() + } + } +} diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 87a34e790..6da7a144c 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -96,7 +96,7 @@ class AppViewModelTest { every { notifier.notifier } returns flow { emit(LogoutEvent(true)) } - every { preferencesManager.clear() } returns Unit + every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit @@ -133,7 +133,7 @@ class AppViewModelTest { emit(LogoutEvent(true)) emit(LogoutEvent(true)) } - every { preferencesManager.clear() } returns Unit + every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit @@ -161,7 +161,7 @@ class AppViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logoutEvent(true) } - verify(exactly = 1) { preferencesManager.clear() } + verify(exactly = 1) { preferencesManager.clearCorePreferences() } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { preferencesManager.user } verify(exactly = 1) { room.clearAllTables() } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index dd03bdaae..53b42f46d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -26,7 +26,9 @@ import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config +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.createHonorCodeField import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.global.WhatsNewGlobalManager @@ -48,6 +50,8 @@ class SignInViewModel( private val oAuthHelper: OAuthHelper, private val router: AuthRouter, private val whatsNewGlobalManager: WhatsNewGlobalManager, + private val calendarPreferences: CalendarPreferences, + private val calendarInteractor: CalendarInteractor, agreementProvider: AgreementProvider, config: Config, val courseId: String?, @@ -100,6 +104,10 @@ class SignInViewModel( interactor.login(username, password) _uiState.update { it.copy(loginSuccess = true) } setUserId() + if (calendarPreferences.calendarUser != username) { + calendarPreferences.clearCalendarPreferences() + calendarInteractor.clearCalendarCachedData() + } logEvent( AuthAnalyticsEvent.SIGN_IN_SUCCESS, buildMap { 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 a46b371c8..f991db3ad 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 @@ -34,7 +34,9 @@ import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.model.User +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.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager @@ -63,6 +65,8 @@ class SignInViewModelTest { private val oAuthHelper = mockk() private val router = mockk() private val whatsNewGlobalManager = mockk() + private val calendarInteractor = mockk() + private val calendarPreferences = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -87,6 +91,9 @@ class SignInViewModelTest { every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { calendarPreferences.calendarUser } returns "" + every { calendarPreferences.clearCalendarPreferences() } returns Unit + coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit every { analytics.logScreenEvent(any(), any()) } returns Unit } @@ -115,6 +122,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -149,6 +158,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -183,6 +194,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.org", "") @@ -216,6 +229,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) viewModel.login("acc@test.org", "ed") @@ -253,6 +268,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") @@ -290,6 +307,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -329,6 +348,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -368,6 +389,8 @@ class SignInViewModelTest { whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", infoType = "", + calendarInteractor = calendarInteractor, + calendarPreferences = calendarPreferences ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") diff --git a/core/src/main/java/org/openedx/core/CalendarRouter.kt b/core/src/main/java/org/openedx/core/CalendarRouter.kt new file mode 100644 index 000000000..1969ca860 --- /dev/null +++ b/core/src/main/java/org/openedx/core/CalendarRouter.kt @@ -0,0 +1,8 @@ +package org.openedx.core + +import androidx.fragment.app.FragmentManager + +interface CalendarRouter { + + fun navigateToCalendarSettings(fm: FragmentManager) +} diff --git a/core/src/main/java/org/openedx/core/DatabaseManager.kt b/core/src/main/java/org/openedx/core/DatabaseManager.kt new file mode 100644 index 000000000..d7bc7d025 --- /dev/null +++ b/core/src/main/java/org/openedx/core/DatabaseManager.kt @@ -0,0 +1,5 @@ +package org.openedx.core + +interface DatabaseManager { + fun clearTables() +} diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 6d30a9044..fab5d924b 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -7,6 +7,7 @@ import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates import retrofit2.http.Body @@ -76,4 +77,9 @@ interface CourseApi { @Query("status") status: String? = null, @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments + + @GET("/api/mobile/v1/users/{username}/enrollments_status/") + suspend fun getEnrollmentsStatus( + @Path("username") username: String + ): List } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt new file mode 100644 index 000000000..f5535879e --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentStatus.kt @@ -0,0 +1,19 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.EnrollmentStatus + +data class EnrollmentStatus( + @SerializedName("course_id") + val courseId: String?, + @SerializedName("course_name") + val courseName: String?, + @SerializedName("is_active") + val isActive: Boolean? +) { + fun mapToDomain() = EnrollmentStatus( + courseId = courseId ?: "", + courseName = courseName ?: "", + isActive = isActive ?: false + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt new file mode 100644 index 000000000..62f3c30b4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarEventEntity.kt @@ -0,0 +1,21 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseCalendarEvent + +@Entity(tableName = "course_calendar_event_table") +data class CourseCalendarEventEntity( + @PrimaryKey + @ColumnInfo("event_id") + val eventId: Long, + @ColumnInfo("course_id") + val courseId: String +) { + + fun mapToDomain() = CourseCalendarEvent( + courseId = courseId, + eventId = eventId + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt new file mode 100644 index 000000000..e2c39991c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseCalendarStateEntity.kt @@ -0,0 +1,24 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseCalendarState + +@Entity(tableName = "course_calendar_state_table") +data class CourseCalendarStateEntity( + @PrimaryKey + @ColumnInfo("course_id") + val courseId: String, + @ColumnInfo("checksum") + val checksum: Int = 0, + @ColumnInfo("is_course_sync_enabled") + val isCourseSyncEnabled: Boolean, +) { + + fun mapToDomain() = CourseCalendarState( + checksum = checksum, + courseId = courseId, + isCourseSyncEnabled = isCourseSyncEnabled + ) +} 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 new file mode 100644 index 000000000..91e38b35c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt @@ -0,0 +1,10 @@ +package org.openedx.core.data.storage + +interface CalendarPreferences { + var calendarId: Long + var calendarUser: String + var isCalendarSyncEnabled: Boolean + var isHideInactiveCourses: Boolean + + fun clearCalendarPreferences() +} 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 29495bae8..7792fb4a4 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 @@ -14,5 +14,5 @@ interface CorePreferences { var appConfig: AppConfig var canResetAppDirectory: Boolean - fun clear() + fun clearCorePreferences() } 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 new file mode 100644 index 000000000..da84dba1a --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt @@ -0,0 +1,60 @@ +package org.openedx.core.domain.interactor + +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.domain.model.CourseCalendarEvent +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.repository.CalendarRepository + +class CalendarInteractor( + private val repository: CalendarRepository +) { + + suspend fun getEnrollmentsStatus() = repository.getEnrollmentsStatus() + + suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) + + suspend fun insertCourseCalendarEntityToCache(vararg courseCalendarEntity: CourseCalendarEventEntity) { + repository.insertCourseCalendarEntityToCache(*courseCalendarEntity) + } + + suspend fun getCourseCalendarEventsByIdFromCache(courseId: String): List { + return repository.getCourseCalendarEventsByIdFromCache(courseId) + } + + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { + repository.deleteCourseCalendarEntitiesByIdFromCache(courseId) + } + + suspend fun insertCourseCalendarStateEntityToCache(vararg courseCalendarStateEntity: CourseCalendarStateEntity) { + repository.insertCourseCalendarStateEntityToCache(*courseCalendarStateEntity) + } + + suspend fun getCourseCalendarStateByIdFromCache(courseId: String): CourseCalendarState? { + return repository.getCourseCalendarStateByIdFromCache(courseId) + } + + suspend fun getAllCourseCalendarStateFromCache(): List { + return repository.getAllCourseCalendarStateFromCache() + } + + suspend fun clearCalendarCachedData() { + repository.clearCalendarCachedData() + } + + suspend fun resetChecksums() { + repository.resetChecksums() + } + + suspend fun updateCourseCalendarStateByIdInCache( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) { + repository.updateCourseCalendarStateByIdInCache(courseId, checksum, isCourseSyncEnabled) + } + + suspend fun deleteCourseCalendarStateByIdFromCache(courseId: String) { + repository.deleteCourseCalendarStateByIdFromCache(courseId) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt b/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt new file mode 100644 index 000000000..849d2f303 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CalendarData.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CalendarData( + val title: String, + val color: Int +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt new file mode 100644 index 000000000..bdf676c7f --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarEvent.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +data class CourseCalendarEvent( + val courseId: String, + val eventId: Long, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt new file mode 100644 index 000000000..fefad4d82 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseCalendarState.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class CourseCalendarState( + val checksum: Int, + val courseId: String, + val isCourseSyncEnabled: Boolean +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt index 394ebdd56..97f8612bf 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -32,4 +32,24 @@ data class CourseDateBlock( fun isTimeDifferenceLessThan24Hours(): Boolean { return (date.isToday() && date.before(Date())) || date.isTimeLessThan24Hours() } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CourseDateBlock + + if (blockId != other.blockId) return false + if (date != other.date) return false + if (assignmentType != other.assignmentType) return false + + return true + } + + override fun hashCode(): Int { + var result = blockId.hashCode() + result = 31 * result + date.hashCode() + result = 31 * result + (assignmentType?.hashCode() ?: 0) + return result + } } diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt new file mode 100644 index 000000000..8d40ea71d --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentStatus.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class EnrollmentStatus( + val courseId: String, + val courseName: String, + val isActive: Boolean +) 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 new file mode 100644 index 000000000..0dcef5006 --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -0,0 +1,58 @@ +package org.openedx.core.module.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity + +@Dao +interface CalendarDao { + + // region CourseCalendarEventEntity + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseCalendarEntity(vararg courseCalendarEntity: CourseCalendarEventEntity) + + @Query("DELETE FROM course_calendar_event_table WHERE course_id = :courseId") + suspend fun deleteCourseCalendarEntitiesById(courseId: String) + + @Query("SELECT * FROM course_calendar_event_table WHERE course_id=:courseId") + suspend fun readCourseCalendarEventsById(courseId: String): List + + @Query("DELETE FROM course_calendar_event_table") + suspend fun clearCourseCalendarEventsCachedData() + + // region CourseCalendarStateEntity + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseCalendarStateEntity(vararg courseCalendarStateEntity: CourseCalendarStateEntity) + + @Query("SELECT * FROM course_calendar_state_table WHERE course_id=:courseId") + suspend fun readCourseCalendarStateById(courseId: String): CourseCalendarStateEntity? + + @Query("SELECT * FROM course_calendar_state_table") + suspend fun readAllCourseCalendarState(): List + + @Query("DELETE FROM course_calendar_state_table") + suspend fun clearCourseCalendarStateCachedData() + + @Query("DELETE FROM course_calendar_state_table WHERE course_id = :courseId") + suspend fun deleteCourseCalendarStateById(courseId: String) + + @Query("UPDATE course_calendar_state_table SET checksum = 0") + suspend fun resetChecksums() + + @Query( + """ + UPDATE course_calendar_state_table + SET + checksum = COALESCE(:checksum, checksum), + is_course_sync_enabled = COALESCE(:isCourseSyncEnabled, is_course_sync_enabled) + WHERE course_id = :courseId""" + ) + suspend fun updateCourseCalendarStateById( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) +} diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index 5bdfc637b..8005a4b95 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -1,6 +1,10 @@ package org.openedx.core.module.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao @@ -23,4 +27,7 @@ interface DownloadDao { @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + + @Query("DELETE FROM download_model") + suspend fun clearCachedData() } diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt new file mode 100644 index 000000000..95a851442 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt @@ -0,0 +1,52 @@ +package org.openedx.core.presentation.settings.calendarsync + +import androidx.annotation.StringRes +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.SyncDisabled +import androidx.compose.material.icons.rounded.EventRepeat +import androidx.compose.material.icons.rounded.FreeCancellation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors + +enum class CalendarSyncState( + @StringRes val title: Int, + @StringRes val longTitle: Int, + val icon: ImageVector +) { + OFFLINE( + R.string.core_offline, + R.string.core_offline, + Icons.Default.SyncDisabled + ), + SYNC_FAILED( + R.string.core_syncing_failed, + R.string.core_calendar_sync_failed, + Icons.Rounded.FreeCancellation + ), + SYNCED( + R.string.core_to_sync, + R.string.core_synced_to_calendar, + Icons.Rounded.EventRepeat + ), + SYNCHRONIZATION( + R.string.core_syncing_to_calendar, + R.string.core_syncing_to_calendar, + Icons.Default.CloudSync + ); + + val tint: Color + @Composable + @ReadOnlyComposable + get() = when (this) { + OFFLINE -> MaterialTheme.appColors.textFieldHint + SYNC_FAILED -> MaterialTheme.appColors.error + SYNCED -> MaterialTheme.appColors.successGreen + SYNCHRONIZATION -> MaterialTheme.appColors.primary + } +} diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt new file mode 100644 index 000000000..e46922605 --- /dev/null +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -0,0 +1,77 @@ +package org.openedx.core.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseCalendarEvent +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.domain.model.EnrollmentStatus +import org.openedx.core.module.db.CalendarDao + +class CalendarRepository( + private val api: CourseApi, + private val corePreferences: CorePreferences, + private val calendarDao: CalendarDao +) { + + suspend fun getEnrollmentsStatus(): List { + val response = api.getEnrollmentsStatus(corePreferences.user?.username ?: "") + return response.map { it.mapToDomain() } + } + + suspend fun getCourseDates(courseId: String) = api.getCourseDates(courseId) + + suspend fun insertCourseCalendarEntityToCache(vararg courseCalendarEntity: CourseCalendarEventEntity) { + calendarDao.insertCourseCalendarEntity(*courseCalendarEntity) + } + + suspend fun getCourseCalendarEventsByIdFromCache(courseId: String): List { + return calendarDao.readCourseCalendarEventsById(courseId).map { it.mapToDomain() } + } + + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { + calendarDao.deleteCourseCalendarEntitiesById(courseId) + } + + suspend fun insertCourseCalendarStateEntityToCache(vararg courseCalendarStateEntity: CourseCalendarStateEntity) { + calendarDao.insertCourseCalendarStateEntity(*courseCalendarStateEntity) + } + + suspend fun getCourseCalendarStateByIdFromCache(courseId: String): CourseCalendarState? { + return calendarDao.readCourseCalendarStateById(courseId)?.mapToDomain() + } + + suspend fun getAllCourseCalendarStateFromCache(): List { + return calendarDao.readAllCourseCalendarState().map { it.mapToDomain() } + } + + suspend fun resetChecksums() { + calendarDao.resetChecksums() + } + + suspend fun clearCalendarCachedData() { + CoroutineScope(Dispatchers.Main).launch { + val clearCourseCalendarStateDeferred = async { calendarDao.clearCourseCalendarStateCachedData() } + val clearCourseCalendarEventsDeferred = async { calendarDao.clearCourseCalendarEventsCachedData() } + clearCourseCalendarStateDeferred.await() + clearCourseCalendarEventsDeferred.await() + } + } + + suspend fun updateCourseCalendarStateByIdInCache( + courseId: String, + checksum: Int? = null, + isCourseSyncEnabled: Boolean? = null + ) { + calendarDao.updateCourseCalendarStateById(courseId, checksum, isCourseSyncEnabled) + } + + suspend fun deleteCourseCalendarStateByIdFromCache(courseId: String) { + calendarDao.deleteCourseCalendarStateById(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 e1e6f926d..c1a393767 100644 --- a/core/src/main/java/org/openedx/core/system/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -1,10 +1,8 @@ package org.openedx.core.system -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 @@ -13,20 +11,17 @@ import androidx.core.content.ContextCompat import io.branch.indexing.BranchUniversalObject import io.branch.referral.util.ContentMetadata import io.branch.referral.util.LinkProperties -import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CalendarData import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar -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) @@ -35,7 +30,7 @@ class CalendarManager( android.Manifest.permission.READ_CALENDAR ) - private val accountName: String + val accountName: String get() = getUserAccountForSync() /** @@ -48,29 +43,40 @@ class CalendarManager( /** * 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 + fun isCalendarExist(calendarId: Long): Boolean { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + val exists = cursor != null && cursor.count > 0 + cursor?.close() + + return exists } /** * Create or update the calendar if it is already existed in mobile calendar app */ fun createOrUpdateCalendar( - calendarTitle: String + calendarId: Long = CALENDAR_DOES_NOT_EXIST, + calendarTitle: String, + calendarColor: Long ): Long { - val calendarId = getCalendarId( - calendarTitle = calendarTitle - ) - if (calendarId != CALENDAR_DOES_NOT_EXIST) { deleteCalendar(calendarId = calendarId) } return createCalendar( - calendarTitle = calendarTitle + calendarTitle = calendarTitle, + calendarColor = calendarColor ) } @@ -78,7 +84,8 @@ class CalendarManager( * Method to create a separate calendar based on course name in mobile calendar app */ private fun createCalendar( - calendarTitle: String + calendarTitle: String, + calendarColor: Long ): Long { val contentValues = ContentValues() contentValues.put(CalendarContract.Calendars.NAME, calendarTitle) @@ -97,7 +104,7 @@ class CalendarManager( contentValues.put(CalendarContract.Calendars.VISIBLE, 1) contentValues.put( CalendarContract.Calendars.CALENDAR_COLOR, - ContextCompat.getColor(context, R.color.primary) + calendarColor.toInt() ) val creationUri: Uri? = asSyncAdapter( Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), @@ -114,39 +121,6 @@ class CalendarManager( 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 */ @@ -155,7 +129,7 @@ class CalendarManager( 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) @@ -167,7 +141,7 @@ class CalendarManager( put(CalendarContract.Events.DTEND, endMillis) put( CalendarContract.Events.TITLE, - "${resourceManager.getString(R.string.core_assignment_due_tag)} : $courseName" + "${courseDateBlock.title} : $courseName" ) put( CalendarContract.Events.DESCRIPTION, @@ -182,6 +156,8 @@ class CalendarManager( } 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 } /** @@ -194,7 +170,7 @@ class CalendarManager( courseDateBlock: CourseDateBlock, isDeeplinkEnabled: Boolean ): String { - var eventDescription = courseDateBlock.title + var eventDescription = courseDateBlock.description if (isDeeplinkEnabled && courseDateBlock.blockId.isNotEmpty()) { val metaData = ContentMetadata() @@ -246,82 +222,6 @@ class CalendarManager( 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 -> - val dueDateCalendar = Calendar.getInstance().apply { - timeInMillis = dueDateInMillis - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - val unitDateCalendar = unit.date.toCalendar().apply { - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - - if (dueDateCalendar == unitDateCalendar) { - 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 */ @@ -352,37 +252,6 @@ class CalendarManager( ).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 * @@ -392,19 +261,49 @@ class CalendarManager( 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" + fun getCalendarData(calendarId: Long): CalendarData? { + val projection = arrayOf( + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.CALENDAR_COLOR + ) + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + + val cursor: Cursor? = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + 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)) + CalendarData( + title = title, + color = color + ) + } else { + null + } + } + } + + 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" } + } } 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" } diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt new file mode 100644 index 000000000..e69de29bb diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt new file mode 100644 index 000000000..028b0d3e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarCreated.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarCreated : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt new file mode 100644 index 000000000..1bdf92dca --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +interface CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt new file mode 100644 index 000000000..b0baa674b --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarNotifier.kt @@ -0,0 +1,14 @@ +package org.openedx.core.system.notifier.calendar + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class CalendarNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: CalendarEvent) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt new file mode 100644 index 000000000..ec9d61e84 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncDisabled.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncDisabled : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt new file mode 100644 index 000000000..af7f507ea --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncFailed.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncFailed : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt new file mode 100644 index 000000000..ac78a4a4c --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncOffline.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncOffline : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt new file mode 100644 index 000000000..71bfed3ef --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSynced.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSynced : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt new file mode 100644 index 000000000..edfe066a9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/calendar/CalendarSyncing.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier.calendar + +object CalendarSyncing : CalendarEvent diff --git a/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt b/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt new file mode 100644 index 000000000..b74d7c9da --- /dev/null +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncScheduler.kt @@ -0,0 +1,39 @@ +package org.openedx.core.worker + +import android.content.Context +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +class CalendarSyncScheduler(private val context: Context) { + + fun scheduleDailySync() { + val periodicWorkRequest = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .addTag(CalendarSyncWorker.WORKER_TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + CalendarSyncWorker.WORKER_TAG, + ExistingPeriodicWorkPolicy.KEEP, + periodicWorkRequest + ) + } + + fun requestImmediateSync() { + val syncWorkRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueue(syncWorkRequest) + } + + fun requestImmediateSync(courseId: String) { + val inputData = Data.Builder() + .putString(CalendarSyncWorker.ARG_COURSE_ID, courseId) + .build() + val syncWorkRequest = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .build() + WorkManager.getInstance(context).enqueue(syncWorkRequest) + } +} diff --git a/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt new file mode 100644 index 000000000..2c36f075b --- /dev/null +++ b/core/src/main/java/org/openedx/core/worker/CalendarSyncWorker.kt @@ -0,0 +1,224 @@ +package org.openedx.core.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.R +import org.openedx.core.data.model.CourseDates +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.EnrollmentStatus +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncFailed +import org.openedx.core.system.notifier.calendar.CalendarSyncOffline +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.system.notifier.calendar.CalendarSyncing + +class CalendarSyncWorker( + private val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val calendarManager: CalendarManager by inject() + private val calendarInteractor: CalendarInteractor by inject() + private val calendarNotifier: CalendarNotifier by inject() + private val calendarPreferences: CalendarPreferences by inject() + private val networkConnection: NetworkConnection by inject() + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANEL_ID) + + private val failedCoursesSync = mutableSetOf() + + override suspend fun doWork(): Result { + return try { + setForeground(createForegroundInfo()) + val courseId = inputData.getString(ARG_COURSE_ID) + tryToSyncCalendar(courseId) + Result.success() + } catch (e: Exception) { + calendarNotifier.send(CalendarSyncFailed) + Result.failure() + } + } + + private fun createForegroundInfo(): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + + return ForegroundInfo( + NOTIFICATION_ID, + notificationBuilder + .setSmallIcon(R.drawable.core_ic_calendar) + .setContentText(context.getString(R.string.core_title_syncing_calendar)) + .setContentTitle("") + .build(), + serviceType + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val notificationChannel = + NotificationChannel( + NOTIFICATION_CHANEL_ID, + context.getString(R.string.core_header_sync_to_calendar), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(notificationChannel) + } + + private suspend fun tryToSyncCalendar(courseId: String?) { + val isCalendarCreated = calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST + val isCalendarSyncEnabled = calendarPreferences.isCalendarSyncEnabled + if (!networkConnection.isOnline()) { + calendarNotifier.send(CalendarSyncOffline) + } else if (isCalendarCreated && isCalendarSyncEnabled) { + calendarNotifier.send(CalendarSyncing) + val enrollmentsStatus = calendarInteractor.getEnrollmentsStatus() + if (courseId.isNullOrEmpty()) { + syncCalendar(enrollmentsStatus) + } else { + syncCalendar(enrollmentsStatus, courseId) + } + removeUnenrolledCourseEvents(enrollmentsStatus) + if (failedCoursesSync.isEmpty()) { + calendarNotifier.send(CalendarSynced) + } else { + calendarNotifier.send(CalendarSyncFailed) + } + } + } + + private suspend fun removeUnenrolledCourseEvents(enrollmentStatus: List) { + val enrolledCourseIds = enrollmentStatus.map { it.courseId } + val cachedCourseIds = calendarInteractor.getAllCourseCalendarStateFromCache().map { it.courseId } + val unenrolledCourseIds = cachedCourseIds.filter { it !in enrolledCourseIds } + unenrolledCourseIds.forEach { courseId -> + removeCalendarEvents(courseId) + calendarInteractor.deleteCourseCalendarStateByIdFromCache(courseId) + } + } + + private suspend fun syncCalendar(enrollmentsStatus: List, courseId: String) { + enrollmentsStatus + .find { it.courseId == courseId } + ?.let { enrollmentStatus -> + syncCourseEvents(enrollmentStatus) + } + } + + private suspend fun syncCalendar(enrollmentsStatus: List) { + enrollmentsStatus.forEach { enrollmentStatus -> + syncCourseEvents(enrollmentStatus) + } + } + + private suspend fun syncCourseEvents(enrollmentStatus: EnrollmentStatus) { + val courseId = enrollmentStatus.courseId + try { + createCalendarState(enrollmentStatus) + if (enrollmentStatus.isActive && isCourseSyncEnabled(courseId)) { + val courseDates = calendarInteractor.getCourseDates(courseId) + val isCourseCalendarUpToDate = isCourseCalendarUpToDate(courseId, courseDates) + if (!isCourseCalendarUpToDate) { + removeCalendarEvents(courseId) + updateCourseEvents(courseDates, enrollmentStatus) + } + } else { + removeCalendarEvents(courseId) + } + } catch (e: Exception) { + failedCoursesSync.add(courseId) + e.printStackTrace() + } + } + + private suspend fun updateCourseEvents(courseDates: CourseDates, enrollmentStatus: EnrollmentStatus) { + courseDates.courseDateBlocks.forEach { courseDateBlock -> + courseDateBlock.mapToDomain()?.let { domainCourseDateBlock -> + createEvent(domainCourseDateBlock, enrollmentStatus) + } + } + calendarInteractor.updateCourseCalendarStateByIdInCache( + courseId = enrollmentStatus.courseId, + checksum = getCourseChecksum(courseDates) + ) + } + + private suspend fun removeCalendarEvents(courseId: String) { + calendarInteractor.getCourseCalendarEventsByIdFromCache(courseId).forEach { + calendarManager.deleteEvent(it.eventId) + } + calendarInteractor.deleteCourseCalendarEntitiesByIdFromCache(courseId) + calendarInteractor.updateCourseCalendarStateByIdInCache(courseId = courseId, checksum = 0) + } + + private suspend fun createEvent(courseDateBlock: CourseDateBlock, enrollmentStatus: EnrollmentStatus) { + val eventId = calendarManager.addEventsIntoCalendar( + calendarId = calendarPreferences.calendarId, + courseId = enrollmentStatus.courseId, + courseName = enrollmentStatus.courseName, + courseDateBlock = courseDateBlock + ) + val courseCalendarEventEntity = CourseCalendarEventEntity( + courseId = enrollmentStatus.courseId, + eventId = eventId + ) + calendarInteractor.insertCourseCalendarEntityToCache(courseCalendarEventEntity) + } + + private suspend fun createCalendarState(enrollmentStatus: EnrollmentStatus) { + val courseCalendarStateChecksum = getCourseCalendarStateChecksum(enrollmentStatus.courseId) + if (courseCalendarStateChecksum == null) { + val courseCalendarStateEntity = CourseCalendarStateEntity( + courseId = enrollmentStatus.courseId, + isCourseSyncEnabled = enrollmentStatus.isActive + ) + calendarInteractor.insertCourseCalendarStateEntityToCache(courseCalendarStateEntity) + } + } + + private suspend fun isCourseCalendarUpToDate(courseId: String, courseDates: CourseDates): Boolean { + val oldChecksum = getCourseCalendarStateChecksum(courseId) + val newChecksum = getCourseChecksum(courseDates) + return newChecksum == oldChecksum + } + + private suspend fun isCourseSyncEnabled(courseId: String): Boolean { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.isCourseSyncEnabled ?: true + } + + private fun getCourseChecksum(courseDates: CourseDates): Int { + return courseDates.courseDateBlocks.sumOf { it.mapToDomain().hashCode() } + } + + private suspend fun getCourseCalendarStateChecksum(courseId: String): Int? { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.checksum + } + + companion object { + const val ARG_COURSE_ID = "ARG_COURSE_ID" + const val WORKER_TAG = "calendar_sync_worker_tag" + const val NOTIFICATION_ID = 1234 + const val NOTIFICATION_CHANEL_ID = "calendar_sync_channel" + } +} diff --git a/dashboard/src/main/res/drawable/dashboard_ic_book.xml b/core/src/main/res/drawable/core_ic_book.xml similarity index 100% rename from dashboard/src/main/res/drawable/dashboard_ic_book.xml rename to core/src/main/res/drawable/core_ic_book.xml diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 931d2c6da..9aded8c31 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -182,4 +182,11 @@ Discussions More Dates + + Calendar Sync Failed + Synced to Calendar + Sync Failed + To Sync + Not Synced + Syncing to calendar… diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt index ca7286e48..63bd1c4d9 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt @@ -5,17 +5,16 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import org.openedx.core.data.model.room.CourseStructureEntity -import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity @Dao interface CourseDao { - @Query("SELECT * FROM course_enrolled_table WHERE id=:id") - suspend fun getEnrolledCourseById(id: String): EnrolledCourseEntity? - @Query("SELECT * FROM course_structure_table WHERE id=:id") suspend fun getCourseStructureById(id: String): CourseStructureEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) + + @Query("DELETE FROM course_structure_table") + suspend fun clearCachedData() } 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 d83cd0c18..9168d3148 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 @@ -57,8 +57,6 @@ 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.presentation.settings.calendarsync.CalendarSyncDialog -import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.RoundTabsBar @@ -90,15 +88,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) } - private val permissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { isGranted -> - viewModel.logCalendarPermissionAccess(!isGranted.containsValue(false)) - if (!isGranted.containsValue(false)) { - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.SYNC_DIALOG) - } - } - private val pushNotificationPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> @@ -188,84 +177,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { 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, - syncDialogPosAction = { dialog -> - when (dialog) { - CalendarSyncDialogType.SYNC_DIALOG -> { - viewModel.logCalendarAddDates(true) - viewModel.addOrUpdateEventsInCalendar( - updatedEvent = false, - ) - } - - CalendarSyncDialogType.UN_SYNC_DIALOG -> { - viewModel.logCalendarRemoveDates(true) - viewModel.deleteCourseCalendar() - } - - CalendarSyncDialogType.PERMISSION_DIALOG -> { - permissionLauncher.launch(viewModel.calendarPermissions) - } - - CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { - viewModel.logCalendarSyncUpdate(true) - viewModel.addOrUpdateEventsInCalendar( - updatedEvent = true, - ) - } - - CalendarSyncDialogType.EVENTS_DIALOG -> { - viewModel.logCalendarSyncedConfirmation(true) - viewModel.openCalendarApp() - } - - else -> {} - } - }, - syncDialogNegAction = { dialog -> - when (dialog) { - CalendarSyncDialogType.SYNC_DIALOG -> - viewModel.logCalendarAddDates(false) - - CalendarSyncDialogType.UN_SYNC_DIALOG -> - viewModel.logCalendarRemoveDates(false) - - CalendarSyncDialogType.OUT_OF_SYNC_DIALOG -> { - viewModel.logCalendarSyncUpdate(false) - viewModel.deleteCourseCalendar() - } - - CalendarSyncDialogType.EVENTS_DIALOG -> - viewModel.logCalendarSyncedConfirmation(false) - - CalendarSyncDialogType.LOADING_DIALOG, - CalendarSyncDialogType.PERMISSION_DIALOG, - CalendarSyncDialogType.NONE, - -> { - } - } - - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - }, - dismissSyncDialog = { - viewModel.setCalendarSyncDialogType(CalendarSyncDialogType.NONE) - } - ) } } } 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 60813d29a..8d0f404c3 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 @@ -3,11 +3,9 @@ package org.openedx.course.presentation.container import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build -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.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -27,10 +25,8 @@ import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState -import org.openedx.core.system.CalendarManager 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.CourseDatesShifted @@ -40,12 +36,10 @@ import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshDates import org.openedx.core.system.notifier.RefreshDiscussions -import org.openedx.core.utils.TimeUtils +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.DatesShiftedSnackBar -import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CalendarSyncDialog -import org.openedx.course.presentation.CalendarSyncSnackbar import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey @@ -61,14 +55,13 @@ class CourseContainerViewModel( private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, - private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, - private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, private val imageProcessor: ImageProcessor, + private val calendarSyncScheduler: CalendarSyncScheduler, val courseRouter: CourseRouter, ) : BaseViewModel() { @@ -104,13 +97,9 @@ class CourseContainerViewModel( val organization: String get() = _organization - 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), @@ -150,6 +139,7 @@ class CourseContainerViewModel( } is CourseDatesShifted -> { + calendarSyncScheduler.requestImmediateSync(courseId) _uiMessage.emit(DatesShiftedSnackBar()) } @@ -282,113 +272,6 @@ class CourseContainerViewModel( } } - fun addOrUpdateEventsInCalendar( - updatedEvent: Boolean, - ) { - setCalendarSyncDialogType(CalendarSyncDialogType.LOADING_DIALOG) - - val startSyncTime = TimeUtils.getCurrentTime() - val calendarId = getCalendarId() - - if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { - setUiMessage(CoreR.string.core_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) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.UPDATED) - setUiMessage(CoreR.string.core_snackbar_course_calendar_updated) - } else if (coursePreferences.isCalendarSyncEventsDialogShown(courseName)) { - logCalendarSyncSnackbar(CalendarSyncSnackbar.ADDED) - setUiMessage(CoreR.string.core_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 - ) - courseNotifier.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() - - } - logCalendarSyncSnackbar(CalendarSyncSnackbar.REMOVED) - setUiMessage(CoreR.string.core_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) || @@ -437,41 +320,6 @@ class CourseContainerViewModel( ) } - fun logCalendarAddDates(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.ADD.getBuildMap(action) - ) - } - - fun logCalendarRemoveDates(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.REMOVE.getBuildMap(action) - ) - } - - fun logCalendarSyncedConfirmation(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.CONFIRMED.getBuildMap(action) - ) - } - - fun logCalendarSyncUpdate(action: Boolean) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_DIALOG_ACTION, - CalendarSyncDialog.UPDATE.getBuildMap(action) - ) - } - - private fun logCalendarSyncSnackbar(snackbar: CalendarSyncSnackbar) { - logCalendarSyncEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_SNACKBAR, - snackbar.getBuildMap() - ) - } - private fun logCalendarSyncEvent( event: CourseAnalyticsEvent, param: Map = emptyMap(), diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 76197b93c..69f6e0559 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -74,6 +73,7 @@ import org.openedx.core.extension.isNotEmptyThenLet import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.WindowSize @@ -100,9 +100,8 @@ fun CourseDatesScreen( isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) + val uiState by viewModel.uiState.collectAsState(DatesUIState.Loading) val uiMessage by viewModel.uiMessage.collectAsState(null) - val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() val context = LocalContext.current CourseDatesUI( @@ -110,7 +109,6 @@ fun CourseDatesScreen( uiState = uiState, uiMessage = uiMessage, isSelfPaced = viewModel.isSelfPaced, - calendarSyncUIState = calendarSyncUIState, onItemClick = { block -> if (block.blockId.isNotEmpty()) { viewModel.getVerticalBlock(block.blockId) @@ -168,9 +166,9 @@ fun CourseDatesScreen( } } }, - onCalendarSyncSwitch = { isChecked -> - viewModel.handleCalendarSyncState(isChecked) - }, + onCalendarSyncStateClick = { + viewModel.calendarRouter.navigateToCalendarSettings(fragmentManager) + } ) } @@ -180,11 +178,10 @@ private fun CourseDatesUI( uiState: DatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, - calendarSyncUIState: CalendarSyncUIState, onItemClick: (CourseDateBlock) -> Unit, onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, - onCalendarSyncSwitch: (Boolean) -> Unit = {}, + onCalendarSyncStateClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -249,16 +246,6 @@ private fun CourseDatesUI( 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) { @@ -277,6 +264,46 @@ private fun CourseDatesUI( } } + // Handle calendar sync state + item { + 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 + ) + .clickable { + onCalendarSyncStateClick() + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 16.dp, end = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = uiState.calendarSyncState.icon, + tint = uiState.calendarSyncState.tint, + contentDescription = null + ) + Text( + text = stringResource(uiState.calendarSyncState.longTitle), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } + } + // Handle DatesSection.COMPLETED separately datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> item { @@ -650,14 +677,16 @@ private fun CourseDatesScreenPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = DatesUIState.Dates( + CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CalendarSyncState.SYNCED + ), uiMessage = null, isSelfPaced = true, - calendarSyncUIState = mockCalendarSyncUIState, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, - onCalendarSyncSwitch = {}, + onCalendarSyncStateClick = {}, ) } } @@ -669,14 +698,16 @@ private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), + uiState = DatesUIState.Dates( + CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CalendarSyncState.SYNCED + ), uiMessage = null, isSelfPaced = true, - calendarSyncUIState = mockCalendarSyncUIState, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, - onCalendarSyncSwitch = {}, + onCalendarSyncStateClick = {}, ) } } 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 4d6236b67..addad3199 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 @@ -1,7 +1,5 @@ package org.openedx.course.presentation.dates -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -12,10 +10,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel +import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config -import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseBannerType import org.openedx.core.domain.model.CourseDateBlock @@ -24,15 +23,14 @@ import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType -import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState -import org.openedx.core.system.CalendarManager +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.RefreshDates +import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -42,38 +40,28 @@ import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, - courseTitle: String, private val enrollmentMode: String, private val courseNotifier: CourseNotifier, private val interactor: CourseInteractor, - private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, - private val corePreferences: CorePreferences, private val courseAnalytics: CourseAnalytics, private val config: Config, - val courseRouter: CourseRouter + private val calendarInteractor: CalendarInteractor, + private val calendarNotifier: CalendarNotifier, + val courseRouter: CourseRouter, + val calendarRouter: CalendarRouter ) : BaseViewModel() { var isSelfPaced = true - private val _uiState = MutableLiveData(DatesUIState.Loading) - val uiState: LiveData - get() = _uiState + private val _uiState = MutableStateFlow(DatesUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _calendarSyncUIState = MutableStateFlow( - CalendarSyncUIState( - isCalendarSyncEnabled = isCalendarSyncEnabled(), - calendarTitle = calendarManager.getCourseCalendarTitle(courseTitle), - isSynced = false, - ) - ) - val calendarSyncUIState: StateFlow = - _calendarSyncUIState.asStateFlow() - private var courseBannerType: CourseBannerType = CourseBannerType.BLANK private var courseStructure: CourseStructure? = null @@ -83,19 +71,24 @@ class CourseDatesViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is CheckCalendarSyncEvent -> { - _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } - } - is RefreshDates -> { loadingCourseDatesInternal() } } } } + viewModelScope.launch { + calendarNotifier.notifier.collect { + (_uiState.value as? DatesUIState.Dates)?.let { currentUiState -> + val courseDates = currentUiState.courseDatesResult.datesSection.values.flatten() + _uiState.update { + (it as DatesUIState.Dates).copy(calendarSyncState = getCalendarState(courseDates)) + } + } + } + } loadingCourseDatesInternal() - updateAndFetchCalendarSyncState() } private fun loadingCourseDatesInternal() { @@ -107,7 +100,9 @@ class CourseDatesViewModel( if (datesResponse.datesSection.isEmpty()) { _uiState.value = DatesUIState.Empty } else { - _uiState.value = DatesUIState.Dates(datesResponse) + val courseDates = datesResponse.datesSection.values.flatten() + val calendarState = getCalendarState(courseDates) + _uiState.value = DatesUIState.Dates(datesResponse, calendarState) courseBannerType = datesResponse.courseBanner.bannerType checkIfCalendarOutOfDate() } @@ -159,40 +154,6 @@ class CourseDatesViewModel( } } - fun handleCalendarSyncState(isChecked: Boolean) { - logCalendarSyncToggle(isChecked) - setCalendarSyncDialogType( - when { - isChecked && calendarManager.hasPermissions() -> CalendarSyncDialogType.SYNC_DIALOG - isChecked -> CalendarSyncDialogType.PERMISSION_DIALOG - else -> CalendarSyncDialogType.UN_SYNC_DIALOG - } - ) - } - - private 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 { - courseNotifier.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) { @@ -208,10 +169,27 @@ class CourseDatesViewModel( } } - private fun isCalendarSyncEnabled(): Boolean { - val calendarSync = corePreferences.appConfig.courseDatesCalendarSync - return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || - (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + private suspend fun getCalendarState(courseDates: List): CalendarSyncState { + val courseCalendarState = calendarInteractor.getCourseCalendarStateByIdFromCache(courseId) + return when { + courseCalendarState?.isCourseSyncEnabled != true -> CalendarSyncState.OFFLINE + !isCourseCalendarUpToDate(courseDates) -> CalendarSyncState.SYNC_FAILED + else -> CalendarSyncState.SYNCED + } + } + + private suspend fun isCourseCalendarUpToDate(courseDateBlocks: List): Boolean { + val oldChecksum = getCourseCalendarStateChecksum() + val newChecksum = getCourseChecksum(courseDateBlocks) + return newChecksum == oldChecksum + } + + private fun getCourseChecksum(courseDateBlocks: List): Int { + return courseDateBlocks.sumOf { it.hashCode() } + } + + private suspend fun getCourseCalendarStateChecksum(): Int? { + return calendarInteractor.getCourseCalendarStateByIdFromCache(courseId)?.checksum } fun logPlsBannerViewed() { @@ -237,18 +215,6 @@ class CourseDatesViewModel( logDatesEvent(CourseAnalyticsEvent.DATES_COURSE_COMPONENT_CLICKED, params) } - private fun logCalendarSyncToggle(isChecked: Boolean) { - logDatesEvent( - CourseAnalyticsEvent.DATES_CALENDAR_SYNC_TOGGLE, - buildMap { - put( - CourseAnalyticsKey.ACTION.key, - if (isChecked) CourseAnalyticsKey.ON.key else CourseAnalyticsKey.OFF.key - ) - } - ) - } - private fun logDatesEvent( event: CourseAnalyticsEvent, param: Map = emptyMap(), diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt index 8ff75239f..18aebac3f 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -1,12 +1,14 @@ package org.openedx.course.presentation.dates import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState -sealed class DatesUIState { +sealed interface DatesUIState { data class Dates( val courseDatesResult: CourseDatesResult, - ) : DatesUIState() + val calendarSyncState: CalendarSyncState + ) : DatesUIState - object Empty : DatesUIState() - object Loading : DatesUIState() + data object Empty : DatesUIState + data object Loading : DatesUIState } 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 f049e3751..63ad22b05 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 @@ -33,12 +33,11 @@ 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.CalendarManager 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.core.worker.CalendarSyncScheduler import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -57,19 +56,17 @@ 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 mockBitmap = mockk() private val imageProcessor = mockk() private val courseRouter = mockk() private val courseApi = mockk() + private val calendarSyncScheduler = mockk() private val openEdx = "OpenEdx" - private val calendarTitle = "OpenEdx - Abc" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -139,7 +136,6 @@ class CourseContainerViewModelTest { every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig every { notifier.notifier } returns emptyFlow() - every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle every { config.getApiHostURL() } returns "baseUrl" every { imageProcessor.loadImage(any(), any(), any()) } returns Unit every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap @@ -159,15 +155,14 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, - courseRouter + calendarSyncScheduler, + courseRouter, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() @@ -193,14 +188,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns true @@ -227,14 +221,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns true @@ -260,14 +253,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) every { networkConnection.isOnline() } returns false @@ -296,14 +288,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws UnknownHostException() @@ -327,14 +318,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws Exception() @@ -358,14 +348,13 @@ class CourseContainerViewModelTest { "", config, interactor, - calendarManager, resourceManager, notifier, networkConnection, corePreferences, - coursePreferences, analytics, imageProcessor, + calendarSyncScheduler, courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure 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 11ffb4932..ca0c18c79 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 @@ -23,25 +23,26 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.CalendarRouter 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.model.User -import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CourseCalendarState 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.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.calendar.CalendarEvent +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSynced import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter @@ -58,31 +59,17 @@ class CourseDatesViewModelTest { private val resourceManager = mockk() private val notifier = mockk() private val interactor = mockk() - private val calendarManager = mockk() - private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() private val courseRouter = mockk() + private val calendarRouter = mockk() + private val calendarNotifier = mockk() + private val calendarInteractor = 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(), @@ -146,14 +133,16 @@ class CourseDatesViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { corePreferences.user } returns user - every { corePreferences.appConfig } returns appConfig every { notifier.notifier } returns flowOf(CourseLoading(false)) - every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle - every { calendarManager.isCalendarExists(any()) } returns true coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit - coEvery { notifier.send(any()) } returns Unit + every { calendarNotifier.notifier } returns flowOf(CalendarSynced) + coEvery { calendarNotifier.send(any()) } returns Unit + coEvery { calendarInteractor.getCourseCalendarStateByIdFromCache(any()) } returns CourseCalendarState( + 0, + "", + true + ) } @After @@ -166,15 +155,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { @@ -195,15 +184,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { @@ -224,15 +213,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult val message = async { @@ -253,15 +242,15 @@ class CourseDatesViewModelTest { val viewModel = CourseDatesViewModel( "id", "", - "", notifier, interactor, - calendarManager, resourceManager, - corePreferences, analytics, config, - courseRouter + calendarInteractor, + calendarNotifier, + courseRouter, + calendarRouter, ) coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 3392ed7bd..c2668f766 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -527,7 +527,7 @@ fun EmptyState( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - painter = painterResource(id = org.openedx.dashboard.R.drawable.dashboard_ic_book), + painter = painterResource(id = R.drawable.core_ic_book), tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 7401f6304..a6d375569 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -384,7 +384,7 @@ private fun ViewAllItem( ) { Icon( modifier = Modifier.size(48.dp), - painter = painterResource(id = R.drawable.dashboard_ic_book), + painter = painterResource(id = CoreR.drawable.core_ic_book), tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) @@ -749,7 +749,7 @@ private fun NoCoursesInfo( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - painter = painterResource(id = R.drawable.dashboard_ic_book), + painter = painterResource(id = CoreR.drawable.core_ic_book), tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt index c68dd1c47..817363fa3 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt @@ -15,6 +15,7 @@ import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -30,6 +31,7 @@ class CourseDetailsViewModel( private val resourceManager: ResourceManager, private val notifier: DiscoveryNotifier, private val analytics: DiscoveryAnalytics, + private val calendarSyncScheduler: CalendarSyncScheduler, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null @@ -92,6 +94,7 @@ class CourseDetailsViewModel( if (courseData is CourseDetailsUIState.CourseData) { _uiState.value = courseData.copy(course = course) courseEnrollSuccessEvent(id, title) + calendarSyncScheduler.requestImmediateSync(id) notifier.send(CourseDashboardUpdate()) } } catch (e: Exception) { diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt index 712a122ab..e62cd0b38 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt @@ -30,6 +30,7 @@ import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -51,6 +52,7 @@ class CourseDetailsViewModelTest { private val networkConnection = mockk() private val notifier = spyk() private val analytics = mockk() + private val calendarSyncScheduler = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -85,6 +87,7 @@ class CourseDetailsViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { config.getApiHostURL() } returns "http://localhost:8000" + every { calendarSyncScheduler.requestImmediateSync(any()) } returns Unit } @After @@ -102,7 +105,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws UnknownHostException() @@ -126,7 +130,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws Exception() @@ -150,7 +155,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -175,7 +181,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -201,7 +208,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -232,7 +240,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -274,7 +283,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) every { config.isPreLoginExperienceEnabled() } returns false every { preferencesManager.user } returns null @@ -328,7 +338,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MIN_VALUE) val count = overview.contains("black") @@ -345,7 +356,8 @@ class CourseDetailsViewModelTest { interactor, resourceManager, notifier, - analytics + analytics, + calendarSyncScheduler, ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MAX_VALUE) val count = overview.contains("black") diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index ce5580a45..561d73c05 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -1,9 +1,9 @@ package org.openedx.profile.data.repository -import androidx.room.RoomDatabase import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody import org.openedx.core.ApiConstants +import org.openedx.core.DatabaseManager import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.profile.data.api.ProfileApi @@ -14,9 +14,9 @@ import java.io.File class ProfileRepository( private val config: Config, private val api: ProfileApi, - private val room: RoomDatabase, private val profilePreferences: ProfilePreferences, private val corePreferences: CorePreferences, + private val databaseManager: DatabaseManager ) { suspend fun getAccount(): Account { @@ -61,8 +61,8 @@ class ProfileRepository( ApiConstants.TOKEN_TYPE_REFRESH ) } finally { - corePreferences.clear() - room.clearAllTables() + corePreferences.clearCorePreferences() + databaseManager.clearTables() } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index fd7514bd5..e9f67ad48 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -22,5 +22,5 @@ interface ProfileRouter { fun navigateToManageAccount(fm: FragmentManager) - fun navigateToCalendarSettings(fm: FragmentManager) + fun navigateToCoursesToSync(fm: FragmentManager) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt index 8d49fb8ec..c1dc22df2 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -86,6 +88,7 @@ private fun CalendarAccessDialog( onCancelClick: () -> Unit, onGrantCalendarAccessClick: () -> Unit ) { + val scrollState = rememberScrollState() DefaultDialogBox( modifier = modifier, onDismissClick = onCancelClick @@ -93,6 +96,7 @@ private fun CalendarAccessDialog( Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(scrollState) .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt index 8a8794c94..112a4e774 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarFragment.kt @@ -1,73 +1,27 @@ package org.openedx.profile.presentation.calendar -import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Autorenew -import androidx.compose.material.icons.rounded.CalendarToday -import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.Toolbar +import org.koin.androidx.compose.koinViewModel 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.settingsHeaderBackground -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.profile.R -import org.openedx.core.R as CoreR class CalendarFragment : Fragment() { - private val viewModel by viewModel() - private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { isGranted -> if (!isGranted.containsValue(false)) { - val dialog = NewCalendarDialogFragment.newInstance() + val dialog = NewCalendarDialogFragment.newInstance(NewCalendarDialogType.CREATE_NEW) dialog.show( requireActivity().supportFragmentManager, NewCalendarDialogFragment.DIALOG_TAG @@ -90,14 +44,30 @@ class CalendarFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() + val viewModel: CalendarViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() - CalendarScreen( + CalendarView( windowSize = windowSize, + uiState = uiState, setUpCalendarSync = { viewModel.setUpCalendarSync(permissionLauncher) }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() + }, + onCalendarSyncSwitchClick = { + viewModel.setCalendarSyncEnabled(it, requireActivity().supportFragmentManager) + }, + onChangeSyncOptionClick = { + val dialog = NewCalendarDialogFragment.newInstance(NewCalendarDialogType.UPDATE) + dialog.show( + requireActivity().supportFragmentManager, + NewCalendarDialogFragment.DIALOG_TAG + ) + }, + onCourseToSyncClick = { + viewModel.navigateToCoursesToSync(requireActivity().supportFragmentManager) } ) } @@ -105,160 +75,30 @@ class CalendarFragment : Fragment() { } } -@OptIn(ExperimentalComposeUiApi::class) @Composable -private fun CalendarScreen( +private fun CalendarView( windowSize: WindowSize, + uiState: CalendarUIState, setUpCalendarSync: () -> Unit, - onBackClick: () -> Unit + onBackClick: () -> Unit, + onChangeSyncOptionClick: () -> Unit, + onCourseToSyncClick: () -> Unit, + onCalendarSyncSwitchClick: (Boolean) -> Unit, ) { - val scaffoldState = rememberScaffoldState() - - Scaffold( - modifier = Modifier - .fillMaxSize() - .semantics { - testTagsAsResourceId = true - }, - scaffoldState = scaffoldState - ) { paddingValues -> - - val contentWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - ) - } - - val topBarWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier - .fillMaxWidth() - ) - ) - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .settingsHeaderBackground() - .statusBarsInset(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Toolbar( - modifier = topBarWidth - .displayCutoutForLandscape(), - label = stringResource(id = R.string.profile_dates_and_calendar), - canShowBackBtn = true, - labelTint = MaterialTheme.appColors.settingsTitleContent, - iconTint = MaterialTheme.appColors.settingsTitleContent, - onBackClick = onBackClick - ) - - Box( - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.appShapes.screenBackgroundShape) - .background(MaterialTheme.appColors.background) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Column( - modifier = contentWidth.padding(vertical = 28.dp), - ) { - Text( - modifier = Modifier.testTag("txt_settings"), - text = stringResource(id = CoreR.string.core_settings), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column( - modifier = Modifier - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .padding(vertical = 28.dp), - contentAlignment = Alignment.Center - ) { - Icon( - modifier = Modifier - .fillMaxWidth() - .height(148.dp), - tint = MaterialTheme.appColors.textDark, - imageVector = Icons.Rounded.CalendarToday, - contentDescription = null - ) - Icon( - modifier = Modifier - .fillMaxWidth() - .padding(top = 30.dp) - .height(60.dp), - tint = MaterialTheme.appColors.textDark, - imageVector = Icons.Default.Autorenew, - contentDescription = null - ) - } - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(id = R.string.profile_calendar_sync), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(id = R.string.profile_calendar_sync_description), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textDark - ) - Spacer(modifier = Modifier.height(16.dp)) - OpenEdXButton( - modifier = Modifier.fillMaxWidth(0.75f), - text = stringResource(id = R.string.profile_set_up_calendar_sync), - onClick = { - setUpCalendarSync() - } - ) - Spacer(modifier = Modifier.height(24.dp)) - } - } - } - } - } - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CalendarScreenPreview() { - OpenEdXTheme { - CalendarScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - setUpCalendarSync = {}, - onBackClick = {} + if (!uiState.isCalendarExist) { + CalendarSetUpView( + windowSize = windowSize, + setUpCalendarSync = setUpCalendarSync, + onBackClick = onBackClick + ) + } else { + CalendarSettingsView( + windowSize = windowSize, + uiState = uiState, + onBackClick = onBackClick, + onCalendarSyncSwitchClick = onCalendarSyncSwitchClick, + onChangeSyncOptionClick = onChangeSyncOptionClick, + onCourseToSyncClick = onCourseToSyncClick ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt new file mode 100644 index 000000000..06a842630 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSetUpView.kt @@ -0,0 +1,213 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.settingsHeaderBackground +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.profile.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CalendarSetUpView( + windowSize: WindowSize, + setUpCalendarSync: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .verticalScroll(scrollState) + .padding(vertical = 28.dp), + ) { + Text( + modifier = Modifier.testTag("txt_calendar_sync"), + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(vertical = 28.dp), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier + .fillMaxWidth() + .height(148.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Rounded.CalendarToday, + contentDescription = null + ) + Icon( + modifier = Modifier + .fillMaxWidth() + .padding(top = 30.dp) + .height(60.dp), + tint = MaterialTheme.appColors.textDark, + imageVector = Icons.Default.Autorenew, + contentDescription = null + ) + } + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.profile_calendar_sync_description), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Spacer(modifier = Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(0.75f), + text = stringResource(id = R.string.profile_set_up_calendar_sync), + onClick = { + setUpCalendarSync() + } + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarScreenPreview() { + OpenEdXTheme { + CalendarSetUpView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + setUpCalendarSync = {}, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt new file mode 100644 index 000000000..bce3ede77 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarSettingsView.kt @@ -0,0 +1,323 @@ +package org.openedx.profile.presentation.calendar + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.settingsHeaderBackground +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.profile.R +import org.openedx.profile.presentation.ui.SettingsItem + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CalendarSettingsView( + windowSize: WindowSize, + uiState: CalendarUIState, + onCalendarSyncSwitchClick: (Boolean) -> Unit, + onChangeSyncOptionClick: () -> Unit, + onCourseToSyncClick: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_dates_and_calendar), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .verticalScroll(scrollState) + .padding(vertical = 28.dp), + ) { + if (uiState.calendarData != null) { + CalendarSyncSection( + isCourseCalendarSyncEnabled = uiState.isCalendarSyncEnabled, + calendarData = uiState.calendarData, + calendarSyncState = uiState.calendarSyncState, + onCalendarSyncSwitchClick = onCalendarSyncSwitchClick, + onChangeSyncOptionClick = onChangeSyncOptionClick + ) + } + Spacer(modifier = Modifier.height(20.dp)) + if (uiState.coursesSynced != null) { + CoursesToSyncSection( + coursesSynced = uiState.coursesSynced, + onCourseToSyncClick = onCourseToSyncClick + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CalendarSyncSection( + isCourseCalendarSyncEnabled: Boolean, + calendarData: CalendarData, + calendarSyncState: CalendarSyncState, + onCalendarSyncSwitchClick: (Boolean) -> Unit, + onChangeSyncOptionClick: () -> Unit +) { + Column { + SectionTitle(stringResource(id = R.string.profile_calendar_sync)) + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background(MaterialTheme.appColors.cardViewBackground) + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(Color(calendarData.color)) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = calendarData.title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + Text( + text = stringResource(id = calendarSyncState.title), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textFieldHint + ) + } + if (calendarSyncState == CalendarSyncState.SYNCHRONIZATION) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } else { + Icon( + imageVector = calendarSyncState.icon, + tint = calendarSyncState.tint, + contentDescription = null + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_course_calendar_sync), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isCourseCalendarSyncEnabled, + onCheckedChange = onCalendarSyncSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.profile_currently_syncing_events), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + SyncOptionsButton( + onChangeSyncOptionClick = onChangeSyncOptionClick + ) + } +} + +@Composable +fun SyncOptionsButton( + onChangeSyncOptionClick: () -> Unit +) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.profile_change_sync_options), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onChangeSyncOptionClick() + } + ) +} + +@Composable +fun CoursesToSyncSection( + coursesSynced: Int, + onCourseToSyncClick: () -> Unit +) { + Column { + SectionTitle(stringResource(R.string.profile_courses_to_sync)) + Spacer(modifier = Modifier.height(8.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + SettingsItem( + text = stringResource(R.string.profile_syncing_courses, coursesSynced), + onClick = onCourseToSyncClick + ) + } + } +} + +@Composable +fun SectionTitle(title: String) { + Text( + text = title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CalendarSettingsViewPreview() { + OpenEdXTheme { + CalendarSettingsView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CalendarUIState( + isCalendarExist = true, + calendarData = CalendarData("calendar", Color.Red.toArgb()), + calendarSyncState = CalendarSyncState.SYNCED, + isCalendarSyncEnabled = false, + coursesSynced = 5 + ), + onBackClick = {}, + onCalendarSyncSwitchClick = {}, + onChangeSyncOptionClick = {}, + onCourseToSyncClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt new file mode 100644 index 000000000..cf99e0fa2 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.profile.presentation.calendar + +import org.openedx.core.domain.model.CalendarData +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState + +data class CalendarUIState( + val isCalendarExist: Boolean, + val calendarData: CalendarData? = null, + val calendarSyncState: CalendarSyncState, + val isCalendarSyncEnabled: Boolean, + val coursesSynced: Int? +) 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 316b689b4..658d7ca8e 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 @@ -1,14 +1,139 @@ package org.openedx.profile.presentation.calendar import androidx.activity.result.ActivityResultLauncher +import androidx.fragment.app.FragmentManager +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.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.system.CalendarManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled +import org.openedx.core.system.notifier.calendar.CalendarSyncFailed +import org.openedx.core.system.notifier.calendar.CalendarSyncOffline +import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.core.system.notifier.calendar.CalendarSyncing +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.profile.presentation.ProfileRouter class CalendarViewModel( - private val calendarManager: CalendarManager + private val calendarSyncScheduler: CalendarSyncScheduler, + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarNotifier: CalendarNotifier, + private val calendarInteractor: CalendarInteractor, + private val profileRouter: ProfileRouter, + private val networkConnection: NetworkConnection, ) : BaseViewModel() { + private val calendarInitState: CalendarUIState + get() = CalendarUIState( + isCalendarExist = isCalendarExist(), + calendarData = null, + calendarSyncState = if (networkConnection.isOnline()) CalendarSyncState.SYNCED else CalendarSyncState.OFFLINE, + isCalendarSyncEnabled = calendarPreferences.isCalendarSyncEnabled, + coursesSynced = null + ) + + private val _uiState = MutableStateFlow(calendarInitState) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + init { + calendarSyncScheduler.requestImmediateSync() + viewModelScope.launch { + calendarNotifier.notifier.collect { calendarEvent -> + when (calendarEvent) { + CalendarCreated -> { + calendarSyncScheduler.requestImmediateSync() + _uiState.update { it.copy(isCalendarExist = true) } + getCalendarData() + } + + CalendarSyncing -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNCHRONIZATION) } + } + + CalendarSynced -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNCED) } + updateSyncedCoursesCount() + } + + CalendarSyncFailed -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.SYNC_FAILED) } + updateSyncedCoursesCount() + } + + CalendarSyncOffline -> { + _uiState.update { it.copy(calendarSyncState = CalendarSyncState.OFFLINE) } + } + + CalendarSyncDisabled -> { + _uiState.update { calendarInitState } + } + } + } + } + + getCalendarData() + updateSyncedCoursesCount() + } + fun setUpCalendarSync(permissionLauncher: ActivityResultLauncher>) { permissionLauncher.launch(calendarManager.permissions) } + + fun setCalendarSyncEnabled(isEnabled: Boolean, fragmentManager: FragmentManager) { + if (!isEnabled) { + _uiState.value.calendarData?.let { + val dialog = DisableCalendarSyncDialogFragment.newInstance(it) + dialog.show( + fragmentManager, + DisableCalendarSyncDialogFragment.DIALOG_TAG + ) + } + } else { + calendarPreferences.isCalendarSyncEnabled = true + _uiState.update { it.copy(isCalendarSyncEnabled = true) } + calendarSyncScheduler.requestImmediateSync() + } + } + + fun navigateToCoursesToSync(fragmentManager: FragmentManager) { + profileRouter.navigateToCoursesToSync(fragmentManager) + } + + private fun getCalendarData() { + if (calendarManager.hasPermissions()) { + val calendarData = calendarManager.getCalendarData(calendarId = calendarPreferences.calendarId) + _uiState.update { it.copy(calendarData = calendarData) } + } + } + + private fun updateSyncedCoursesCount() { + viewModelScope.launch { + val courseStates = calendarInteractor.getAllCourseCalendarStateFromCache() + if (courseStates.isNotEmpty()) { + val syncedCoursesCount = courseStates.count { it.isCourseSyncEnabled } + _uiState.update { it.copy(coursesSynced = syncedCoursesCount) } + } + } + } + + private fun isCalendarExist(): Boolean { + return try { + calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST && + calendarManager.isCalendarExist(calendarPreferences.calendarId) + } catch (e: SecurityException) { + false + } + } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt new file mode 100644 index 000000000..7b4d1d9d0 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncFragment.kt @@ -0,0 +1,443 @@ +package org.openedx.profile.presentation.calendar + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.UIMessage +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.settingsHeaderBackground +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.theme.fontFamily +import org.openedx.core.ui.windowSizeValue +import org.openedx.profile.R +import org.openedx.core.R as coreR + +class CoursesToSyncFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val viewModel: CoursesToSyncViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) + + CoursesToSyncView( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onHideInactiveCoursesSwitchClick = { + viewModel.setHideInactiveCoursesEnabled(it) + }, + onCourseSyncCheckChange = { isEnabled, courseId -> + viewModel.setCourseSyncEnabled(isEnabled, courseId) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + } + ) + } + } + } +} + +@Composable +private fun CoursesToSyncView( + windowSize: WindowSize, + onBackClick: () -> Unit, + uiState: CoursesToSyncUIState, + uiMessage: UIMessage?, + onHideInactiveCoursesSwitchClick: (Boolean) -> Unit, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState + ) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .settingsHeaderBackground() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Toolbar( + modifier = topBarWidth + .displayCutoutForLandscape(), + label = stringResource(id = R.string.profile_courses_to_sync), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.appShapes.screenBackgroundShape) + .background(MaterialTheme.appColors.background) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = contentWidth + .padding(vertical = 28.dp), + ) { + Text( + text = stringResource(R.string.profile_courses_to_sync_title), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + Spacer(modifier = Modifier.height(20.dp)) + HideInactiveCoursesView( + isHideInactiveCourses = uiState.isHideInactiveCourses, + onHideInactiveCoursesSwitchClick = onHideInactiveCoursesSwitchClick + ) + Spacer(modifier = Modifier.height(20.dp)) + SyncCourseTabRow( + uiState = uiState, + onCourseSyncCheckChange = onCourseSyncCheckChange + ) + } + } + } + } + } +} + +@Composable +private fun SyncCourseTabRow( + uiState: CoursesToSyncUIState, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + var selectedTab by remember { mutableStateOf(SyncCourseTab.SYNCED) } + val selectedTabIndex = SyncCourseTab.entries.indexOf(selectedTab) + + Column { + TabRow( + modifier = Modifier + .clip(MaterialTheme.appShapes.buttonShape) + .border( + 1.dp, + MaterialTheme.appColors.textAccent, + MaterialTheme.appShapes.buttonShape + ), + selectedTabIndex = selectedTabIndex, + backgroundColor = MaterialTheme.appColors.background, + indicator = {} + ) { + SyncCourseTab.entries.forEachIndexed { index, tab -> + val backgroundColor = if (selectedTabIndex == index) { + MaterialTheme.appColors.textAccent + } else { + MaterialTheme.appColors.background + } + Tab( + modifier = Modifier + .background(backgroundColor), + text = { Text(stringResource(id = tab.title)) }, + selected = selectedTabIndex == index, + onClick = { selectedTab = SyncCourseTab.entries[index] }, + unselectedContentColor = MaterialTheme.appColors.textAccent, + selectedContentColor = MaterialTheme.appColors.background + ) + } + } + + CourseCheckboxList( + selectedTab = selectedTab, + uiState = uiState, + onCourseSyncCheckChange = onCourseSyncCheckChange + ) + } +} + + +@Composable +private fun CourseCheckboxList( + selectedTab: SyncCourseTab, + uiState: CoursesToSyncUIState, + onCourseSyncCheckChange: (Boolean, String) -> Unit +) { + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp), + ) { + val courseIds = uiState.coursesCalendarState + .filter { it.isCourseSyncEnabled == (selectedTab == SyncCourseTab.SYNCED) } + .map { it.courseId } + val filteredEnrollments = uiState.enrollmentsStatus + .filter { it.courseId in courseIds } + .let { enrollments -> + if (uiState.isHideInactiveCourses) { + enrollments.filter { it.isActive } + } else { + enrollments + } + } + if (filteredEnrollments.isEmpty()) { + item { + EmptyListState( + selectedTab = selectedTab + ) + } + } else { + items(filteredEnrollments) { course -> + val isCourseSyncEnabled = + uiState.coursesCalendarState.find { it.courseId == course.courseId }?.isCourseSyncEnabled + ?: false + val annotatedString = buildAnnotatedString { + append(course.courseName) + if (!course.isActive) { + append(" ") + withStyle( + style = SpanStyle( + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp, + fontFamily = fontFamily, + color = MaterialTheme.appColors.textFieldHint, + ) + ) { + append(stringResource(R.string.profile_inactive)) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + modifier = Modifier.size(24.dp), + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.appColors.primary, + uncheckedColor = MaterialTheme.appColors.textFieldText + ), + checked = isCourseSyncEnabled, + enabled = course.isActive, + onCheckedChange = { isEnabled -> + onCourseSyncCheckChange(isEnabled, course.courseId) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = annotatedString, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark + ) + } + } + } + } + } +} + +@Composable +private fun EmptyListState( + modifier: Modifier = Modifier, + selectedTab: SyncCourseTab, +) { + val description = if (selectedTab == SyncCourseTab.SYNCED) { + stringResource(id = R.string.profile_no_sync_courses) + } else { + stringResource(id = R.string.profile_no_courses_with_current_filter) + } + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + modifier = Modifier.size(96.dp), + painter = painterResource(id = coreR.drawable.core_ic_book), + tint = MaterialTheme.appColors.divider, + contentDescription = null + ) + Text( + text = stringResource( + id = R.string.profile_no_courses, + stringResource(id = selectedTab.title) + ), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + Text( + text = description, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun HideInactiveCoursesView( + isHideInactiveCourses: Boolean, + onHideInactiveCoursesSwitchClick: (Boolean) -> Unit +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.profile_hide_inactive_courses), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Switch( + modifier = Modifier + .padding(0.dp), + checked = isHideInactiveCourses, + onCheckedChange = onHideInactiveCoursesSwitchClick, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.textAccent + ) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.profile_automatically_remove_events), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } +} + +@Preview +@Composable +private fun CoursesToSyncViewPreview() { + OpenEdXTheme { + CoursesToSyncView( + windowSize = rememberWindowSize(), + uiState = CoursesToSyncUIState( + enrollmentsStatus = emptyList(), + coursesCalendarState = emptyList(), + isHideInactiveCourses = true, + isLoading = false + ), + uiMessage = null, + onHideInactiveCoursesSwitchClick = {}, + onCourseSyncCheckChange = { _, _ -> }, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt new file mode 100644 index 000000000..e43988d2f --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncUIState.kt @@ -0,0 +1,11 @@ +package org.openedx.profile.presentation.calendar + +import org.openedx.core.domain.model.CourseCalendarState +import org.openedx.core.domain.model.EnrollmentStatus + +data class CoursesToSyncUIState( + val enrollmentsStatus: List, + val coursesCalendarState: List, + val isHideInactiveCourses: Boolean, + val isLoading: Boolean +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt new file mode 100644 index 000000000..5e54363e6 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt @@ -0,0 +1,92 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +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.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.worker.CalendarSyncScheduler + +class CoursesToSyncViewModel( + private val calendarInteractor: CalendarInteractor, + private val calendarPreferences: CalendarPreferences, + private val calendarSyncScheduler: CalendarSyncScheduler, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow( + CoursesToSyncUIState( + enrollmentsStatus = emptyList(), + coursesCalendarState = emptyList(), + isHideInactiveCourses = calendarPreferences.isHideInactiveCourses, + isLoading = true + ) + ) + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + val uiState: StateFlow + get() = _uiState.asStateFlow() + + init { + getEnrollmentsStatus() + getCourseCalendarState() + } + + fun setHideInactiveCoursesEnabled(isEnabled: Boolean) { + calendarPreferences.isHideInactiveCourses = isEnabled + _uiState.update { it.copy(isHideInactiveCourses = isEnabled) } + } + + fun setCourseSyncEnabled(isEnabled: Boolean, courseId: String) { + viewModelScope.launch { + calendarInteractor.updateCourseCalendarStateByIdInCache( + courseId = courseId, + isCourseSyncEnabled = isEnabled + ) + getCourseCalendarState() + calendarSyncScheduler.requestImmediateSync(courseId) + } + } + + private fun getCourseCalendarState() { + viewModelScope.launch { + try { + val coursesCalendarState = calendarInteractor.getAllCourseCalendarStateFromCache() + _uiState.update { it.copy(coursesCalendarState = coursesCalendarState) } + } catch (e: Exception) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } + } + + private fun getEnrollmentsStatus() { + viewModelScope.launch { + try { + val enrollmentsStatus = calendarInteractor.getEnrollmentsStatus() + _uiState.update { it.copy(enrollmentsStatus = enrollmentsStatus) } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } +} 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 new file mode 100644 index 000000000..e6a196a8c --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt @@ -0,0 +1,194 @@ +package org.openedx.profile.presentation.calendar + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +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.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +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.CalendarData +import org.openedx.core.extension.parcelable +import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +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.profile.R +import androidx.compose.ui.graphics.Color as ComposeColor +import org.openedx.core.R as coreR + +class DisableCalendarSyncDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val viewModel: DisableCalendarSyncDialogViewModel = koinViewModel() + DisableCalendarSyncDialogView( + calendarData = requireArguments().parcelable(ARG_CALENDAR_DATA), + onCancelClick = { + dismiss() + }, + onDisableSyncingClick = { + viewModel.disableSyncingClick() + dismiss() + } + ) + } + } + } + + companion object { + const val DIALOG_TAG = "DisableCalendarSyncDialogFragment" + const val ARG_CALENDAR_DATA = "ARG_CALENDAR_DATA" + + fun newInstance( + calendarData: CalendarData + ): DisableCalendarSyncDialogFragment { + val fragment = DisableCalendarSyncDialogFragment() + fragment.arguments = bundleOf( + ARG_CALENDAR_DATA to calendarData + ) + return fragment + } + } +} + +@Composable +private fun DisableCalendarSyncDialogView( + modifier: Modifier = Modifier, + calendarData: CalendarData?, + onCancelClick: () -> Unit, + onDisableSyncingClick: () -> Unit +) { + val scrollState = rememberScrollState() + DefaultDialogBox( + modifier = modifier, + onDismissClick = onCancelClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = coreR.drawable.core_ic_warning), + contentDescription = null + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_disable_calendar_dialog_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } + calendarData?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background(MaterialTheme.appColors.cardViewBackground) + .padding(vertical = 16.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(ComposeColor(calendarData.color)) + ) + Text( + text = calendarData.title, + style = MaterialTheme.appTypography.bodyMedium.copy( + textDecoration = TextDecoration.LineThrough + ), + color = MaterialTheme.appColors.textDark + ) + } + } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource( + id = R.string.profile_disable_calendar_dialog_description, + calendarData?.title ?: "" + ), + 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() + } + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DisableCalendarSyncDialogPreview() { + OpenEdXTheme { + DisableCalendarSyncDialogView( + calendarData = CalendarData("calendar", Color.GREEN), + onCancelClick = { }, + 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 new file mode 100644 index 000000000..303dd2a40 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt @@ -0,0 +1,27 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.notifier.calendar.CalendarNotifier +import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled + +class DisableCalendarSyncDialogViewModel( + private val calendarNotifier: CalendarNotifier, + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarInteractor: CalendarInteractor, +) : BaseViewModel() { + + fun disableSyncingClick() { + viewModelScope.launch { + calendarInteractor.clearCalendarCachedData() + calendarManager.deleteCalendar(calendarPreferences.calendarId) + calendarPreferences.clearCalendarPreferences() + calendarNotifier.send(CalendarSyncDisabled) + } + } +} 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 8e55b885b..af09b3ea3 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 @@ -21,9 +21,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem @@ -36,6 +38,7 @@ import androidx.compose.material.icons.Icons 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.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,7 +61,11 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview 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.extension.parcelable +import org.openedx.core.extension.toastMessage import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -82,12 +89,32 @@ class NewCalendarDialogFragment : DialogFragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { + val viewModel: NewCalendarDialogViewModel = koinViewModel() + + LaunchedEffect(Unit) { + viewModel.uiMessage.collect { message -> + if (message.isNotEmpty()) { + context.toastMessage(message) + } + } + } + + LaunchedEffect(Unit) { + viewModel.isSuccess.collect { isSuccess -> + if (isSuccess) { + dismiss() + } + } + } + NewCalendarDialog( + newCalendarDialogType = requireArguments().parcelable(ARG_DIALOG_TYPE) + ?: NewCalendarDialogType.CREATE_NEW, onCancelClick = { dismiss() }, - onBeginSyncingClick = { calendarName, calendarColor -> - //TODO Create calendar and sync events + onBeginSyncingClick = { calendarTitle, calendarColor -> + viewModel.createCalendar(calendarTitle, calendarColor) } ) } @@ -96,12 +123,19 @@ class NewCalendarDialogFragment : DialogFragment() { companion object { const val DIALOG_TAG = "NewCalendarDialogFragment" + const val ARG_DIALOG_TYPE = "ARG_DIALOG_TYPE" - fun newInstance(): NewCalendarDialogFragment { - return NewCalendarDialogFragment() + fun newInstance( + newCalendarDialogType: NewCalendarDialogType + ): NewCalendarDialogFragment { + val fragment = NewCalendarDialogFragment() + fragment.arguments = bundleOf( + ARG_DIALOG_TYPE to newCalendarDialogType + ) + return fragment } - fun getDefaultCalendarName(context: Context): String { + fun getDefaultCalendarTitle(context: Context): String { return "${context.getString(CoreR.string.app_name)} ${context.getString(R.string.profile_course_dates)}" } } @@ -110,11 +144,17 @@ class NewCalendarDialogFragment : DialogFragment() { @Composable private fun NewCalendarDialog( modifier: Modifier = Modifier, + newCalendarDialogType: NewCalendarDialogType, onCancelClick: () -> Unit, - onBeginSyncingClick: (calendarName: String, calendarColor: CalendarColor) -> Unit + onBeginSyncingClick: (calendarTitle: String, calendarColor: CalendarColor) -> Unit ) { val context = LocalContext.current - var calendarName by rememberSaveable { + val scrollState = rememberScrollState() + val title = when (newCalendarDialogType) { + NewCalendarDialogType.CREATE_NEW -> stringResource(id = R.string.profile_new_calendar) + NewCalendarDialogType.UPDATE -> stringResource(id = R.string.profile_change_sync_options) + } + var calendarTitle by rememberSaveable { mutableStateOf("") } var calendarColor by rememberSaveable { @@ -127,6 +167,7 @@ private fun NewCalendarDialog( Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(scrollState) .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) @@ -136,7 +177,7 @@ private fun NewCalendarDialog( ) { Text( modifier = Modifier.weight(1f), - text = stringResource(id = R.string.profile_new_calendar), + text = title, color = MaterialTheme.appColors.textDark, style = MaterialTheme.appTypography.titleLarge ) @@ -151,9 +192,9 @@ private fun NewCalendarDialog( tint = MaterialTheme.appColors.primary ) } - CalendarNameTextField( + CalendarTitleTextField( onValueChanged = { - calendarName = it + calendarTitle = it } ) ColorDropdown( @@ -183,7 +224,7 @@ private fun NewCalendarDialog( text = stringResource(id = R.string.profile_begin_syncing), onClick = { onBeginSyncingClick( - calendarName.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarName(context) }, + calendarTitle.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarTitle(context) }, calendarColor ) } @@ -193,11 +234,12 @@ private fun NewCalendarDialog( } @Composable -private fun CalendarNameTextField( +private fun CalendarTitleTextField( modifier: Modifier = Modifier, onValueChanged: (String) -> Unit ) { val focusManager = LocalFocusManager.current + val maxChar = 40 var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( TextFieldValue("") @@ -218,7 +260,7 @@ private fun CalendarNameTextField( .height(48.dp), value = textFieldValue, onValueChange = { - textFieldValue = it + if (it.text.length <= maxChar) textFieldValue = it onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( @@ -227,7 +269,7 @@ private fun CalendarNameTextField( shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( - text = NewCalendarDialogFragment.getDefaultCalendarName(LocalContext.current), + text = NewCalendarDialogFragment.getDefaultCalendarTitle(LocalContext.current), color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium ) @@ -383,6 +425,7 @@ private fun ColorCircle( private fun NewCalendarDialogPreview() { OpenEdXTheme { NewCalendarDialog( + newCalendarDialogType = NewCalendarDialogType.CREATE_NEW, onCancelClick = { }, onBeginSyncingClick = { _, _ -> } ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt new file mode 100644 index 000000000..1905b8faa --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogType.kt @@ -0,0 +1,9 @@ +package org.openedx.profile.presentation.calendar + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class NewCalendarDialogType : Parcelable { + CREATE_NEW, UPDATE +} 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 new file mode 100644 index 000000000..e43f1b989 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt @@ -0,0 +1,62 @@ +package org.openedx.profile.presentation.calendar + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.data.storage.CalendarPreferences +import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.system.CalendarManager +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.calendar.CalendarCreated +import org.openedx.core.system.notifier.calendar.CalendarNotifier + +class NewCalendarDialogViewModel( + private val calendarManager: CalendarManager, + private val calendarPreferences: CalendarPreferences, + private val calendarNotifier: CalendarNotifier, + private val calendarInteractor: CalendarInteractor, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _isSuccess = MutableSharedFlow() + val isSuccess: SharedFlow + get() = _isSuccess.asSharedFlow() + + fun createCalendar( + calendarTitle: String, + calendarColor: CalendarColor, + ) { + viewModelScope.launch { + if (networkConnection.isOnline()) { + calendarInteractor.resetChecksums() + val calendarId = calendarManager.createOrUpdateCalendar( + calendarId = calendarPreferences.calendarId, + calendarTitle = calendarTitle, + calendarColor = calendarColor.color + ) + if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { + calendarPreferences.calendarId = calendarId + calendarPreferences.calendarUser = calendarManager.accountName + viewModelScope.launch { + calendarNotifier.send(CalendarCreated) + } + _isSuccess.emit(true) + } else { + _uiMessage.emit(resourceManager.getString(R.string.core_error_unknown_error)) + } + } else { + _uiMessage.emit(resourceManager.getString(R.string.core_error_no_connection)) + } + } + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt new file mode 100644 index 000000000..ef65db249 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/SyncCourseTab.kt @@ -0,0 +1,12 @@ +package org.openedx.profile.presentation.calendar + +import androidx.annotation.StringRes +import org.openedx.core.R + +enum class SyncCourseTab( + @StringRes + val title: Int +) { + SYNCED(R.string.core_to_sync), + NOT_SYNCED(R.string.core_not_synced) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index c4477ef28..79fff00d1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -15,8 +15,8 @@ import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey -import org.openedx.profile.system.notifier.AccountDeactivated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class DeleteProfileViewModel( private val resourceManager: ResourceManager, diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index 211ce2794..bc4c77dd1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -15,8 +15,8 @@ import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.io.File class EditProfileViewModel( diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt index 2370e0508..972426d2e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt @@ -20,8 +20,8 @@ import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class ManageAccountViewModel( private val interactor: ProfileInteractor, diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index d8fc19715..f02e09c22 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -19,8 +19,8 @@ import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class ProfileViewModel( private val interactor: ProfileInteractor, diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 6e622e2cc..64145b063 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openedx.core.AppUpdateState import org.openedx.core.BaseViewModel +import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config @@ -33,8 +34,8 @@ import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountDeactivated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.profile.ProfileNotifier class SettingsViewModel( private val appData: AppData, @@ -44,7 +45,8 @@ class SettingsViewModel( private val cookieManager: AppCookieManager, private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, - private val router: ProfileRouter, + private val profileRouter: ProfileRouter, + private val calendarRouter: CalendarRouter, private val appNotifier: AppNotifier, private val profileNotifier: ProfileNotifier, ) : BaseViewModel() { @@ -128,12 +130,12 @@ class SettingsViewModel( } fun videoSettingsClicked(fragmentManager: FragmentManager) { - router.navigateToVideoSettings(fragmentManager) + profileRouter.navigateToVideoSettings(fragmentManager) logProfileEvent(ProfileAnalyticsEvent.VIDEO_SETTING_CLICKED) } fun privacyPolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_privacy_policy), url = configuration.agreementUrls.privacyPolicyUrl, @@ -142,7 +144,7 @@ class SettingsViewModel( } fun cookiePolicyClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_cookie_policy), url = configuration.agreementUrls.cookiePolicyUrl, @@ -151,7 +153,7 @@ class SettingsViewModel( } fun dataSellClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_data_sell), url = configuration.agreementUrls.dataSellConsentUrl, @@ -164,7 +166,7 @@ class SettingsViewModel( } fun termsOfUseClicked(fragmentManager: FragmentManager) { - router.navigateToWebContent( + profileRouter.navigateToWebContent( fm = fragmentManager, title = resourceManager.getString(R.string.core_terms_of_use), url = configuration.agreementUrls.tosUrl, @@ -186,15 +188,15 @@ class SettingsViewModel( } fun manageAccountClicked(fragmentManager: FragmentManager) { - router.navigateToManageAccount(fragmentManager) + profileRouter.navigateToManageAccount(fragmentManager) } fun calendarSettingsClicked(fragmentManager: FragmentManager) { - router.navigateToCalendarSettings(fragmentManager) + calendarRouter.navigateToCalendarSettings(fragmentManager) } fun restartApp(fragmentManager: FragmentManager) { - router.restartApp( + profileRouter.restartApp( fragmentManager, isLogistrationEnabled ) diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt deleted file mode 100644 index ff09cbf72..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt deleted file mode 100644 index 2870235f2..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt deleted file mode 100644 index dbe877081..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt new file mode 100644 index 000000000..68f68e58f --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountDeactivated.kt @@ -0,0 +1,5 @@ +package org.openedx.profile.system.notifier.account + +import org.openedx.profile.system.notifier.profile.ProfileEvent + +class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt new file mode 100644 index 000000000..f43d6c329 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/account/AccountUpdated.kt @@ -0,0 +1,5 @@ +package org.openedx.profile.system.notifier.account + +import org.openedx.profile.system.notifier.profile.ProfileEvent + +class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt new file mode 100644 index 000000000..c978a78d3 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.profile.system.notifier.profile + +interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt similarity index 70% rename from profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt rename to profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt index c51d82340..71e2dbf1d 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/profile/ProfileNotifier.kt @@ -1,9 +1,10 @@ -package org.openedx.profile.system.notifier +package org.openedx.profile.system.notifier.profile import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.openedx.core.system.notifier.VideoQualityChanged +import org.openedx.profile.system.notifier.account.AccountDeactivated +import org.openedx.profile.system.notifier.account.AccountUpdated class ProfileNotifier { diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 60f0e4060..41535240c 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -60,5 +60,23 @@ Accent Course Dates Color + Course Calendar Sync + Currently syncing events to your calendar + Change Sync Options + Courses to Sync + Syncing %1$s Courses + Options + Use relative dates + Show relative dates like “Tomorrow” and “Yesterday” + Disabling sync for a course will remove all events connected to the course from your synced calendar. + Automatically remove events from courses you haven’t viewed in the last month + Inactive + Hide Inactive Courses + Disable Calendar Sync + Disabling calendar sync will delete the calendar “%1$s.” You can turn calendar sync back on at any time. + Disable Syncing + No %1$s Courses + No courses are currently being synced to your calendar. + No courses match the current filter. diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index a9b5b0c31..e8f1c13ef 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -1,25 +1,33 @@ package org.openedx.profile.presentation.edit import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.profile.domain.model.Account -import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.system.ResourceManager -import org.openedx.profile.domain.interactor.ProfileInteractor -import org.openedx.profile.presentation.ProfileAnalytics -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +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.UIMessage +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.system.ResourceManager +import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.io.File import java.net.UnknownHostException diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index ca2ffd9bb..d33f24fb9 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -32,8 +32,8 @@ import org.openedx.core.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier +import org.openedx.profile.system.notifier.account.AccountUpdated +import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class)