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)