diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt index d98dda0d48..f3efdbc778 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt @@ -144,7 +144,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager { courseName = courses[0].name, courseImageUrl = null, courseSyllabus = "Syllabus for Course 1", - progress = 0.25 + progress = 25.0 ) } else { null } val completedCourse = if (courses.size > 1) { @@ -153,7 +153,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager { courseName = courses[1].name, courseImageUrl = null, courseSyllabus = "Syllabus for Course 2", - progress = 1.0 + progress = 100.0 ) } else { null } val invitedCourse = if (courses.size > 2) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt index 3bbd7968cc..114928eb0f 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/journey/learninglibrary/CollectionItemType.kt @@ -26,7 +26,11 @@ enum class CollectionItemType { EXTERNAL_URL, EXTERNAL_TOOL, FILE, - PROGRAM + PROGRAM; + + companion object { + fun safeValueOf(name: String): CollectionItemType? = entries.find { it.name == name } + } } fun ApolloCollectionItemType.toModel(): CollectionItemType { diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt new file mode 100644 index 0000000000..37d71c03a2 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.espresso + +import android.content.Context +import androidx.room.Room +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.database.HorizonDatabase +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonCourseScoreDao +import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao +import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.di.HorizonHtmlParserQualifier +import com.instructure.horizon.di.HorizonOfflineModule +import com.instructure.horizon.offline.HorizonHtmlParserFileSource +import com.instructure.pandautils.features.offline.sync.HtmlParser +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [HorizonOfflineModule::class]) +object HorizonOfflineTestModule { + + @Provides + @Singleton + fun provideHorizonDatabase(@ApplicationContext context: Context): HorizonDatabase { + return Room.inMemoryDatabaseBuilder(context, HorizonDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @Provides + fun provideHorizonDashboardEnrollmentDao(db: HorizonDatabase): HorizonDashboardEnrollmentDao = db.dashboardEnrollmentDao() + + @Provides + fun provideHorizonProgramDao(db: HorizonDatabase): HorizonProgramDao = db.programDao() + + @Provides + fun provideHorizonDashboardModuleItemDao(db: HorizonDatabase): HorizonDashboardModuleItemDao = db.dashboardModuleItemDao() + + @Provides + fun provideHorizonSyncMetadataDao(db: HorizonDatabase): HorizonSyncMetadataDao = db.syncMetadataDao() + + @Provides + fun provideHorizonLearnItemDao(db: HorizonDatabase): HorizonLearnItemDao = db.learnItemDao() + + @Provides + fun provideHorizonLearnCollectionDao(db: HorizonDatabase): HorizonLearnCollectionDao = db.learnCollectionDao() + + @Provides + fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao = db.learnSavedItemDao() + + @Provides + fun provideHorizonCourseDao(db: HorizonDatabase): HorizonCourseDao = db.courseDao() + + @Provides + fun provideHorizonCourseModuleDao(db: HorizonDatabase): HorizonCourseModuleDao = db.courseModuleDao() + + @Provides + fun provideHorizonCourseScoreDao(db: HorizonDatabase): HorizonCourseScoreDao = db.courseScoreDao() + + @Provides + fun provideHorizonLocalFileDao(db: HorizonDatabase): HorizonLocalFileDao = db.localFileDao() + + @Provides + fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao = db.fileFolderDao() + + @Provides + @HorizonHtmlParserQualifier + fun provideHorizonHtmlParser( + fileSource: HorizonHtmlParserFileSource, + apiPrefs: ApiPrefs, + @ApplicationContext context: Context, + ): HtmlParser = HtmlParser(fileSource, apiPrefs, context) +} diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt index f641e452af..7961a7019f 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt @@ -5,6 +5,7 @@ import android.content.Intent import androidx.room.Room import com.instructure.canvasapi2.LoginRouter import com.instructure.canvasapi2.utils.pageview.PandataInfo +import dagger.hilt.android.qualifiers.ApplicationContext import com.instructure.pandautils.features.about.AboutRepository import com.instructure.pandautils.features.assignments.details.AssignmentDetailsBehaviour import com.instructure.pandautils.features.assignments.details.AssignmentDetailsColorProvider @@ -70,8 +71,8 @@ import com.instructure.pandautils.utils.LogoutHelper import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -101,6 +102,7 @@ object HorizonTestModule { } @Provides + @Singleton fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt new file mode 100644 index 0000000000..4c2e942f56 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsLocalDataSource.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseEntity +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import com.instructure.journey.type.ProgramVariantType +import java.util.Date +import javax.inject.Inject + +class CourseDetailsLocalDataSource @Inject constructor( + private val courseDao: HorizonCourseDao, + private val programDao: HorizonProgramDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getCourse(courseId: Long): CourseWithProgress { + val entity = courseDao.getByCourseId(courseId) + ?: throw IllegalStateException("Course $courseId not found in cache") + return entity.toCourseWithProgress() + } + + suspend fun getProgramsForCourse(courseId: Long): List { + val allPrograms = programDao.getAll() + return allPrograms.mapNotNull { programEntity -> + val refs = programDao.getRefsForProgram(programEntity.programId) + val program = programEntity.toProgram(refs) + if (program.sortedRequirements.any { it.courseId == courseId }) program else null + } + } + + suspend fun saveCourseDetails(course: CourseWithProgress, programs: List) { + courseDao.insertIfAbsent(listOf(course.toDefaultEntity())) + courseDao.updateCourseDetailsFields( + courseId = course.courseId, + name = course.courseName, + progress = course.progress, + imageUrl = course.courseImageUrl, + courseSyllabus = course.courseSyllabus, + ) + val programEntities = programs.map { it.toEntity() } + val refs = programs.flatMap { program -> + program.sortedRequirements.mapIndexed { index, req -> + HorizonProgramCourseRef( + programId = program.id, + courseId = req.courseId, + requirementId = req.id, + progressId = req.progressId, + required = req.required, + progress = req.progress, + enrollmentStatus = req.enrollmentStatus?.rawValue, + sortOrder = index, + ) + } + } + programDao.insertAll(programEntities) + programDao.insertAllRefs(refs) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.COURSE_DETAILS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonCourseEntity.toCourseWithProgress(): CourseWithProgress { + return CourseWithProgress( + courseId = courseId, + courseName = name, + courseImageUrl = imageUrl, + courseSyllabus = courseSyllabus, + progress = progress, + ) + } + + private fun CourseWithProgress.toDefaultEntity(): HorizonCourseEntity { + return HorizonCourseEntity( + courseId = courseId, + name = courseName, + progress = progress, + imageUrl = courseImageUrl, + startAtMs = null, + endAtMs = null, + requirementCount = null, + requirementCompletedCount = null, + completedAtMs = null, + grade = null, + workflowState = null, + lastActivityAtMs = null, + enrolledAtMs = null, + courseSyllabus = courseSyllabus, + moduleItemsDurations = "", + ) + } + + private fun Program.toEntity(): HorizonProgramEntity { + return HorizonProgramEntity( + programId = id, + name = name, + description = description, + startDateMs = startDate?.time, + endDateMs = endDate?.time, + variant = variant.rawValue, + estimatedDurationMinutes = null, + courseCount = sortedRequirements.size, + courseCompletionCount = courseCompletionCount, + enrolledAtMs = null, + completionPercentage = null, + enrollmentStatus = null, + ) + } + + private fun HorizonProgramEntity.toProgram(refs: List): Program { + val requirements = refs.sortedBy { it.sortOrder }.map { ref -> + ProgramRequirement( + id = ref.requirementId, + progressId = ref.progressId, + courseId = ref.courseId, + required = ref.required, + progress = ref.progress, + enrollmentStatus = ref.enrollmentStatus?.let { + runCatching { ProgramProgressCourseEnrollmentStatus.valueOf(it) }.getOrNull() + }, + ) + } + return Program( + id = programId, + name = name, + description = description, + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + variant = runCatching { ProgramVariantType.valueOf(variant) }.getOrDefault(ProgramVariantType.LINEAR), + courseCompletionCount = courseCompletionCount, + sortedRequirements = requirements, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsNetworkDataSource.kt similarity index 70% rename from libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsNetworkDataSource.kt index 069e80b0d9..2bcef5a5a4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseDetailsNetworkDataSource.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.learn.course.details +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.ExternalToolAPI import com.instructure.canvasapi2.builders.RestParams @@ -25,24 +25,27 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import javax.inject.Inject -class CourseDetailsRepository @Inject constructor( +class CourseDetailsNetworkDataSource @Inject constructor( private val getCoursesManager: HorizonGetCoursesManager, private val getProgramsManager: GetProgramsManager, private val externalToolApi: ExternalToolAPI.ExternalToolInterface, - private val apiPrefs: ApiPrefs + private val apiPrefs: ApiPrefs, ) { - suspend fun getCourse(courseId: Long, forceNetwork: Boolean): CourseWithProgress { - return getCoursesManager.getCourseWithProgressById(courseId, apiPrefs.user?.id ?: -1L, forceNetwork).dataOrThrow + + suspend fun getCourse(courseId: Long, forceRefresh: Boolean): CourseWithProgress { + return getCoursesManager.getCourseWithProgressById(courseId, apiPrefs.user?.id ?: -1L, forceRefresh).dataOrThrow } - suspend fun getProgramsForCourse(courseId: Long, forceNetwork: Boolean): List { - return getProgramsManager.getPrograms(forceNetwork).filter { it.sortedRequirements.firstOrNull()?.courseId == courseId } + suspend fun getProgramsForCourse(courseId: Long, forceRefresh: Boolean): List { + return getProgramsManager.getPrograms(forceRefresh).filter { + it.sortedRequirements.firstOrNull()?.courseId == courseId + } } - suspend fun hasExternalTools(courseId: Long, forceNetwork: Boolean): Boolean { + suspend fun hasExternalTools(courseId: Long, forceRefresh: Boolean): Boolean { return externalToolApi.getExternalToolsForCourses( listOf(CanvasContext.emptyCourseContext(courseId).contextId), - RestParams(isForceReadFromNetwork = forceNetwork) + RestParams(isForceReadFromNetwork = forceRefresh) ).dataOrNull.orEmpty().isNotEmpty() } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt index 67ac235dea..d971c0ade8 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt @@ -28,10 +28,12 @@ class CourseEnrollmentNetworkDataSource @Inject constructor( private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, ) { - suspend fun getEnrollments(): List { + suspend fun getEnrollments( + forceRefresh: Boolean, + ): List { return horizonGetCoursesManager.getDashboardEnrollments( userId = apiPrefs.user?.id ?: -1, - forceNetwork = true, + forceNetwork = forceRefresh, ).dataOrThrow } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt new file mode 100644 index 0000000000..ac594a316e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.ModuleCompletionRequirement +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseModuleEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import javax.inject.Inject + +class CourseProgressLocalDataSource @Inject constructor( + private val courseModuleDao: HorizonCourseModuleDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getModuleItems(courseId: Long): List { + val moduleEntities = courseModuleDao.getModulesForCourse(courseId) + return moduleEntities.map { moduleEntity -> + val itemEntities = courseModuleDao.getItemsForModule(moduleEntity.moduleId) + moduleEntity.toModuleObject(itemEntities) + } + } + + suspend fun saveModuleItems(courseId: Long, modules: List) { + val moduleEntities = modules.map { it.toModuleEntity(courseId) } + val itemEntities = modules.flatMap { module -> + module.items.map { it.toModuleItemEntity(module.id, courseId) } + } + courseModuleDao.replaceForCourse(courseId, moduleEntities, itemEntities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.COURSE_MODULES, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonCourseModuleEntity.toModuleObject(items: List): ModuleObject { + val prerequisiteIds = if (prerequisiteIds.isEmpty()) null + else prerequisiteIds.split(",").mapNotNull { it.toLongOrNull() }.toLongArray() + return ModuleObject( + id = moduleId, + position = position, + name = name, + state = state, + estimatedDuration = estimatedDuration, + prerequisiteIds = prerequisiteIds, + items = items.map { it.toModuleItem() }, + ) + } + + private fun HorizonCourseModuleItemEntity.toModuleItem(): ModuleItem { + val completionRequirement = completionRequirementType?.let { + ModuleCompletionRequirement( + type = it, + minScore = completionRequirementMinScore, + completed = completionRequirementCompleted, + ) + } + val moduleDetails = if (pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null) { + ModuleContentDetails( + pointsPossible = pointsPossible, + dueAt = dueAt, + lockedForUser = lockedForUser, + lockExplanation = lockExplanation, + lockAt = lockAt, + unlockAt = unlockAt, + ) + } else null + return ModuleItem( + id = itemId, + moduleId = moduleId, + position = position, + title = title, + type = type, + htmlUrl = htmlUrl, + url = url, + completionRequirement = completionRequirement, + moduleDetails = moduleDetails, + quizLti = quizLti, + estimatedDuration = estimatedDuration, + ) + } + + private fun ModuleObject.toModuleEntity(courseId: Long): HorizonCourseModuleEntity { + return HorizonCourseModuleEntity( + moduleId = id, + courseId = courseId, + name = name, + position = position, + state = state, + estimatedDuration = estimatedDuration, + prerequisiteIds = prerequisiteIds?.joinToString(",") ?: "", + ) + } + + private fun ModuleItem.toModuleItemEntity(moduleId: Long, courseId: Long): HorizonCourseModuleItemEntity { + return HorizonCourseModuleItemEntity( + itemId = id, + moduleId = moduleId, + courseId = courseId, + title = title, + position = position, + type = type, + htmlUrl = htmlUrl, + url = url, + completionRequirementType = completionRequirement?.type, + completionRequirementMinScore = completionRequirement?.minScore ?: 0.0, + completionRequirementCompleted = completionRequirement?.completed ?: false, + pointsPossible = moduleDetails?.pointsPossible, + dueAt = moduleDetails?.dueAt, + lockedForUser = moduleDetails?.lockedForUser ?: false, + lockExplanation = moduleDetails?.lockExplanation, + lockAt = moduleDetails?.lockAt, + unlockAt = moduleDetails?.unlockAt, + quizLti = quizLti, + estimatedDuration = estimatedDuration, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt new file mode 100644 index 0000000000..43810a416d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.utils.depaginate +import javax.inject.Inject + +class CourseProgressNetworkDataSource @Inject constructor( + private val moduleApi: ModuleAPI.ModuleInterface, +) { + + suspend fun getModuleItems(courseId: Long, forceRefresh: Boolean): List { + val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) + return moduleApi.getFirstPageModulesWithItems( + CanvasContext.Type.COURSE.apiString, + courseId, + params, + includes = listOf("estimated_durations") + ) + .depaginate { moduleApi.getNextPageModuleObjectList(it, params) } + .dataOrThrow + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreLocalDataSource.kt new file mode 100644 index 0000000000..cf24894d50 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreLocalDataSource.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AssignmentGroup +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.models.Grades +import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.models.SubmissionComment +import com.instructure.horizon.database.dao.HorizonCourseScoreDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity +import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity +import com.instructure.horizon.database.entity.HorizonCourseGradeEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date +import javax.inject.Inject + +class CourseScoreLocalDataSource @Inject constructor( + private val courseScoreDao: HorizonCourseScoreDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getAssignmentGroups(courseId: Long): List { + val groupEntities = courseScoreDao.getGroupsForCourse(courseId) + return groupEntities.map { groupEntity -> + val assignmentEntities = courseScoreDao.getAssignmentsForGroup(groupEntity.groupId) + groupEntity.toAssignmentGroup(assignmentEntities) + } + } + + suspend fun getEnrollments(courseId: Long): List { + val grade = courseScoreDao.getGradeForCourse(courseId) + return listOf( + Enrollment( + enrollmentState = EnrollmentAPI.STATE_ACTIVE, + grades = grade?.let { Grades(currentScore = it.currentScore) }, + ) + ) + } + + suspend fun saveScoreData( + courseId: Long, + assignmentGroups: List, + enrollments: List, + ) { + val groupEntities = assignmentGroups.map { it.toGroupEntity(courseId) } + val assignmentEntities = assignmentGroups.flatMap { group -> + group.assignments.map { it.toAssignmentEntity(group.id, courseId) } + } + val activeEnrollment = enrollments.firstOrNull { it.enrollmentState == EnrollmentAPI.STATE_ACTIVE } + val gradeEntity = activeEnrollment?.currentScore?.let { + HorizonCourseGradeEntity(courseId = courseId, currentScore = it) + } + courseScoreDao.replaceForCourse(courseId, groupEntities, assignmentEntities, gradeEntity) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.COURSE_SCORES, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonCourseAssignmentGroupEntity.toAssignmentGroup( + assignmentEntities: List, + ): AssignmentGroup { + return AssignmentGroup( + id = groupId, + name = name, + groupWeight = groupWeight, + assignments = assignmentEntities.map { it.toAssignment() }, + ) + } + + private fun HorizonCourseAssignmentEntity.toAssignment(): Assignment { + val hasSubmissionData = submissionGrade != null || submissionWorkflowState != null + || submissionExcused || submissionMissing || submissionLate + || submissionPostedAtMs != null || submissionCustomGradeStatusId != null + val submission = if (hasSubmissionData) { + Submission( + grade = submissionGrade, + workflowState = submissionWorkflowState, + excused = submissionExcused, + missing = submissionMissing, + late = submissionLate, + postedAt = submissionPostedAtMs?.let { Date(it) }, + customGradeStatusId = submissionCustomGradeStatusId, + submissionComments = List(submissionCommentsCount) { SubmissionComment() }, + ) + } else null + return Assignment( + id = assignmentId, + name = name, + pointsPossible = pointsPossible, + dueAt = dueAt, + submission = submission, + ) + } + + private fun AssignmentGroup.toGroupEntity(courseId: Long): HorizonCourseAssignmentGroupEntity { + return HorizonCourseAssignmentGroupEntity( + groupId = id, + courseId = courseId, + name = name, + groupWeight = groupWeight, + ) + } + + private fun Assignment.toAssignmentEntity(groupId: Long, courseId: Long): HorizonCourseAssignmentEntity { + val lastSubmission = submission?.takeIf { + it.workflowState == "graded" || it.workflowState == "submitted" + } + return HorizonCourseAssignmentEntity( + assignmentId = id, + groupId = groupId, + courseId = courseId, + name = name, + pointsPossible = pointsPossible, + dueAt = dueAt, + submissionGrade = submission?.grade, + submissionWorkflowState = submission?.workflowState, + submissionExcused = submission?.excused ?: false, + submissionMissing = submission?.missing ?: false, + submissionLate = submission?.late ?: false, + submissionPostedAtMs = submission?.postedAt?.time, + submissionCustomGradeStatusId = submission?.customGradeStatusId, + submissionCommentsCount = lastSubmission?.submissionComments?.size ?: 0, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreNetworkDataSource.kt similarity index 56% rename from libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreNetworkDataSource.kt index 25d9d6e074..f9063368d6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseScoreNetworkDataSource.kt @@ -1,20 +1,19 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * http://www.apache.org/licenses/LICENSE-2.0 * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ -package com.instructure.horizon.features.learn.course.details.score +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.AssignmentAPI import com.instructure.canvasapi2.apis.EnrollmentAPI @@ -25,22 +24,23 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.depaginate import javax.inject.Inject -class CourseScoreRepository @Inject constructor( +class CourseScoreNetworkDataSource @Inject constructor( private val assignmentApi: AssignmentAPI.AssignmentInterface, private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, - private val apiPrefs: ApiPrefs + private val apiPrefs: ApiPrefs, ) { - suspend fun getAssignmentGroups(courseId: Long, forceRefresh: Boolean = false): List { + + suspend fun getAssignmentGroups(courseId: Long, forceRefresh: Boolean): List { val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) return assignmentApi.getFirstPageAssignmentGroupListWithAssignments(courseId, restParams) .depaginate { assignmentApi.getNextPageAssignmentGroupListWithAssignments(it, restParams) } .dataOrThrow } - suspend fun getEnrollments(courseId: Long, forceNetwork: Boolean): List { - val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceNetwork) + suspend fun getEnrollments(courseId: Long, forceRefresh: Boolean): List { + val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) return enrollmentApi.getEnrollmentsForUserInCourse(courseId, apiPrefs.user?.id ?: -1, restParams) .depaginate { enrollmentApi.getNextPage(it, restParams) } .dataOrThrow } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt new file mode 100644 index 0000000000..86bdb26e0c --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryLocalDataSource.kt @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryModuleInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonLearnCollectionEntity +import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity +import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date +import javax.inject.Inject + +class LearnLearningLibraryLocalDataSource @Inject constructor( + private val collectionDao: HorizonLearnCollectionDao, + private val savedItemDao: HorizonLearnSavedItemDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getEnrolledLearningLibraries(): List { + val collections = collectionDao.getAllCollections() + return collections.map { collection -> + val items = collectionDao.getItemsByCollectionId(collection.id).mapNotNull { it.toModel() } + collection.toModel(items) + } + } + + suspend fun saveEnrolledLearningLibraries(collections: List) { + val collectionEntities = collections.map { it.toEntity() } + val itemEntities = collections.flatMap { collection -> + collection.items.map { it.toCollectionItemEntity(collection.id) } + } + collectionDao.replaceAll(collectionEntities, itemEntities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.LEARN_LIBRARY_COLLECTIONS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + suspend fun getSavedItems(): LearningLibraryCollectionItemsResponse { + val items = savedItemDao.getAll().mapNotNull { it.toModel() } + return LearningLibraryCollectionItemsResponse( + items = items, + pageInfo = LearningLibraryPageInfo( + nextCursor = null, + previousCursor = null, + hasNextPage = false, + hasPreviousPage = false, + totalCount = items.size, + pageCursors = null, + ) + ) + } + + suspend fun saveSavedItems(items: List) { + val entities = items.map { it.toSavedItemEntity() } + savedItemDao.replaceAll(entities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.LEARN_SAVED_ITEMS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun HorizonLearnCollectionEntity.toModel(items: List): EnrolledLearningLibraryCollection { + return EnrolledLearningLibraryCollection( + id = id, + name = name, + publicName = publicName, + description = description, + createdAt = Date(createdAtMs), + updatedAt = Date(updatedAtMs), + totalItemCount = totalItemCount, + items = items, + ) + } + + private fun HorizonLearnCollectionItemEntity.toModel(): LearningLibraryCollectionItem? { + val resolvedItemType = CollectionItemType.safeValueOf(itemType) ?: return null + val canvasCourse = if (canvasCourseId != null && canvasUrl != null) { + CanvasCourseInfo( + courseId = canvasCourseId, + canvasUrl = canvasUrl, + courseName = courseName, + courseImageUrl = courseImageUrl, + moduleCount = moduleCount ?: 0.0, + moduleItemCount = moduleItemCount ?: 0.0, + estimatedDurationMinutes = estimatedDurationMinutes, + ) + } else null + val moduleInfo = if (moduleItemId != null) { + LearningLibraryModuleInfo( + moduleId = moduleId, + moduleItemId = moduleItemId, + moduleItemType = moduleItemType, + resourceId = resourceId, + ) + } else null + return LearningLibraryCollectionItem( + id = id, + libraryId = libraryId, + itemType = resolvedItemType, + displayOrder = displayOrder, + canvasCourse = canvasCourse, + moduleInfo = moduleInfo, + programId = programId, + programCourseId = programCourseId, + createdAt = Date(createdAtMs), + updatedAt = Date(updatedAtMs), + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } + + private fun HorizonLearnSavedItemEntity.toModel(): LearningLibraryCollectionItem? { + val resolvedItemType = CollectionItemType.safeValueOf(itemType) ?: return null + val canvasCourse = if (canvasCourseId != null && canvasUrl != null) { + CanvasCourseInfo( + courseId = canvasCourseId, + canvasUrl = canvasUrl, + courseName = courseName, + courseImageUrl = courseImageUrl, + moduleCount = moduleCount ?: 0.0, + moduleItemCount = moduleItemCount ?: 0.0, + estimatedDurationMinutes = estimatedDurationMinutes, + ) + } else null + val moduleInfo = if (moduleItemId != null) { + LearningLibraryModuleInfo( + moduleId = moduleId, + moduleItemId = moduleItemId, + moduleItemType = moduleItemType, + resourceId = resourceId, + ) + } else null + return LearningLibraryCollectionItem( + id = id, + libraryId = libraryId, + itemType = resolvedItemType, + displayOrder = displayOrder, + canvasCourse = canvasCourse, + moduleInfo = moduleInfo, + programId = programId, + programCourseId = programCourseId, + createdAt = Date(createdAtMs), + updatedAt = Date(updatedAtMs), + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } + + private fun EnrolledLearningLibraryCollection.toEntity(): HorizonLearnCollectionEntity { + return HorizonLearnCollectionEntity( + id = id, + name = name, + publicName = publicName, + description = description, + createdAtMs = createdAt.time, + updatedAtMs = updatedAt.time, + totalItemCount = totalItemCount, + ) + } + + private fun LearningLibraryCollectionItem.toCollectionItemEntity(collectionId: String): HorizonLearnCollectionItemEntity { + return HorizonLearnCollectionItemEntity( + id = id, + collectionId = collectionId, + libraryId = libraryId, + itemType = itemType.name, + displayOrder = displayOrder, + canvasCourseId = canvasCourse?.courseId, + canvasUrl = canvasCourse?.canvasUrl, + courseName = canvasCourse?.courseName, + courseImageUrl = canvasCourse?.courseImageUrl, + moduleCount = canvasCourse?.moduleCount, + moduleItemCount = canvasCourse?.moduleItemCount, + estimatedDurationMinutes = canvasCourse?.estimatedDurationMinutes, + moduleId = moduleInfo?.moduleId, + moduleItemId = moduleInfo?.moduleItemId, + moduleItemType = moduleInfo?.moduleItemType, + resourceId = moduleInfo?.resourceId, + programId = programId, + programCourseId = programCourseId, + createdAtMs = createdAt.time, + updatedAtMs = updatedAt.time, + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } + + private fun LearningLibraryCollectionItem.toSavedItemEntity(): HorizonLearnSavedItemEntity { + return HorizonLearnSavedItemEntity( + id = id, + libraryId = libraryId, + itemType = itemType.name, + displayOrder = displayOrder, + canvasCourseId = canvasCourse?.courseId, + canvasUrl = canvasCourse?.canvasUrl, + courseName = canvasCourse?.courseName, + courseImageUrl = canvasCourse?.courseImageUrl, + moduleCount = canvasCourse?.moduleCount, + moduleItemCount = canvasCourse?.moduleItemCount, + estimatedDurationMinutes = canvasCourse?.estimatedDurationMinutes, + moduleId = moduleInfo?.moduleId, + moduleItemId = moduleInfo?.moduleItemId, + moduleItemType = moduleInfo?.moduleItemType, + resourceId = moduleInfo?.resourceId, + programId = programId, + programCourseId = programCourseId, + createdAtMs = createdAt.time, + updatedAtMs = updatedAt.time, + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasEnrollmentId = canvasEnrollmentId, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryNetworkDataSource.kt new file mode 100644 index 0000000000..7746c99393 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnLearningLibraryNetworkDataSource.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import javax.inject.Inject + +class LearnLearningLibraryNetworkDataSource @Inject constructor( + private val getLearningLibraryManager: GetLearningLibraryManager, +) { + + suspend fun getEnrolledLearningLibraries(limit: Int, forceRefresh: Boolean): List { + return getLearningLibraryManager.getEnrolledLearningLibraryCollections(limit, forceNetwork = forceRefresh).collections + } + + suspend fun getLearningLibraryItems( + cursor: String?, + limit: Int?, + searchQuery: String?, + typeFilter: CollectionItemType?, + bookmarkedOnly: Boolean, + completedOnly: Boolean, + sortBy: CollectionItemSortOption?, + forceRefresh: Boolean, + ): LearningLibraryCollectionItemsResponse { + return getLearningLibraryManager.getLearningLibraryCollectionItems( + cursor = cursor, + limit = limit, + bookmarkedOnly = bookmarkedOnly, + completedOnly = completedOnly, + searchTerm = searchQuery, + types = typeFilter?.let { listOf(it) }, + sortBy = sortBy, + forceNetwork = forceRefresh, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt new file mode 100644 index 0000000000..315e402205 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentLocalDataSource.kt @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem +import com.instructure.canvasapi2.models.journey.mycontent.LearnItem +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseEntity +import com.instructure.horizon.database.entity.HorizonLearnItemEntity +import com.instructure.horizon.database.entity.HorizonProgramEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date +import javax.inject.Inject + +class LearnMyContentLocalDataSource @Inject constructor( + private val learnItemDao: HorizonLearnItemDao, + private val courseDao: HorizonCourseDao, + private val programDao: HorizonProgramDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getLearnItems( + queryKey: String, + searchQuery: String?, + sortBy: CollectionItemSortOption?, + itemTypes: List?, + cursor: String?, + ): LearnItemsResponse { + val listItems = learnItemDao.getByQueryKey(queryKey) + + var items: List = listItems.mapNotNull { listItem -> + when (listItem.itemType) { + LearnItemType.COURSE.name -> { + val entity = courseDao.getByCourseId(listItem.id.toLong()) ?: return@mapNotNull null + entity.toCourseEnrollmentItem(listItem.position) + } + LearnItemType.PROGRAM.name -> { + val entity = programDao.getById(listItem.id) ?: return@mapNotNull null + entity.toProgramEnrollmentItem(listItem.position) + } + else -> null + } + } + + if (!itemTypes.isNullOrEmpty()) { + items = items.filter { item -> + itemTypes.any { type -> + when (type) { + LearnItemType.PROGRAM -> item is ProgramEnrollmentItem + LearnItemType.COURSE -> item is CourseEnrollmentItem + } + } + } + } + + if (!searchQuery.isNullOrBlank()) { + items = items.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + + items = when (sortBy) { + CollectionItemSortOption.NAME_A_Z -> items.sortedBy { it.name } + CollectionItemSortOption.NAME_Z_A -> items.sortedByDescending { it.name } + CollectionItemSortOption.MOST_RECENT -> items.sortedByDescending { it.enrolledAt?.time ?: 0L } + CollectionItemSortOption.LEAST_RECENT -> items.sortedBy { it.enrolledAt?.time ?: Long.MAX_VALUE } + null -> items.sortedBy { it.position } + } + + val offset = cursor?.toIntOrNull() ?: 0 + val page = items.drop(offset).take(PAGE_SIZE) + val hasNextPage = offset + PAGE_SIZE < items.size + return LearnItemsResponse( + items = page, + pageInfo = LearningLibraryPageInfo( + nextCursor = if (hasNextPage) (offset + PAGE_SIZE).toString() else null, + previousCursor = null, + hasNextPage = hasNextPage, + hasPreviousPage = false, + totalCount = items.size, + pageCursors = null, + ) + ) + } + + suspend fun saveLearnItems(items: List, queryKey: String) { + items.forEach { item -> + when (item) { + is CourseEnrollmentItem -> { + val courseId = item.id.toLong() + courseDao.insertIfAbsent(listOf(item.toDefaultEntity())) + courseDao.updateEnrollmentFields( + courseId = courseId, + name = item.name, + progress = item.completionPercentage ?: 0.0, + imageUrl = item.imageUrl, + startAtMs = item.startAt?.time, + endAtMs = item.endAt?.time, + requirementCount = item.requirementCount, + requirementCompletedCount = item.requirementCompletedCount, + completedAtMs = item.completedAt?.time, + grade = item.grade, + workflowState = item.workflowState, + lastActivityAtMs = item.lastActivityAt?.time, + enrolledAtMs = item.enrolledAt?.time, + ) + } + is ProgramEnrollmentItem -> { + programDao.insertAll(listOf(item.toDefaultEntity())) + } + } + } + learnItemDao.replaceByQueryKey(items.map { it.toListEntity(queryKey) }, queryKey) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = syncDataTypeFor(queryKey), + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + private fun syncDataTypeFor(queryKey: String): SyncDataType = when (queryKey) { + QUERY_KEY_IN_PROGRESS -> SyncDataType.LEARN_MY_CONTENT_IN_PROGRESS + QUERY_KEY_COMPLETED -> SyncDataType.LEARN_MY_CONTENT_COMPLETED + else -> throw IllegalArgumentException("Unknown queryKey: $queryKey") + } + + companion object { + const val QUERY_KEY_IN_PROGRESS = "IN_PROGRESS" + const val QUERY_KEY_COMPLETED = "COMPLETED" + private const val PAGE_SIZE = 4 + } + + private fun HorizonCourseEntity.toCourseEnrollmentItem(position: Int): CourseEnrollmentItem { + return CourseEnrollmentItem( + id = courseId.toString(), + name = name, + position = position, + enrolledAt = enrolledAtMs?.let { Date(it) }, + completionPercentage = progress, + startAt = startAtMs?.let { Date(it) }, + endAt = endAtMs?.let { Date(it) }, + requirementCount = requirementCount, + requirementCompletedCount = requirementCompletedCount, + completedAt = completedAtMs?.let { Date(it) }, + grade = grade, + imageUrl = imageUrl, + workflowState = workflowState.orEmpty(), + lastActivityAt = lastActivityAtMs?.let { Date(it) }, + ) + } + + private fun HorizonProgramEntity.toProgramEnrollmentItem(position: Int): ProgramEnrollmentItem { + return ProgramEnrollmentItem( + id = programId, + name = name, + position = position, + enrolledAt = enrolledAtMs?.let { Date(it) }, + completionPercentage = completionPercentage, + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + status = enrollmentStatus.orEmpty(), + description = description, + variant = variant, + estimatedDurationMinutes = estimatedDurationMinutes, + courseCount = courseCount ?: 0, + ) + } + + private fun CourseEnrollmentItem.toDefaultEntity(): HorizonCourseEntity { + return HorizonCourseEntity( + courseId = id.toLong(), + name = name, + progress = completionPercentage ?: 0.0, + imageUrl = imageUrl, + startAtMs = startAt?.time, + endAtMs = endAt?.time, + requirementCount = requirementCount, + requirementCompletedCount = requirementCompletedCount, + completedAtMs = completedAt?.time, + grade = grade, + workflowState = workflowState, + lastActivityAtMs = lastActivityAt?.time, + enrolledAtMs = enrolledAt?.time, + courseSyllabus = null, + moduleItemsDurations = "", + ) + } + + private fun ProgramEnrollmentItem.toDefaultEntity(): HorizonProgramEntity { + return HorizonProgramEntity( + programId = id, + name = name, + description = description, + startDateMs = startDate?.time, + endDateMs = endDate?.time, + variant = variant, + estimatedDurationMinutes = estimatedDurationMinutes, + courseCount = courseCount, + courseCompletionCount = null, + enrolledAtMs = enrolledAt?.time, + completionPercentage = completionPercentage, + enrollmentStatus = status, + ) + } + + private fun LearnItem.toListEntity(queryKey: String): HorizonLearnItemEntity { + return HorizonLearnItemEntity( + id = id, + queryKey = queryKey, + itemType = when (this) { + is CourseEnrollmentItem -> LearnItemType.COURSE.name + is ProgramEnrollmentItem -> LearnItemType.PROGRAM.name + }, + position = position, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentNetworkDataSource.kt new file mode 100644 index 0000000000..80315e88c6 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/LearnMyContentNetworkDataSource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.MyContentManager +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import javax.inject.Inject + +class LearnMyContentNetworkDataSource @Inject constructor( + private val myContentManager: MyContentManager, +) { + + suspend fun getLearnItems( + cursor: String?, + searchQuery: String?, + sortBy: CollectionItemSortOption?, + status: List?, + itemTypes: List?, + forceRefresh: Boolean, + ): LearnItemsResponse { + return myContentManager.getLearnItems( + cursor = cursor, + searchTerm = searchQuery, + sortBy = sortBy, + status = status, + itemTypes = itemTypes, + forceNetwork = forceRefresh, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt index baa01de2f3..0c7b867f14 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt @@ -26,8 +26,8 @@ class ModuleItemNetworkDataSource @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, ) { - suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { - val params = RestParams(isForceReadFromNetwork = true) + suspend fun getNextModuleItemForCourse(courseId: Long, forceRefresh: Boolean): DashboardNextModuleItem? { + val params = RestParams(isForceReadFromNetwork = forceRefresh) val modules = moduleApi.getFirstPageModulesWithItems( CanvasContext.Type.COURSE.apiString, courseId, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt new file mode 100644 index 0000000000..1505609749 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsLocalDataSource.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.entity.HorizonCourseEntity +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity +import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import com.instructure.journey.type.ProgramVariantType +import java.util.Date +import javax.inject.Inject + +class ProgramDetailsLocalDataSource @Inject constructor( + private val programDao: HorizonProgramDao, + private val courseDao: HorizonCourseDao, +) { + + suspend fun getProgramDetails(programId: String): Program { + val entity = programDao.getById(programId) + ?: throw IllegalArgumentException("Program with id $programId not found in cache") + val refs = programDao.getRefsForProgram(programId) + return entity.toProgram(refs) + } + + suspend fun getCoursesById(courseIds: List): List { + return courseDao.getByCourseIds(courseIds).map { it.toCourseWithModuleItemDurations() } + } + + suspend fun saveProgramDetails(program: Program) { + val entity = HorizonProgramEntity( + programId = program.id, + name = program.name, + description = program.description, + startDateMs = program.startDate?.time, + endDateMs = program.endDate?.time, + variant = program.variant.rawValue, + estimatedDurationMinutes = null, + courseCount = program.sortedRequirements.size, + courseCompletionCount = program.courseCompletionCount, + enrolledAtMs = null, + completionPercentage = null, + enrollmentStatus = null, + ) + val refs = program.sortedRequirements.mapIndexed { index, req -> + HorizonProgramCourseRef( + programId = program.id, + courseId = req.courseId, + requirementId = req.id, + progressId = req.progressId, + required = req.required, + progress = req.progress, + enrollmentStatus = req.enrollmentStatus?.rawValue, + sortOrder = index, + ) + } + programDao.upsertProgram(entity, refs) + } + + suspend fun saveCourses(courses: List) { + courses.forEach { course -> + courseDao.insertIfAbsent(listOf(course.toDefaultEntity())) + courseDao.updateProgramCourseFields( + courseId = course.courseId, + name = course.courseName, + startAtMs = course.startDate?.time, + endAtMs = course.endDate?.time, + moduleItemsDurations = course.moduleItemsDuration.joinToString(","), + ) + } + } + + private fun HorizonProgramEntity.toProgram( + refs: List + ): Program { + val requirements = refs.sortedBy { it.sortOrder }.map { ref -> + ProgramRequirement( + id = ref.requirementId, + progressId = ref.progressId, + courseId = ref.courseId, + required = ref.required, + progress = ref.progress, + enrollmentStatus = ref.enrollmentStatus?.let { + runCatching { ProgramProgressCourseEnrollmentStatus.valueOf(it) }.getOrNull() + }, + ) + } + return Program( + id = programId, + name = name, + description = description, + startDate = startDateMs?.let { Date(it) }, + endDate = endDateMs?.let { Date(it) }, + variant = runCatching { ProgramVariantType.valueOf(variant) }.getOrDefault(ProgramVariantType.LINEAR), + courseCompletionCount = courseCompletionCount, + sortedRequirements = requirements, + ) + } + + private fun HorizonCourseEntity.toCourseWithModuleItemDurations(): CourseWithModuleItemDurations { + return CourseWithModuleItemDurations( + courseId = courseId, + courseName = name, + moduleItemsDuration = if (moduleItemsDurations.isEmpty()) emptyList() else moduleItemsDurations.split(","), + startDate = startAtMs?.let { Date(it) }, + endDate = endAtMs?.let { Date(it) }, + ) + } + + private fun CourseWithModuleItemDurations.toDefaultEntity(): HorizonCourseEntity { + return HorizonCourseEntity( + courseId = courseId, + name = courseName, + progress = 0.0, + imageUrl = null, + startAtMs = startDate?.time, + endAtMs = endDate?.time, + requirementCount = null, + requirementCompletedCount = null, + completedAtMs = null, + grade = null, + workflowState = null, + lastActivityAtMs = null, + enrolledAtMs = null, + courseSyllabus = null, + moduleItemsDurations = moduleItemsDuration.joinToString(","), + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsNetworkDataSource.kt similarity index 71% rename from libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsNetworkDataSource.kt index e37ae4f712..06ef61a016 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramDetailsNetworkDataSource.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.learn.program.details +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager @@ -25,23 +25,23 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import javax.inject.Inject -class ProgramDetailsRepository @Inject constructor( +class ProgramDetailsNetworkDataSource @Inject constructor( private val getProgramsManager: GetProgramsManager, - private val getCoursesManager: HorizonGetCoursesManager + private val getCoursesManager: HorizonGetCoursesManager, ) { - suspend fun getProgramDetails(programId: String, forceNetwork: Boolean = false): Program { - val program = getProgramsManager.getPrograms(forceNetwork).find { it.id == programId } + + suspend fun getProgramDetails(programId: String, forceRefresh: Boolean): Program { + return getProgramsManager.getPrograms(forceRefresh).find { it.id == programId } ?: throw IllegalArgumentException("Program with id $programId not found") - return program } - suspend fun getCoursesById(courseIds: List, forceNetwork: Boolean = false): List = coroutineScope { + suspend fun getCoursesById(courseIds: List, forceRefresh: Boolean): List = coroutineScope { courseIds.map { id -> - async { getCoursesManager.getProgramCourses(id, forceNetwork).dataOrThrow } + async { getCoursesManager.getProgramCourses(id, forceRefresh).dataOrThrow } }.awaitAll() } suspend fun enrollCourse(progressId: String): DataResult { return getProgramsManager.enrollCourse(progressId) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt index 20c4ba6cb4..63af3b1476 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt @@ -17,16 +17,16 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao -import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity +import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType import java.util.Date import javax.inject.Inject class ProgramLocalDataSource @Inject constructor( - private val programDao: HorizonDashboardProgramDao, + private val programDao: HorizonProgramDao, ) { suspend fun getPrograms(): List { @@ -34,7 +34,7 @@ class ProgramLocalDataSource @Inject constructor( val refs = programDao.getRefsForProgram(programEntity.programId) Program( id = programEntity.programId, - name = programEntity.programName, + name = programEntity.name, description = programEntity.description, startDate = programEntity.startDateMs?.let { Date(it) }, endDate = programEntity.endDateMs?.let { Date(it) }, @@ -58,21 +58,26 @@ class ProgramLocalDataSource @Inject constructor( suspend fun savePrograms(programs: List, enrolledCourseIds: Set) { val programEntities = programs.map { program -> - HorizonDashboardProgramEntity( + HorizonProgramEntity( programId = program.id, - programName = program.name, + name = program.name, description = program.description, startDateMs = program.startDate?.time, endDateMs = program.endDate?.time, variant = program.variant.rawValue, + estimatedDurationMinutes = null, + courseCount = program.sortedRequirements.size, courseCompletionCount = program.courseCompletionCount, + enrolledAtMs = null, + completionPercentage = null, + enrollmentStatus = null, ) } val refs = programs.flatMap { program -> program.sortedRequirements .filter { it.courseId in enrolledCourseIds } .mapIndexed { index, req -> - HorizonDashboardProgramCourseRef( + HorizonProgramCourseRef( programId = program.id, courseId = req.courseId, requirementId = req.id, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt index aaea7ad793..0dee36635a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.kt @@ -23,7 +23,7 @@ class ProgramNetworkDataSource @Inject constructor( private val getProgramsManager: GetProgramsManager, ) { - suspend fun getPrograms(): List { - return getProgramsManager.getPrograms(forceNetwork = true) + suspend fun getPrograms(forceRefresh: Boolean): List { + return getProgramsManager.getPrograms(forceNetwork = forceRefresh) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt index f68048afbd..2e609258c3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.kt @@ -30,9 +30,9 @@ class CourseEnrollmentRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getEnrollments(): List { + suspend fun getEnrollments(forceRefresh: Boolean = false): List { return if (shouldFetchFromNetwork()) { - networkDataSource.getEnrollments() + networkDataSource.getEnrollments(forceRefresh) .also { if (shouldSync()) localDataSource.saveEnrollments(it) } } else { localDataSource.getEnrollments() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt new file mode 100644 index 0000000000..78f6027d03 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseRepository.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.datasource.CourseDetailsLocalDataSource +import com.instructure.horizon.data.datasource.CourseDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.CourseProgressLocalDataSource +import com.instructure.horizon.data.datasource.CourseProgressNetworkDataSource +import com.instructure.horizon.di.HorizonHtmlParserQualifier +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class CourseRepository @Inject constructor( + private val courseDetailsNetworkDataSource: CourseDetailsNetworkDataSource, + private val courseDetailsLocalDataSource: CourseDetailsLocalDataSource, + private val courseProgressNetworkDataSource: CourseProgressNetworkDataSource, + private val courseProgressLocalDataSource: CourseProgressLocalDataSource, + @HorizonHtmlParserQualifier private val htmlParser: HtmlParser, + private val fileSyncRepository: HorizonFileSyncRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getCourse(courseId: Long, forceRefresh: Boolean): CourseWithProgress { + return if (shouldFetchFromNetwork()) { + courseDetailsNetworkDataSource.getCourse(courseId, forceRefresh) + .also { course -> + if (shouldSync()) { + val parsedSyllabus = htmlParser.createHtmlStringWithLocalFiles(course.courseSyllabus, course.courseId) + val programs = courseDetailsNetworkDataSource.getProgramsForCourse(courseId, forceRefresh) + courseDetailsLocalDataSource.saveCourseDetails(course.copy(courseSyllabus = parsedSyllabus.htmlWithLocalFileLinks), programs) + fileSyncRepository.syncHtmlFiles(course.courseId, parsedSyllabus) + } + } + } else { + courseDetailsLocalDataSource.getCourse(courseId) + } + } + + suspend fun getProgramsForCourse(courseId: Long, forceRefresh: Boolean): List { + return if (shouldFetchFromNetwork()) { + courseDetailsNetworkDataSource.getProgramsForCourse(courseId, forceRefresh) + } else { + courseDetailsLocalDataSource.getProgramsForCourse(courseId) + } + } + + suspend fun hasExternalTools(courseId: Long, forceRefresh: Boolean): Boolean { + return if (shouldFetchFromNetwork()) { + courseDetailsNetworkDataSource.hasExternalTools(courseId, forceRefresh) + } else { + false + } + } + + suspend fun getModuleItems(courseId: Long, forceRefresh: Boolean): List { + return if (shouldFetchFromNetwork()) { + courseProgressNetworkDataSource.getModuleItems(courseId, forceRefresh) + .also { if (shouldSync()) courseProgressLocalDataSource.saveModuleItems(courseId, it) } + } else { + courseProgressLocalDataSource.getModuleItems(courseId) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseScoreRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseScoreRepository.kt new file mode 100644 index 0000000000..0b2d7bb93a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseScoreRepository.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.AssignmentGroup +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.horizon.data.datasource.CourseScoreLocalDataSource +import com.instructure.horizon.data.datasource.CourseScoreNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class CourseScoreRepository @Inject constructor( + private val networkDataSource: CourseScoreNetworkDataSource, + private val localDataSource: CourseScoreLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getAssignmentGroups(courseId: Long, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getAssignmentGroups(courseId, forceRefresh) + } else { + localDataSource.getAssignmentGroups(courseId) + } + } + + suspend fun getEnrollments(courseId: Long, forceRefresh: Boolean): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getEnrollments(courseId, forceRefresh) + } else { + localDataSource.getEnrollments(courseId) + } + } + + suspend fun saveScoreData(courseId: Long, assignmentGroups: List, enrollments: List) { + if (shouldSync()) { + localDataSource.saveScoreData(courseId, assignmentGroups, enrollments) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt new file mode 100644 index 0000000000..5a702c1306 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt @@ -0,0 +1,120 @@ +package com.instructure.horizon.data.repository + +import android.content.Context +import android.net.Uri +import com.instructure.canvasapi2.apis.DownloadState +import com.instructure.canvasapi2.apis.FileDownloadAPI +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.apis.saveFile +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.entity.HorizonFileFolderEntity +import com.instructure.horizon.database.entity.HorizonLocalFileEntity +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParsingResult +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Date +import javax.inject.Inject + +class HorizonFileSyncRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val fileDownloadApi: FileDownloadAPI, + private val localFileDao: HorizonLocalFileDao, + private val fileFolderDao: HorizonFileFolderDao, + private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, + private val apiPrefs: ApiPrefs, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun syncHtmlFiles(courseId: Long, parsingResult: HtmlParsingResult) = withContext(Dispatchers.IO) { + val alreadyDownloadedIds = localFileDao.findByCourseId(courseId).map { it.id }.toSet() + val internalFileIdsToSync = parsingResult.internalFileIds.filterNot { alreadyDownloadedIds.contains(it) } + + coroutineScope { + internalFileIdsToSync.chunked(6).forEach { chunk -> + chunk.map { fileId -> async { downloadInternalFile(fileId, courseId) } }.awaitAll() + } + } + + coroutineScope { + parsingResult.externalFileUrls.toList().chunked(6).forEach { chunk -> + chunk.map { url -> async { downloadExternalFile(url, courseId) } }.awaitAll() + } + } + } + + private suspend fun downloadInternalFile(fileId: Long, courseId: Long) { + val fileInfo = resolveInternalFileInfo(fileId, courseId) ?: return + val dir = File(context.filesDir, apiPrefs.user?.id.toString()).also { it.mkdirs() } + val destFile = File(dir, "${fileId}_${fileInfo.displayName}") + + if (destFile.exists()) return + + downloadToFile(fileInfo.url, destFile, shouldIgnoreToken = false) { + localFileDao.insert( + HorizonLocalFileEntity( + fileId, + courseId, + Date(), + destFile.absolutePath + ) + ) + } + } + + private suspend fun resolveInternalFileInfo(fileId: Long, courseId: Long): HorizonFileFolderEntity? { + fileFolderDao.findById(fileId)?.let { return it } + + val file = fileFolderApi.getCourseFile( + courseId, fileId, + RestParams(isForceReadFromNetwork = true, shouldLoginOnTokenError = false) + ).dataOrNull ?: return null + + val url = file.url ?: return null + val displayName = file.displayName ?: return null + + val entity = HorizonFileFolderEntity(fileId, url, displayName) + fileFolderDao.insert(entity) + return entity + } + + private suspend fun downloadExternalFile(url: String, courseId: Long) { + val fileName = Uri.parse(url).lastPathSegment ?: return + val dir = File(context.filesDir, "${apiPrefs.user?.id}/external_$courseId").also { it.mkdirs() } + val destFile = File(dir, fileName) + + if (destFile.exists()) return + + downloadToFile(url, destFile, shouldIgnoreToken = true) {} + } + + private suspend fun downloadToFile(url: String, destFile: File, shouldIgnoreToken: Boolean, onSuccess: suspend () -> Unit) { + val body = fileDownloadApi.downloadFile( + url, + RestParams(shouldIgnoreToken = shouldIgnoreToken, shouldLoginOnTokenError = false) + ).dataOrNull ?: return + + body.saveFile(destFile).collect { state -> + when (state) { + is DownloadState.Success -> onSuccess() + is DownloadState.Failure -> destFile.delete() + else -> {} + } + } + } + + override suspend fun sync() { + TODO("Not yet implemented — will sync all/selected course files") + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnLearningLibraryRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnLearningLibraryRepository.kt new file mode 100644 index 0000000000..387df0528d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnLearningLibraryRepository.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.horizon.data.datasource.LearnLearningLibraryLocalDataSource +import com.instructure.horizon.data.datasource.LearnLearningLibraryNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class LearnLearningLibraryRepository @Inject constructor( + private val networkDataSource: LearnLearningLibraryNetworkDataSource, + private val localDataSource: LearnLearningLibraryLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getEnrolledLearningLibraries(limit: Int, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getEnrolledLearningLibraries(limit, forceRefresh) + .also { if (shouldSync()) localDataSource.saveEnrolledLearningLibraries(it) } + } else { + localDataSource.getEnrolledLearningLibraries() + } + } + + suspend fun getLearningLibraryItems( + cursor: String?, + limit: Int?, + searchQuery: String?, + typeFilter: CollectionItemType?, + bookmarkedOnly: Boolean, + completedOnly: Boolean, + sortBy: CollectionItemSortOption?, + forceRefresh: Boolean = false, + ): LearningLibraryCollectionItemsResponse { + return if (shouldFetchFromNetwork()) { + networkDataSource.getLearningLibraryItems(cursor, limit, searchQuery, typeFilter, bookmarkedOnly, completedOnly, sortBy, forceRefresh) + .also { response -> + if (shouldSync() && cursor == null && bookmarkedOnly) { + localDataSource.saveSavedItems(response.items) + } + } + } else { + localDataSource.getSavedItems() + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt new file mode 100644 index 0000000000..7abcc0425a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/LearnMyContentRepository.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItem +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.horizon.data.datasource.LearnMyContentLocalDataSource +import com.instructure.horizon.data.datasource.LearnMyContentNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class LearnMyContentRepository @Inject constructor( + private val networkDataSource: LearnMyContentNetworkDataSource, + private val localDataSource: LearnMyContentLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getLearnItems( + cursor: String?, + searchQuery: String?, + sortBy: CollectionItemSortOption?, + status: List?, + itemTypes: List?, + queryKey: String, + forceRefresh: Boolean = false, + ): LearnItemsResponse { + return if (shouldFetchFromNetwork()) { + networkDataSource.getLearnItems(cursor, searchQuery, sortBy, status, itemTypes, forceRefresh) + .also { + if (shouldSync() && cursor == null) { + depaginateAndSync(status, queryKey) + } + } + } else { + localDataSource.getLearnItems(queryKey, searchQuery, sortBy, itemTypes, cursor) + } + } + + private suspend fun depaginateAndSync(status: List?, queryKey: String) { + val allItems = mutableListOf() + var nextCursor: String? = null + do { + val page = networkDataSource.getLearnItems( + cursor = nextCursor, + searchQuery = null, + sortBy = null, + status = status, + itemTypes = null, + forceRefresh = true, + ) + allItems.addAll(page.items) + nextCursor = if (page.pageInfo.hasNextPage) page.pageInfo.nextCursor else null + } while (nextCursor != null) + localDataSource.saveLearnItems(allItems, queryKey) + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt index e7127d6caf..c0142b008c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt @@ -30,9 +30,9 @@ class ModuleItemRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { + suspend fun getNextModuleItemForCourse(courseId: Long, forceRefresh: Boolean = false): DashboardNextModuleItem? { return if (shouldFetchFromNetwork()) { - networkDataSource.getNextModuleItemForCourse(courseId) + networkDataSource.getNextModuleItemForCourse(courseId, forceRefresh) .also { item -> if (shouldSync() && item != null) localDataSource.saveNextModuleItem(item) } } else { localDataSource.getNextModuleItemForCourse(courseId) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt index 5ac37c4f6e..3d4167c5ef 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.kt @@ -15,7 +15,11 @@ */ package com.instructure.horizon.data.repository +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.ProgramDetailsLocalDataSource +import com.instructure.horizon.data.datasource.ProgramDetailsNetworkDataSource import com.instructure.horizon.data.datasource.ProgramLocalDataSource import com.instructure.horizon.data.datasource.ProgramNetworkDataSource import com.instructure.horizon.offline.OfflineSyncRepository @@ -26,14 +30,16 @@ import javax.inject.Inject class ProgramRepository @Inject constructor( private val networkDataSource: ProgramNetworkDataSource, private val localDataSource: ProgramLocalDataSource, + private val programDetailsNetworkDataSource: ProgramDetailsNetworkDataSource, + private val programDetailsLocalDataSource: ProgramDetailsLocalDataSource, private val enrollmentRepository: CourseEnrollmentRepository, networkStateProvider: NetworkStateProvider, featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { - suspend fun getPrograms(): List { + suspend fun getPrograms(forceRefresh: Boolean = false): List { return if (shouldFetchFromNetwork()) { - networkDataSource.getPrograms() + networkDataSource.getPrograms(forceRefresh) .also { programs -> if (shouldSync()) { val enrolledCourseIds = enrollmentRepository.getEnrolledCourseIds().toSet() @@ -45,6 +51,28 @@ class ProgramRepository @Inject constructor( } } + suspend fun getProgramDetails(programId: String, forceRefresh: Boolean = false): Program { + return if (shouldFetchFromNetwork()) { + programDetailsNetworkDataSource.getProgramDetails(programId, forceRefresh) + .also { if (shouldSync()) programDetailsLocalDataSource.saveProgramDetails(it) } + } else { + programDetailsLocalDataSource.getProgramDetails(programId) + } + } + + suspend fun getCoursesById(courseIds: List, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + programDetailsNetworkDataSource.getCoursesById(courseIds, forceRefresh) + .also { if (shouldSync()) programDetailsLocalDataSource.saveCourses(it) } + } else { + programDetailsLocalDataSource.getCoursesById(courseIds) + } + } + + suspend fun enrollCourse(progressId: String): DataResult { + return programDetailsNetworkDataSource.enrollCourse(progressId) + } + override suspend fun sync() { TODO("Not yet implemented") } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index fca3d4a40e..715a2523f9 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -18,30 +18,70 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonCourseScoreDao import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity +import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity +import com.instructure.horizon.database.entity.HorizonCourseEntity +import com.instructure.horizon.database.entity.HorizonCourseGradeEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity import com.instructure.horizon.database.entity.HorizonDashboardEnrollmentEntity import com.instructure.horizon.database.entity.HorizonDashboardModuleItemEntity -import com.instructure.horizon.database.entity.HorizonDashboardProgramCourseRef -import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity +import com.instructure.horizon.database.entity.HorizonFileFolderEntity +import com.instructure.horizon.database.entity.HorizonLearnCollectionEntity +import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity +import com.instructure.horizon.database.entity.HorizonLearnItemEntity +import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity +import com.instructure.horizon.database.entity.HorizonLocalFileEntity +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity @TypeConverters(HorizonTypeConverters::class) @Database( entities = [ HorizonDashboardEnrollmentEntity::class, - HorizonDashboardProgramEntity::class, - HorizonDashboardProgramCourseRef::class, + HorizonProgramEntity::class, + HorizonProgramCourseRef::class, HorizonDashboardModuleItemEntity::class, HorizonSyncMetadataEntity::class, + HorizonLearnItemEntity::class, + HorizonLearnCollectionEntity::class, + HorizonLearnCollectionItemEntity::class, + HorizonLearnSavedItemEntity::class, + HorizonCourseEntity::class, + HorizonCourseModuleEntity::class, + HorizonCourseModuleItemEntity::class, + HorizonCourseAssignmentGroupEntity::class, + HorizonCourseAssignmentEntity::class, + HorizonCourseGradeEntity::class, + HorizonLocalFileEntity::class, + HorizonFileFolderEntity::class, ], - version = 2, + version = 7, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao - abstract fun dashboardProgramDao(): HorizonDashboardProgramDao + abstract fun programDao(): HorizonProgramDao abstract fun dashboardModuleItemDao(): HorizonDashboardModuleItemDao abstract fun syncMetadataDao(): HorizonSyncMetadataDao + abstract fun learnItemDao(): HorizonLearnItemDao + abstract fun learnCollectionDao(): HorizonLearnCollectionDao + abstract fun learnSavedItemDao(): HorizonLearnSavedItemDao + abstract fun courseDao(): HorizonCourseDao + abstract fun courseModuleDao(): HorizonCourseModuleDao + abstract fun courseScoreDao(): HorizonCourseScoreDao + abstract fun localFileDao(): HorizonLocalFileDao + abstract fun fileFolderDao(): HorizonFileFolderDao } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt index aef8525cb3..dbe0554aca 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt @@ -17,21 +17,36 @@ package com.instructure.horizon.database import android.content.Context import androidx.room.Room +import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +private const val HORIZON_DB_PREFIX = "horizon-db-" + @Singleton class HorizonDatabaseProvider @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val firebaseCrashlytics: FirebaseCrashlytics, ) { private val dbMap = mutableMapOf() - fun getDatabase(userId: Long): HorizonDatabase { + @Synchronized + fun getDatabase(userId: Long?): HorizonDatabase { + if (userId == null) { + firebaseCrashlytics.recordException(IllegalStateException("Cannot access Horizon database while logged out")) + return Room.inMemoryDatabaseBuilder(context, HorizonDatabase::class.java).build() + } return dbMap.getOrPut(userId) { - Room.databaseBuilder(context, HorizonDatabase::class.java, "horizon-db-$userId") + Room.databaseBuilder(context, HorizonDatabase::class.java, "$HORIZON_DB_PREFIX$userId") .fallbackToDestructiveMigration() .build() } } + + fun clearDatabase(userId: Long) { + getDatabase(userId).clearAllTables() + dbMap.remove(userId) + context.deleteDatabase("$HORIZON_DB_PREFIX$userId") + } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt index c079e84003..2720cc8eaf 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt @@ -17,6 +17,7 @@ package com.instructure.horizon.database import androidx.room.TypeConverter import com.instructure.horizon.database.entity.SyncDataType +import java.util.Date class HorizonTypeConverters { @@ -25,4 +26,10 @@ class HorizonTypeConverters { @TypeConverter fun toSyncDataType(value: String): SyncDataType = SyncDataType.valueOf(value) + + @TypeConverter + fun fromDate(value: Date?): Long? = value?.time + + @TypeConverter + fun toDate(value: Long?): Date? = value?.let { Date(it) } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseDao.kt new file mode 100644 index 0000000000..fbb185ae39 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseDao.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonCourseEntity + +@Dao +interface HorizonCourseDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(courses: List) + + @Query(""" + UPDATE horizon_courses SET + name = :name, progress = :progress, imageUrl = :imageUrl, + startAtMs = :startAtMs, endAtMs = :endAtMs, + requirementCount = :requirementCount, + requirementCompletedCount = :requirementCompletedCount, + completedAtMs = :completedAtMs, grade = :grade, + workflowState = :workflowState, lastActivityAtMs = :lastActivityAtMs, + enrolledAtMs = :enrolledAtMs + WHERE courseId = :courseId + """) + suspend fun updateEnrollmentFields( + courseId: Long, name: String, progress: Double, imageUrl: String?, + startAtMs: Long?, endAtMs: Long?, + requirementCount: Int?, requirementCompletedCount: Int?, + completedAtMs: Long?, grade: Double?, + workflowState: String?, lastActivityAtMs: Long?, enrolledAtMs: Long?, + ) + + @Query(""" + UPDATE horizon_courses SET + name = :name, progress = :progress, imageUrl = :imageUrl, + courseSyllabus = :courseSyllabus + WHERE courseId = :courseId + """) + suspend fun updateCourseDetailsFields( + courseId: Long, name: String, progress: Double, imageUrl: String?, courseSyllabus: String?, + ) + + @Query(""" + UPDATE horizon_courses SET + name = :name, startAtMs = :startAtMs, endAtMs = :endAtMs, + moduleItemsDurations = :moduleItemsDurations + WHERE courseId = :courseId + """) + suspend fun updateProgramCourseFields( + courseId: Long, name: String, startAtMs: Long?, endAtMs: Long?, moduleItemsDurations: String, + ) + + @Query("SELECT * FROM horizon_courses WHERE courseId = :courseId") + suspend fun getByCourseId(courseId: Long): HorizonCourseEntity? + + @Query("SELECT * FROM horizon_courses WHERE courseId IN (:courseIds)") + suspend fun getByCourseIds(courseIds: List): List +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt new file mode 100644 index 0000000000..c97bcd9337 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonCourseModuleEntity +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity + +@Dao +interface HorizonCourseModuleDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertModules(modules: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Query("SELECT * FROM horizon_course_modules WHERE courseId = :courseId ORDER BY position") + suspend fun getModulesForCourse(courseId: Long): List + + @Query("SELECT * FROM horizon_course_module_items WHERE moduleId = :moduleId ORDER BY position") + suspend fun getItemsForModule(moduleId: Long): List + + @Query("DELETE FROM horizon_course_modules WHERE courseId = :courseId") + suspend fun deleteModulesForCourse(courseId: Long) + + @Query("DELETE FROM horizon_course_module_items WHERE courseId = :courseId") + suspend fun deleteItemsForCourse(courseId: Long) + + @Transaction + suspend fun replaceForCourse( + courseId: Long, + modules: List, + items: List, + ) { + deleteItemsForCourse(courseId) + deleteModulesForCourse(courseId) + insertModules(modules) + insertItems(items) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseScoreDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseScoreDao.kt new file mode 100644 index 0000000000..67555bae05 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseScoreDao.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity +import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity +import com.instructure.horizon.database.entity.HorizonCourseGradeEntity + +@Dao +interface HorizonCourseScoreDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGroups(groups: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAssignments(assignments: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGrade(grade: HorizonCourseGradeEntity) + + @Query("SELECT * FROM horizon_course_assignment_groups WHERE courseId = :courseId") + suspend fun getGroupsForCourse(courseId: Long): List + + @Query("SELECT * FROM horizon_course_assignments WHERE groupId = :groupId") + suspend fun getAssignmentsForGroup(groupId: Long): List + + @Query("SELECT * FROM horizon_course_grades WHERE courseId = :courseId") + suspend fun getGradeForCourse(courseId: Long): HorizonCourseGradeEntity? + + @Query("DELETE FROM horizon_course_assignment_groups WHERE courseId = :courseId") + suspend fun deleteGroupsForCourse(courseId: Long) + + @Query("DELETE FROM horizon_course_assignments WHERE courseId = :courseId") + suspend fun deleteAssignmentsForCourse(courseId: Long) + + @Query("DELETE FROM horizon_course_grades WHERE courseId = :courseId") + suspend fun deleteGradeForCourse(courseId: Long) + + @Transaction + suspend fun replaceForCourse( + courseId: Long, + groups: List, + assignments: List, + grade: HorizonCourseGradeEntity?, + ) { + deleteAssignmentsForCourse(courseId) + deleteGroupsForCourse(courseId) + deleteGradeForCourse(courseId) + insertGroups(groups) + insertAssignments(assignments) + grade?.let { insertGrade(it) } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonFileFolderDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonFileFolderDao.kt new file mode 100644 index 0000000000..b55075c8a3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonFileFolderDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonFileFolderEntity + +@Dao +interface HorizonFileFolderDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(fileFolder: HorizonFileFolderEntity) + + @Query("SELECT * FROM HorizonFileFolderEntity WHERE id = :id") + suspend fun findById(id: Long): HorizonFileFolderEntity? +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCollectionDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCollectionDao.kt new file mode 100644 index 0000000000..77207f4329 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnCollectionDao.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonLearnCollectionEntity +import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity + +@Dao +interface HorizonLearnCollectionDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCollections(collections: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Query("SELECT * FROM horizon_learn_collections") + suspend fun getAllCollections(): List + + @Query("SELECT * FROM horizon_learn_collection_items WHERE collectionId = :collectionId") + suspend fun getItemsByCollectionId(collectionId: String): List + + @Query("DELETE FROM horizon_learn_collections") + suspend fun deleteAllCollections() + + @Query("DELETE FROM horizon_learn_collection_items") + suspend fun deleteAllItems() + + @Transaction + suspend fun replaceAll( + collections: List, + items: List, + ) { + deleteAllCollections() + deleteAllItems() + insertCollections(collections) + insertItems(items) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnItemDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnItemDao.kt new file mode 100644 index 0000000000..988ca2b613 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnItemDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonLearnItemEntity + +@Dao +interface HorizonLearnItemDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("SELECT * FROM horizon_learn_items WHERE queryKey = :queryKey") + suspend fun getByQueryKey(queryKey: String): List + + @Query("DELETE FROM horizon_learn_items WHERE queryKey = :queryKey") + suspend fun deleteByQueryKey(queryKey: String) + + @Transaction + suspend fun replaceByQueryKey(entities: List, queryKey: String) { + deleteByQueryKey(queryKey) + insertAll(entities) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnSavedItemDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnSavedItemDao.kt new file mode 100644 index 0000000000..e7824a5536 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLearnSavedItemDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity + +@Dao +interface HorizonLearnSavedItemDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("SELECT * FROM horizon_learn_saved_items") + suspend fun getAll(): List + + @Query("DELETE FROM horizon_learn_saved_items") + suspend fun deleteAll() + + @Transaction + suspend fun replaceAll(entities: List) { + deleteAll() + insertAll(entities) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLocalFileDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLocalFileDao.kt new file mode 100644 index 0000000000..07c8870aea --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonLocalFileDao.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonLocalFileEntity + +@Dao +interface HorizonLocalFileDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(localFile: HorizonLocalFileEntity) + + @Query("SELECT * FROM HorizonLocalFileEntity WHERE id = :id") + suspend fun findById(id: Long): HorizonLocalFileEntity? + + @Query("SELECT * FROM HorizonLocalFileEntity WHERE courseId = :courseId") + suspend fun findByCourseId(courseId: Long): List +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt new file mode 100644 index 0000000000..edc3bddac3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonProgramDao.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonProgramCourseRef +import com.instructure.horizon.database.entity.HorizonProgramEntity + +@Dao +interface HorizonProgramDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(programs: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllRefs(refs: List) + + @Query("SELECT * FROM horizon_programs") + suspend fun getAll(): List + + @Query("SELECT * FROM horizon_programs WHERE programId = :programId") + suspend fun getById(programId: String): HorizonProgramEntity? + + @Query("SELECT * FROM horizon_program_course_refs WHERE programId = :programId") + suspend fun getRefsForProgram(programId: String): List + + @Query("DELETE FROM horizon_programs") + suspend fun deleteAll() + + @Query("DELETE FROM horizon_program_course_refs") + suspend fun deleteAllRefs() + + @Query("DELETE FROM horizon_program_course_refs WHERE programId = :programId") + suspend fun deleteRefsForProgram(programId: String) + + @Transaction + suspend fun replaceAll(programs: List, refs: List) { + deleteAllRefs() + deleteAll() + insertAll(programs) + insertAllRefs(refs) + } + + @Transaction + suspend fun upsertProgram(program: HorizonProgramEntity, refs: List) { + insertAll(listOf(program)) + deleteRefsForProgram(program.programId) + insertAllRefs(refs) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentEntity.kt new file mode 100644 index 0000000000..e7501f46bc --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentEntity.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Flattened entity for [com.instructure.canvasapi2.models.Assignment] with submission data + * needed to reconstruct [com.instructure.horizon.model.AssignmentStatus] offline. + * [courseId] is stored for efficient per-course deletion. + * Submission fields are prefixed with "submission" and are null when no submission exists. + */ +@Entity( + tableName = "horizon_course_assignments", + indices = [Index("groupId"), Index("courseId")] +) +data class HorizonCourseAssignmentEntity( + @PrimaryKey val assignmentId: Long, + val groupId: Long, + val courseId: Long, + val name: String?, + val pointsPossible: Double, + val dueAt: String?, + // Submission fields flattened + val submissionGrade: String?, + val submissionWorkflowState: String?, + val submissionExcused: Boolean, + val submissionMissing: Boolean, + val submissionLate: Boolean, + val submissionPostedAtMs: Long?, + val submissionCustomGradeStatusId: Long?, + val submissionCommentsCount: Int, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentGroupEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentGroupEntity.kt new file mode 100644 index 0000000000..f1a3b211ec --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseAssignmentGroupEntity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores [com.instructure.canvasapi2.models.AssignmentGroup] data per course for offline use. + */ +@Entity( + tableName = "horizon_course_assignment_groups", + indices = [Index("courseId")] +) +data class HorizonCourseAssignmentGroupEntity( + @PrimaryKey val groupId: Long, + val courseId: Long, + val name: String?, + val groupWeight: Double, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseEntity.kt new file mode 100644 index 0000000000..75d3179f9e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseEntity.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Unified entity for course data, used by both the My Content list screen and the Course/Program + * Details screens. Fields from the list screen (enrollment metadata) and from the details screens + * (syllabus, module durations) are merged here to ensure data consistency when offline. + */ +@Entity(tableName = "horizon_courses") +data class HorizonCourseEntity( + @PrimaryKey val courseId: Long, + val name: String, + val progress: Double, + val imageUrl: String?, + val startAtMs: Long?, + val endAtMs: Long?, + val requirementCount: Int?, + val requirementCompletedCount: Int?, + val completedAtMs: Long?, + val grade: Double?, + val workflowState: String?, + val lastActivityAtMs: Long?, + val enrolledAtMs: Long?, + val courseSyllabus: String?, + val moduleItemsDurations: String, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseGradeEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseGradeEntity.kt new file mode 100644 index 0000000000..8d917fed79 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseGradeEntity.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Stores the current grade score per course, derived from the active + * [com.instructure.canvasapi2.models.Enrollment] for the Course Scores screen. + */ +@Entity(tableName = "horizon_course_grades") +data class HorizonCourseGradeEntity( + @PrimaryKey val courseId: Long, + val currentScore: Double, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleEntity.kt new file mode 100644 index 0000000000..917baab90d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleEntity.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores [com.instructure.canvasapi2.models.ModuleObject] header data per course for offline use. + * [prerequisiteIds] is stored as a comma-separated list of Long IDs, or an empty string if none. + */ +@Entity( + tableName = "horizon_course_modules", + indices = [Index("courseId")] +) +data class HorizonCourseModuleEntity( + @PrimaryKey val moduleId: Long, + val courseId: Long, + val name: String?, + val position: Int, + val state: String?, + val estimatedDuration: String?, + val prerequisiteIds: String, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt new file mode 100644 index 0000000000..941694d6b1 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Flattened entity for [com.instructure.canvasapi2.models.ModuleItem] belonging to a course module. + * [courseId] is stored for efficient per-course deletion. + * [completionRequirementType], [completionRequirementMinScore], [completionRequirementCompleted] + * are from [com.instructure.canvasapi2.models.ModuleCompletionRequirement]. + * [pointsPossible], [dueAt], [lockedForUser], [lockExplanation], [lockAt], [unlockAt] + * are from [com.instructure.canvasapi2.models.ModuleContentDetails]. + */ +@Entity( + tableName = "horizon_course_module_items", + indices = [Index("moduleId"), Index("courseId")] +) +data class HorizonCourseModuleItemEntity( + @PrimaryKey val itemId: Long, + val moduleId: Long, + val courseId: Long, + val title: String?, + val position: Int, + val type: String?, + val htmlUrl: String?, + val url: String?, + // ModuleCompletionRequirement flattened + val completionRequirementType: String?, + val completionRequirementMinScore: Double, + val completionRequirementCompleted: Boolean, + // ModuleContentDetails flattened + val pointsPossible: String?, + val dueAt: String?, + val lockedForUser: Boolean, + val lockExplanation: String?, + val lockAt: String?, + val unlockAt: String?, + // Other + val quizLti: Boolean, + val estimatedDuration: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt new file mode 100644 index 0000000000..674e199da9 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "HorizonFileFolderEntity") +data class HorizonFileFolderEntity( + @PrimaryKey + val id: Long, + val url: String, + val displayName: String, +) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionEntity.kt new file mode 100644 index 0000000000..1cdafb8ac2 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionEntity.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "horizon_learn_collections") +data class HorizonLearnCollectionEntity( + @PrimaryKey val id: String, + val name: String, + val publicName: String?, + val description: String?, + val createdAtMs: Long, + val updatedAtMs: Long, + val totalItemCount: Int, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionItemEntity.kt new file mode 100644 index 0000000000..071a2c6cb4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnCollectionItemEntity.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Flattened entity for [com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem] + * belonging to an enrolled collection (Browse tab). + */ +@Entity( + tableName = "horizon_learn_collection_items", + indices = [Index("collectionId")] +) +data class HorizonLearnCollectionItemEntity( + @PrimaryKey val id: String, + val collectionId: String, + val libraryId: String, + val itemType: String, + val displayOrder: Double, + // CanvasCourseInfo flattened + val canvasCourseId: String?, + val canvasUrl: String?, + val courseName: String?, + val courseImageUrl: String?, + val moduleCount: Double?, + val moduleItemCount: Double?, + val estimatedDurationMinutes: Double?, + // LearningLibraryModuleInfo flattened + val moduleId: String?, + val moduleItemId: String?, + val moduleItemType: String?, + val resourceId: String?, + // Other + val programId: String?, + val programCourseId: String?, + val createdAtMs: Long, + val updatedAtMs: Long, + val isBookmarked: Boolean, + val completionPercentage: Double?, + val isEnrolledInCanvas: Boolean?, + val canvasEnrollmentId: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt new file mode 100644 index 0000000000..c38173a502 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnItemEntity.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Lightweight list-metadata entity. Stores the ordering and bucketing information for items + * shown on the My Content screen. Domain data (course/program fields) lives in the unified + * [HorizonCourseEntity] and [HorizonProgramEntity] tables, keyed by [id]. + */ +@Entity( + tableName = "horizon_learn_items", + indices = [Index("queryKey")] +) +data class HorizonLearnItemEntity( + @PrimaryKey val id: String, + val queryKey: String, + val itemType: String, + val position: Int, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnSavedItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnSavedItemEntity.kt new file mode 100644 index 0000000000..40a3e742ba --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLearnSavedItemEntity.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Flattened entity for bookmarked [com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem] + * items (Saved tab). + */ +@Entity(tableName = "horizon_learn_saved_items") +data class HorizonLearnSavedItemEntity( + @PrimaryKey val id: String, + val libraryId: String, + val itemType: String, + val displayOrder: Double, + // CanvasCourseInfo flattened + val canvasCourseId: String?, + val canvasUrl: String?, + val courseName: String?, + val courseImageUrl: String?, + val moduleCount: Double?, + val moduleItemCount: Double?, + val estimatedDurationMinutes: Double?, + // LearningLibraryModuleInfo flattened + val moduleId: String?, + val moduleItemId: String?, + val moduleItemType: String?, + val resourceId: String?, + // Other + val programId: String?, + val programCourseId: String?, + val createdAtMs: Long, + val updatedAtMs: Long, + val isBookmarked: Boolean, + val completionPercentage: Double?, + val isEnrolledInCanvas: Boolean?, + val canvasEnrollmentId: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt new file mode 100644 index 0000000000..71d88fa512 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonLocalFileEntity.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.Date + +@Entity(tableName = "HorizonLocalFileEntity") +data class HorizonLocalFileEntity( + @PrimaryKey + val id: Long, + val courseId: Long, + val createdDate: Date, + val path: String, +) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramCourseRef.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramCourseRef.kt new file mode 100644 index 0000000000..3c4363c32b --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramCourseRef.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity + +@Entity( + tableName = "horizon_program_course_refs", + primaryKeys = ["programId", "courseId"] +) +data class HorizonProgramCourseRef( + val programId: String, + val courseId: Long, + val requirementId: String, + val progressId: String, + val required: Boolean, + val progress: Double, + val enrollmentStatus: String?, + val sortOrder: Int, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramEntity.kt new file mode 100644 index 0000000000..13eb45aa8a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonProgramEntity.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Unified entity for program data, used by both the My Content list screen and the Program + * Details screen. Merges fields from [ProgramEnrollmentItem] (list) and the + * [Program] domain model (details) to ensure data consistency when offline. + */ +@Entity(tableName = "horizon_programs") +data class HorizonProgramEntity( + @PrimaryKey val programId: String, + val name: String, + val description: String?, + val startDateMs: Long?, + val endDateMs: Long?, + val variant: String, + val estimatedDurationMinutes: Int?, + val courseCount: Int?, + val courseCompletionCount: Int?, + val enrolledAtMs: Long?, + val completionPercentage: Double?, + val enrollmentStatus: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index 852461480c..d1b21c3dbc 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -22,6 +22,13 @@ enum class SyncDataType { DASHBOARD_ENROLLMENTS, DASHBOARD_PROGRAMS, DASHBOARD_MODULE_ITEMS, + LEARN_MY_CONTENT_IN_PROGRESS, + LEARN_MY_CONTENT_COMPLETED, + LEARN_SAVED_ITEMS, + LEARN_LIBRARY_COLLECTIONS, + COURSE_DETAILS, + COURSE_MODULES, + COURSE_SCORES, } @Entity(tableName = "horizon_sync_metadata") diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 99124f89d8..5d12b56932 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -15,12 +15,24 @@ */ package com.instructure.horizon.di +import android.content.Context import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.database.HorizonDatabase import com.instructure.horizon.database.HorizonDatabaseProvider +import com.instructure.horizon.offline.HorizonHtmlParserFileSource +import com.instructure.pandautils.features.offline.sync.HtmlParser +import dagger.hilt.android.qualifiers.ApplicationContext +import com.instructure.horizon.database.dao.HorizonCourseDao +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.dao.HorizonCourseScoreDao import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao -import com.instructure.horizon.database.dao.HorizonDashboardProgramDao +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLearnCollectionDao +import com.instructure.horizon.database.dao.HorizonLearnItemDao +import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao import dagger.Module import dagger.Provides @@ -36,8 +48,7 @@ class HorizonOfflineModule { provider: HorizonDatabaseProvider, apiPrefs: ApiPrefs, ): HorizonDatabase { - val userId = apiPrefs.user?.id ?: -1L - return provider.getDatabase(userId) + return provider.getDatabase(apiPrefs.user?.id) } @Provides @@ -46,8 +57,8 @@ class HorizonOfflineModule { } @Provides - fun provideHorizonDashboardProgramDao(db: HorizonDatabase): HorizonDashboardProgramDao { - return db.dashboardProgramDao() + fun provideHorizonProgramDao(db: HorizonDatabase): HorizonProgramDao { + return db.programDao() } @Provides @@ -59,4 +70,52 @@ class HorizonOfflineModule { fun provideHorizonSyncMetadataDao(db: HorizonDatabase): HorizonSyncMetadataDao { return db.syncMetadataDao() } + + @Provides + fun provideHorizonLearnItemDao(db: HorizonDatabase): HorizonLearnItemDao { + return db.learnItemDao() + } + + @Provides + fun provideHorizonLearnCollectionDao(db: HorizonDatabase): HorizonLearnCollectionDao { + return db.learnCollectionDao() + } + + @Provides + fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao { + return db.learnSavedItemDao() + } + + @Provides + fun provideHorizonCourseDao(db: HorizonDatabase): HorizonCourseDao { + return db.courseDao() + } + + @Provides + fun provideHorizonCourseModuleDao(db: HorizonDatabase): HorizonCourseModuleDao { + return db.courseModuleDao() + } + + @Provides + fun provideHorizonCourseScoreDao(db: HorizonDatabase): HorizonCourseScoreDao { + return db.courseScoreDao() + } + + @Provides + fun provideHorizonLocalFileDao(db: HorizonDatabase): HorizonLocalFileDao { + return db.localFileDao() + } + + @Provides + fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao { + return db.fileFolderDao() + } + + @Provides + @HorizonHtmlParserQualifier + fun provideHorizonHtmlParser( + fileSource: HorizonHtmlParserFileSource, + apiPrefs: ApiPrefs, + @ApplicationContext context: Context, + ): HtmlParser = HtmlParser(fileSource, apiPrefs, context) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonQualifiers.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonQualifiers.kt new file mode 100644 index 0000000000..f1add781f2 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonQualifiers.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class HorizonHtmlParserQualifier diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibrariesUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibrariesUseCase.kt new file mode 100644 index 0000000000..88a2604a81 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibrariesUseCase.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetLearnLearningLibrariesUseCase @Inject constructor( + private val repository: LearnLearningLibraryRepository, +) : BaseUseCase>() { + + override suspend fun execute(params: Int): List { + return repository.getEnrolledLearningLibraries(params) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryItemsUseCase.kt new file mode 100644 index 0000000000..1da5916ad3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryItemsUseCase.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class GetLearnLearningLibraryItemsParams( + val cursor: String?, + val limit: Int?, + val searchQuery: String?, + val typeFilter: CollectionItemType?, + val bookmarkedOnly: Boolean, + val completedOnly: Boolean, + val sortBy: CollectionItemSortOption?, + val forceRefresh: Boolean = false, +) + +class GetLearnLearningLibraryItemsUseCase @Inject constructor( + private val repository: LearnLearningLibraryRepository, +) : BaseUseCase() { + + override suspend fun execute(params: GetLearnLearningLibraryItemsParams): LearningLibraryCollectionItemsResponse { + return repository.getLearningLibraryItems( + cursor = params.cursor, + limit = params.limit, + searchQuery = params.searchQuery, + typeFilter = params.typeFilter, + bookmarkedOnly = params.bookmarkedOnly, + completedOnly = params.completedOnly, + sortBy = params.sortBy, + forceRefresh = params.forceRefresh, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryRecommendationsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryRecommendationsUseCase.kt new file mode 100644 index 0000000000..6dcb6e2765 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnLearningLibraryRecommendationsUseCase.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryRecommendation +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class GetLearnLearningLibraryRecommendationsParams( + val forceRefresh: Boolean = false, +) + +class GetLearnLearningLibraryRecommendationsUseCase @Inject constructor( + private val getLearningLibraryManager: GetLearningLibraryManager, +) : BaseUseCase>() { + + override suspend fun execute(params: GetLearnLearningLibraryRecommendationsParams): List { + return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = params.forceRefresh) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentCompletedItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentCompletedItemsUseCase.kt new file mode 100644 index 0000000000..8a325703b5 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentCompletedItemsUseCase.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.horizon.data.repository.LearnMyContentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +private const val QUERY_KEY = "COMPLETED" + +data class GetLearnMyContentCompletedItemsParams( + val cursor: String?, + val searchQuery: String?, + val sortBy: CollectionItemSortOption?, + val itemTypes: List?, + val forceRefresh: Boolean = false, +) + +class GetLearnMyContentCompletedItemsUseCase @Inject constructor( + private val repository: LearnMyContentRepository, +) : BaseUseCase() { + + override suspend fun execute(params: GetLearnMyContentCompletedItemsParams): LearnItemsResponse { + return repository.getLearnItems( + cursor = params.cursor, + searchQuery = params.searchQuery, + sortBy = params.sortBy, + status = listOf(LearnItemStatus.COMPLETED), + itemTypes = params.itemTypes, + queryKey = QUERY_KEY, + forceRefresh = params.forceRefresh, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentInProgressItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentInProgressItemsUseCase.kt new file mode 100644 index 0000000000..e7485b7242 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLearnMyContentInProgressItemsUseCase.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.horizon.data.repository.LearnMyContentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +private const val QUERY_KEY = "IN_PROGRESS" + +data class GetLearnMyContentInProgressItemsParams( + val cursor: String?, + val searchQuery: String?, + val sortBy: CollectionItemSortOption?, + val itemTypes: List?, + val forceRefresh: Boolean = false, +) + +class GetLearnMyContentInProgressItemsUseCase @Inject constructor( + private val repository: LearnMyContentRepository, +) : BaseUseCase() { + + override suspend fun execute(params: GetLearnMyContentInProgressItemsParams): LearnItemsResponse { + return repository.getLearnItems( + cursor = params.cursor, + searchQuery = params.searchQuery, + sortBy = params.sortBy, + status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), + itemTypes = params.itemTypes, + queryKey = QUERY_KEY, + forceRefresh = params.forceRefresh, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/ToggleLearnLearningLibraryItemBookmarkUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/ToggleLearnLearningLibraryItemBookmarkUseCase.kt new file mode 100644 index 0000000000..622716b07b --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/ToggleLearnLearningLibraryItemBookmarkUseCase.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class ToggleLearnLearningLibraryItemBookmarkParams( + val itemId: String, +) + +class ToggleLearnLearningLibraryItemBookmarkUseCase @Inject constructor( + private val getLearningLibraryManager: GetLearningLibraryManager, +) : BaseUseCase() { + + override suspend fun execute(params: ToggleLearnLearningLibraryItemBookmarkParams): Boolean { + return getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked(params.itemId) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt index c4e1e45dc1..2380aaa877 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnRepository.kt @@ -15,14 +15,16 @@ */ package com.instructure.horizon.features.learn -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository import javax.inject.Inject class LearnRepository @Inject constructor( - private val getLearningLibraryManager: GetLearningLibraryManager, + private val learningLibraryRepository: LearnLearningLibraryRepository, ) { - suspend fun getEnrolledLearningLibraries(forceNetwork: Boolean): List { - return getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, forceNetwork).collections + private val itemLimitPerCollection = 4 + + suspend fun getEnrolledLearningLibraries(): List { + return learningLibraryRepository.getEnrolledLearningLibraries(itemLimitPerCollection) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt index 00235f5377..e39adf9daa 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt @@ -15,10 +15,13 @@ */ package com.instructure.horizon.features.learn -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,7 +31,11 @@ import javax.inject.Inject @HiltViewModel class LearnViewModel @Inject constructor( private val repository: LearnRepository, -) : ViewModel() { + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { + private val _uiState = MutableStateFlow(LearnUiState( updateSelectedTab = ::updateSelectedTab, updateSelectedTabIndex = ::updateSelectedTabIndex, @@ -37,10 +44,30 @@ class LearnViewModel @Inject constructor( val state = _uiState.asStateFlow() init { + loadBrowseTab() + } + + override fun onNetworkRestored() { + loadBrowseTab() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + + private fun loadBrowseTab() { viewModelScope.tryLaunch { - val enrolledLearningLibraries = repository.getEnrolledLearningLibraries(false) - if (enrolledLearningLibraries.isNotEmpty()) { - _uiState.update { it.copy(tabs = it.tabs + LearnTab.BROWSE) } + val enrolledLearningLibraries = repository.getEnrolledLearningLibraries() + val hasBrowseTab = enrolledLearningLibraries.isNotEmpty() + _uiState.update { current -> + val tabs = if (hasBrowseTab && LearnTab.BROWSE !in current.tabs) { + current.tabs + LearnTab.BROWSE + } else if (!hasBrowseTab) { + current.tabs - LearnTab.BROWSE + } else { + current.tabs + } + current.copy(tabs = tabs) } } catch { } } @@ -58,4 +85,4 @@ class LearnViewModel @Inject constructor( ) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt index eecc3dc261..6de6944d51 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModel.kt @@ -16,11 +16,15 @@ package com.instructure.horizon.features.learn.course.details import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.navigation.LearnRoute +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -30,8 +34,11 @@ import javax.inject.Inject @HiltViewModel class CourseDetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val repository: CourseDetailsRepository -) : ViewModel() { + private val repository: CourseRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow(CourseDetailsUiState()) val state = _uiState.asStateFlow() @@ -52,6 +59,14 @@ class CourseDetailsViewModel @Inject constructor( } } + override fun onNetworkRestored() { + loadData() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + suspend fun fetchData(forceRefresh: Boolean = false) { val course = repository.getCourse(courseId, forceRefresh) val programs = repository.getProgramsForCourse(courseId, forceRefresh) @@ -72,4 +87,4 @@ class CourseDetailsViewModel @Inject constructor( ) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepository.kt deleted file mode 100644 index 5a746d75a2..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepository.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details.progress - -import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.utils.depaginate -import javax.inject.Inject - -class CourseProgressRepository @Inject constructor( - private val moduleApi: ModuleAPI.ModuleInterface -) { - suspend fun getModuleItems( - courseId: Long, - forceRefresh: Boolean, - ): List { - val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) - return moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - courseId, - params, - includes = listOf("estimated_durations") - ) - .depaginate { moduleApi.getNextPageModuleObjectList(it, params) } - .dataOrThrow - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt index d48c45b535..965763a529 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressViewModel.kt @@ -17,17 +17,21 @@ package com.instructure.horizon.features.learn.course.details.progress import android.content.Context -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.LearnEvent import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.horizonui.organisms.cards.ModuleHeaderStateMapper import com.instructure.horizon.horizonui.organisms.cards.ModuleItemCardStateMapper import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.orDefault import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -40,11 +44,14 @@ import javax.inject.Inject @HiltViewModel class CourseProgressViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: CourseProgressRepository, + private val repository: CourseRepository, private val moduleHeaderStateMapper: ModuleHeaderStateMapper, private val moduleItemCardStateMapper: ModuleItemCardStateMapper, - private val learnEventHandler: LearnEventHandler -): ViewModel() { + private val learnEventHandler: LearnEventHandler, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow( CourseProgressUiState( screenState = LoadingState( @@ -120,6 +127,14 @@ class CourseProgressViewModel @Inject constructor( _uiState.update { it.copy(moduleItemStates = moduleItemStates) } } + override fun onNetworkRestored() { + if (uiState.value.courseId != -1L) refresh() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + fun refresh() { viewModelScope.tryLaunch { _uiState.update { it.copy(screenState = it.screenState.copy(isRefreshing = true)) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt index 6fc9d18696..92cbfce098 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreViewModel.kt @@ -17,14 +17,18 @@ package com.instructure.horizon.features.learn.course.details.score import android.content.Context -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.apis.EnrollmentAPI import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.data.repository.CourseScoreRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.stringValueWithoutTrailingZeros import dagger.hilt.android.lifecycle.HiltViewModel @@ -38,7 +42,10 @@ import javax.inject.Inject class CourseScoreViewModel @Inject constructor( @ApplicationContext private val context: Context, private val courseScoreRepository: CourseScoreRepository, -): ViewModel() { + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow( CourseScoreUiState( @@ -75,10 +82,19 @@ class CourseScoreViewModel @Inject constructor( } } + override fun onNetworkRestored() { + if (uiState.value.courseId != -1L) refresh() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + private suspend fun getData(courseId: Long, forceRefresh: Boolean = false) { val assignmentGroups = courseScoreRepository.getAssignmentGroups(courseId, forceRefresh) val assignmentGroupItems = assignmentGroups.map { AssignmentGroupScoreItem(it) } val enrollments = courseScoreRepository.getEnrollments(courseId, forceRefresh) + courseScoreRepository.saveScoreData(courseId, assignmentGroups, enrollments) val grades = enrollments.first { it.enrollmentState == EnrollmentAPI.STATE_ACTIVE }.grades assignments = assignmentGroups.flatMap { it.assignments } val sortedAssignments = sortAssignments() @@ -121,4 +137,4 @@ class CourseScoreViewModel @Inject constructor( it.copy(screenState = it.screenState.copy(snackbarMessage = null)) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt index add6321fd0..d4a18a2c1f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepository.kt @@ -22,9 +22,11 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemT import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryRecommendation +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository import javax.inject.Inject class LearnLearningLibraryListRepository @Inject constructor( + private val learningLibraryRepository: LearnLearningLibraryRepository, private val getLearningLibraryManager: GetLearningLibraryManager, ) { private val itemLimitPerCollection = 4 @@ -37,29 +39,29 @@ class LearnLearningLibraryListRepository @Inject constructor( bookmarkedOnly: Boolean = false, completedOnly: Boolean = false, sortBy: CollectionItemSortOption? = null, - forceNetwork: Boolean + forceRefresh: Boolean = false, ): LearningLibraryCollectionItemsResponse { - return getLearningLibraryManager.getLearningLibraryCollectionItems( + return learningLibraryRepository.getLearningLibraryItems( cursor = afterCursor, limit = limit, + searchQuery = searchQuery, + typeFilter = typeFilter, bookmarkedOnly = bookmarkedOnly, completedOnly = completedOnly, - searchTerm = searchQuery, - types = typeFilter?.let { listOf(it) }, sortBy = sortBy, - forceNetwork = forceNetwork + forceRefresh = forceRefresh, ) } - suspend fun getEnrolledLearningLibraries(forceNetwork: Boolean): List { - return getLearningLibraryManager.getEnrolledLearningLibraryCollections(itemLimitPerCollection, forceNetwork).collections + suspend fun getEnrolledLearningLibraries(forceRefresh: Boolean = false): List { + return learningLibraryRepository.getEnrolledLearningLibraries(itemLimitPerCollection, forceRefresh) } suspend fun toggleLearningLibraryItemIsBookmarked(itemId: String): Boolean { return getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked(itemId) } - suspend fun getLearningLibraryRecommendedItems(forceNetwork: Boolean): List { - return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork) + suspend fun getLearningLibraryRecommendedItems(forceRefresh: Boolean = false): List { + return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = forceRefresh) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt index 29fad0657c..abc8c93c11 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModel.kt @@ -18,7 +18,6 @@ package com.instructure.horizon.features.learn.learninglibrary.list import android.content.res.Resources import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType @@ -28,6 +27,7 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.LearnEvent import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryCollectionState @@ -36,6 +36,9 @@ import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearni import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.learninglibrary.common.toUiState import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -54,7 +57,10 @@ class LearnLearningLibraryListViewModel @Inject constructor( private val repository: LearnLearningLibraryListRepository, private val eventHandler: LearnEventHandler, private val apiPrefs: ApiPrefs, -): ViewModel() { + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private var currentTypeFilter: LearnLearningLibraryTypeFilter = LearnLearningLibraryTypeFilter.All private var currentSortOption: LearnLearningLibrarySortOption = LearnLearningLibrarySortOption.MostRecent @@ -132,8 +138,16 @@ class LearnLearningLibraryListViewModel @Inject constructor( } } - private suspend fun fetchRecommendedItems(forceNetwork: Boolean = false): List { - val recommendations = repository.getLearningLibraryRecommendedItems(forceNetwork) + override fun onNetworkRestored() { + refreshCollections() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + + private suspend fun fetchRecommendedItems(forceRefresh: Boolean = false): List { + val recommendations = repository.getLearningLibraryRecommendedItems(forceRefresh) _uiState.update { it.copy(collectionState = it.collectionState.copy( recommendedItems = recommendations.map { it.toUiState(resources) } @@ -145,8 +159,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun loadCollections() { viewModelScope.tryLaunch { _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isLoading = true))) } - val result = fetchCollections() - val recommendedItems = fetchRecommendedItems() + val result = fetchCollections(forceRefresh = false) + val recommendedItems = fetchRecommendedItems(forceRefresh = false) allCollections = result.toUiState(resources, recommendedItems) _uiState.update { it.copy(collectionState = it.collectionState.copy(collections = allCollections)) } _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isLoading = false))) } @@ -163,7 +177,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( ) { viewModelScope.tryLaunch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isLoading = true))) } - fetchItems(cursor, searchQuery, typeFilter, sortBy = sortBy) + fetchItems(cursor, searchQuery, typeFilter, sortBy = sortBy, forceRefresh = false) _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isLoading = false))) } } catch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isLoading = false, isError = true))) } @@ -173,8 +187,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun refreshCollections() { viewModelScope.tryLaunch { _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isRefreshing = true))) } - val result = fetchCollections(true) - val recommendedItems = fetchRecommendedItems(true) + val result = fetchCollections(forceRefresh = true) + val recommendedItems = fetchRecommendedItems(forceRefresh = true) allCollections = result.toUiState(resources, recommendedItems) _uiState.update { it.copy(collectionState = it.collectionState.copy(collections = allCollections)) } _uiState.update { it.copy(collectionState = it.collectionState.copy(loadingState = it.collectionState.loadingState.copy(isRefreshing = false, isError = false))) } @@ -185,8 +199,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( } } - private suspend fun fetchCollections(forceNetwork: Boolean = false): List { - return repository.getEnrolledLearningLibraries(forceNetwork) + private suspend fun fetchCollections(forceRefresh: Boolean = false): List { + return repository.getEnrolledLearningLibraries(forceRefresh) } private suspend fun fetchItems( @@ -194,7 +208,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( searchQuery: String? = uiState.value.searchQuery.text, filterType: CollectionItemType? = currentTypeFilter.toCollectionItemType(), sortBy: CollectionItemSortOption? = currentSortOption.toCollectionItemSortOption(), - forceNetwork: Boolean = false + forceRefresh: Boolean = false, ) { val response = repository.getLearningLibraryItems( afterCursor = cursor, @@ -202,7 +216,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( searchQuery = searchQuery, typeFilter = filterType, sortBy = sortBy, - forceNetwork = forceNetwork + forceRefresh = forceRefresh, ) val recommendedItemsList = fetchRecommendedItems() @@ -229,7 +243,7 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun refreshItems() { viewModelScope.tryLaunch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isRefreshing = true))) } - fetchItems(cursor = null, forceNetwork = true) + fetchItems(cursor = null, forceRefresh = true) _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isRefreshing = false, isError = false))) } } catch { _uiState.update { it.copy(itemState = it.itemState.copy(loadingState = it.itemState.loadingState.copy(isRefreshing = false, snackbarMessage = resources.getString( @@ -244,20 +258,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy(bookmarkLoading = true) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = true) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy(bookmarkLoading = true) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = true) + else recommendedItemState } ))} @@ -267,26 +275,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else recommendedItemState } ))} } catch { @@ -294,24 +290,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy( - bookmarkLoading = false, - ) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false) + else recommendedItemState }, loadingState = it.collectionState.loadingState.copy(snackbarMessage = resources.getString(R.string.learnLearningLibraryFailedToUpdateBookmarkMessage)))) } } @@ -323,33 +309,22 @@ class LearnLearningLibraryListViewModel @Inject constructor( it.copy( itemState = it.itemState.copy( items = it.itemState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy(bookmarkLoading = true) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = true) + else collectionItemState } ), collectionState = it.collectionState.copy( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = true, - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = true) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy(bookmarkLoading = true) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = true) + else recommendedItemState } ) ) @@ -361,40 +336,22 @@ class LearnLearningLibraryListViewModel @Inject constructor( it.copy( itemState = it.itemState.copy( items = it.itemState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - isBookmarked = newIsBookmarked, - bookmarkLoading = false - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(isBookmarked = newIsBookmarked, bookmarkLoading = false) + else collectionItemState } ), collectionState = it.collectionState.copy( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy( - bookmarkLoading = false, - isBookmarked = newIsBookmarked - ) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false, isBookmarked = newIsBookmarked) + else recommendedItemState } ) ) @@ -404,11 +361,8 @@ class LearnLearningLibraryListViewModel @Inject constructor( it.copy( itemState = it.itemState.copy( items = it.itemState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy(bookmarkLoading = false) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false) + else collectionItemState }, loadingState = it.itemState.loadingState.copy(snackbarMessage = resources.getString(R.string.learnLearningLibraryFailedToUpdateBookmarkMessage)) ), @@ -416,22 +370,14 @@ class LearnLearningLibraryListViewModel @Inject constructor( collections = it.collectionState.collections.map { collectionState -> collectionState.copy( items = collectionState.items.map { collectionItemState -> - if (collectionItemState.id == itemId) { - collectionItemState.copy( - bookmarkLoading = false, - ) - } else { - collectionItemState - } + if (collectionItemState.id == itemId) collectionItemState.copy(bookmarkLoading = false) + else collectionItemState } ) }, recommendedItems = it.collectionState.recommendedItems.map { recommendedItemState -> - if (recommendedItemState.id == itemId) { - recommendedItemState.copy(bookmarkLoading = false) - } else { - recommendedItemState - } + if (recommendedItemState.id == itemId) recommendedItemState.copy(bookmarkLoading = false) + else recommendedItemState } ) ) @@ -468,4 +414,4 @@ class LearnLearningLibraryListViewModel @Inject constructor( private fun computeActiveFilterCount(): Int { return if (currentTypeFilter != LearnLearningLibraryTypeFilter.All) 1 else 0 } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentRepository.kt deleted file mode 100644 index fee8a47dd6..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentRepository.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.mycontent.common - -import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.MyContentManager -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption -import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType -import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse -import javax.inject.Inject - -class LearnMyContentRepository @Inject constructor( - private val myContentManager: MyContentManager, - private val getLearningLibraryManager: GetLearningLibraryManager, - private val moduleApi: ModuleAPI.ModuleInterface, -) { - suspend fun getLearnItems( - cursor: String? = null, - searchQuery: String? = null, - sortBy: CollectionItemSortOption? = null, - status: List? = null, - itemTypes: List? = null, - forceNetwork: Boolean = false, - ): LearnItemsResponse { - return myContentManager.getLearnItems( - cursor = cursor, - searchTerm = searchQuery, - sortBy = sortBy, - status = status, - itemTypes = itemTypes, - forceNetwork = forceNetwork, - ) - } - - suspend fun getBookmarkedLearningLibraryItems( - afterCursor: String? = null, - limit: Int? = 10, - searchQuery: String? = null, - sortBy: CollectionItemSortOption? = null, - types: List? = null, - forceNetwork: Boolean = false, - ): LearningLibraryCollectionItemsResponse { - return getLearningLibraryManager.getLearningLibraryCollectionItems( - cursor = afterCursor, - limit = limit, - bookmarkedOnly = true, - completedOnly = false, - searchTerm = searchQuery, - types = types, - sortBy = sortBy, - forceNetwork = forceNetwork, - ) - } - - suspend fun getFirstPageModulesWithItems(courseId: Long, forceNetwork: Boolean): List { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - courseId, - params, - includes = listOf("estimated_durations") - ).dataOrThrow - } -} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt index 93bfd7a188..b19b8b7e5d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentViewModel.kt @@ -16,16 +16,20 @@ */ package com.instructure.horizon.features.learn.mycontent.common -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.horizonui.platform.LoadingState import com.instructure.horizon.navigation.MainNavigationRoute +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -36,8 +40,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch abstract class LearnMyContentViewModel( - protected val repository: LearnMyContentRepository, -) : ViewModel() { + protected val getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase, +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private data class Filters( val searchQuery: String = "", @@ -73,6 +80,14 @@ abstract class LearnMyContentViewModel( } } + override fun onNetworkRestored() { + refresh() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + fun onFiltersChanged(searchQuery: String, sortBy: LearnLearningLibrarySortOption, typeFilter: LearnLearningLibraryTypeFilter) { filtersFlow.tryEmit(Filters(searchQuery, sortBy, typeFilter)) } @@ -81,7 +96,7 @@ abstract class LearnMyContentViewModel( viewModelScope.tryLaunch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } nextCursor = null - fetchAndUpdate(cursor = null) + fetchAndUpdate(cursor = null, forceRefresh = false) _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = false)) } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = true)) } @@ -92,7 +107,7 @@ abstract class LearnMyContentViewModel( viewModelScope.tryLaunch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = true)) } nextCursor = null - fetchAndUpdate(cursor = null, forceNetwork = true) + fetchAndUpdate(cursor = null, forceRefresh = true) _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = false, isError = false)) } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = false, snackbarMessage = errorMessage)) } @@ -102,20 +117,20 @@ abstract class LearnMyContentViewModel( private fun loadMore() { viewModelScope.tryLaunch { _uiState.update { it.copy(isMoreLoading = true) } - fetchAndUpdate(cursor = nextCursor, append = true) + fetchAndUpdate(cursor = nextCursor, forceRefresh = false, append = true) _uiState.update { it.copy(isMoreLoading = false) } } catch { _uiState.update { it.copy(isMoreLoading = false, loadingState = it.loadingState.copy(snackbarMessage = errorMessage)) } } } - private suspend fun fetchAndUpdate(cursor: String?, forceNetwork: Boolean = false, append: Boolean = false) { + private suspend fun fetchAndUpdate(cursor: String?, forceRefresh: Boolean = false, append: Boolean = false) { val (items, pageInfo) = fetchPage( cursor = cursor, searchQuery = currentFilters.searchQuery, sortBy = currentFilters.sortBy.toCollectionItemSortOption(), typeFilter = currentFilters.typeFilter, - forceNetwork = forceNetwork, + forceRefresh = forceRefresh, ) nextCursor = if (pageInfo.hasNextPage) pageInfo.nextCursor else null _uiState.update { state -> @@ -132,21 +147,13 @@ abstract class LearnMyContentViewModel( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> - protected suspend fun fetchNextModuleItemRoute(courseId: Long?, forceNetwork: Boolean): Any? { + protected suspend fun fetchNextModuleItemRoute(courseId: Long?): Any? { if (courseId == null) return null - val modules = repository.getFirstPageModulesWithItems(courseId, forceNetwork = forceNetwork) - val nextModuleItem = modules.flatMap { module -> module.items }.firstOrNull() - if (nextModuleItem == null) { - return null - } - - return MainNavigationRoute.ModuleItemSequence( - courseId, - nextModuleItem.id - ) + val nextModuleItem = getNextModuleItemUseCase(GetNextModuleItemUseCase.Params(courseId)) ?: return null + return MainNavigationRoute.ModuleItemSequence(courseId, nextModuleItem.moduleItemId) } private fun onSnackbarDismiss() { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt index 0e34569c21..f32672255f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModel.kt @@ -19,22 +19,29 @@ package com.instructure.horizon.features.learn.mycontent.completed import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsParams +import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.mycontent.common.LearnContentCardState -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentViewModel import com.instructure.horizon.features.learn.mycontent.common.toCardState +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class LearnMyContentCompletedViewModel @Inject constructor( private val resources: Resources, - repository: LearnMyContentRepository, -) : LearnMyContentViewModel(repository) { + private val getLearnMyContentCompletedItemsUseCase: GetLearnMyContentCompletedItemsUseCase, + getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) @@ -44,21 +51,19 @@ class LearnMyContentCompletedViewModel @Inject constructor( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> { - val response = repository.getLearnItems( - cursor = cursor, - searchQuery = searchQuery.ifEmpty { null }, - sortBy = sortBy, - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, - forceNetwork = forceNetwork, + val response = getLearnMyContentCompletedItemsUseCase( + GetLearnMyContentCompletedItemsParams( + cursor = cursor, + searchQuery = searchQuery.ifEmpty { null }, + sortBy = sortBy, + itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, + forceRefresh = forceRefresh, + ) ) return response.items.map { - it.toCardState( - resources, - { fetchNextModuleItemRoute(it, forceNetwork) } - ) + it.toCardState(resources) { courseId -> fetchNextModuleItemRoute(courseId) } } to response.pageInfo } -} +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt index 23e2842d7b..a5b615ca23 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModel.kt @@ -19,21 +19,29 @@ package com.instructure.horizon.features.learn.mycontent.inprogress import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsParams +import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.mycontent.common.LearnContentCardState -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentViewModel import com.instructure.horizon.features.learn.mycontent.common.toCardState +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class LearnMyContentInProgressViewModel @Inject constructor( private val resources: Resources, - repository: LearnMyContentRepository, -) : LearnMyContentViewModel(repository) { + private val getLearnMyContentInProgressItemsUseCase: GetLearnMyContentInProgressItemsUseCase, + getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) @@ -43,21 +51,19 @@ class LearnMyContentInProgressViewModel @Inject constructor( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> { - val response = repository.getLearnItems( - cursor = cursor, - searchQuery = searchQuery.ifEmpty { null }, - sortBy = sortBy, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, - forceNetwork = forceNetwork, + val response = getLearnMyContentInProgressItemsUseCase( + GetLearnMyContentInProgressItemsParams( + cursor = cursor, + searchQuery = searchQuery.ifEmpty { null }, + sortBy = sortBy, + itemTypes = typeFilter.toLearnItemType()?.let { listOf(it) }, + forceRefresh = forceRefresh, + ) ) return response.items.map { - it.toCardState( - resources, - { fetchNextModuleItemRoute(it, forceNetwork) } - ) + it.toCardState(resources) { courseId -> fetchNextModuleItemRoute(courseId) } } to response.pageInfo } -} +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedRepository.kt deleted file mode 100644 index 47b3a9ece1..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedRepository.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.mycontent.saved - -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager -import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryRecommendation -import javax.inject.Inject - -class LearnMyContentSavedRepository @Inject constructor( - private val getLearningLibraryManager: GetLearningLibraryManager -) { - suspend fun toggleLearningLibraryItemIsBookmarked(itemId: String): Boolean { - return getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked(itemId) - } - - suspend fun getLearningLibraryRecommendedItems(forceNetwork: Boolean): List { - return getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork) - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt index 18ae6c6188..bbe5f5c83a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModel.kt @@ -23,11 +23,20 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsParams +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsUseCase +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsParams +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase +import com.instructure.horizon.domain.usecase.ToggleLearnLearningLibraryItemBookmarkParams +import com.instructure.horizon.domain.usecase.ToggleLearnLearningLibraryItemBookmarkUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryCollectionItemState import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter import com.instructure.horizon.features.learn.learninglibrary.common.toUiState -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -35,9 +44,14 @@ import javax.inject.Inject @HiltViewModel class LearnMyContentSavedViewModel @Inject constructor( private val resources: Resources, - myContentRepository: LearnMyContentRepository, - private val savedContentRepository: LearnMyContentSavedRepository, -) : LearnMyContentViewModel(myContentRepository) { + private val getLearnLearningLibraryItemsUseCase: GetLearnLearningLibraryItemsUseCase, + private val getLearnLearningLibraryRecommendationsUseCase: GetLearnLearningLibraryRecommendationsUseCase, + private val toggleLearnLearningLibraryItemBookmarkUseCase: ToggleLearnLearningLibraryItemBookmarkUseCase, + getNextModuleItemUseCase: GetNextModuleItemUseCase, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : LearnMyContentViewModel(getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { override val errorMessage: String get() = resources.getString(R.string.learnMyContentProgramErrorMessage) @@ -47,42 +61,39 @@ class LearnMyContentSavedViewModel @Inject constructor( searchQuery: String, sortBy: CollectionItemSortOption, typeFilter: LearnLearningLibraryTypeFilter, - forceNetwork: Boolean, + forceRefresh: Boolean, ): Pair, LearningLibraryPageInfo> { - val recommendations = savedContentRepository.getLearningLibraryRecommendedItems(forceNetwork) - val response = repository.getBookmarkedLearningLibraryItems( - afterCursor = cursor, - searchQuery = searchQuery.ifEmpty { null }, - sortBy = sortBy, - types = typeFilter.toCollectionItemType()?.let { listOf(it) }, - forceNetwork = forceNetwork, + val recommendations = getLearnLearningLibraryRecommendationsUseCase(GetLearnLearningLibraryRecommendationsParams(forceRefresh)) + val response = getLearnLearningLibraryItemsUseCase( + GetLearnLearningLibraryItemsParams( + cursor = cursor, + limit = 10, + searchQuery = searchQuery.ifEmpty { null }, + typeFilter = typeFilter.toCollectionItemType(), + bookmarkedOnly = true, + completedOnly = false, + sortBy = sortBy, + forceRefresh = forceRefresh, + ) ) return response.items.map { it.toUiState(resources, recommendations) } to response.pageInfo } fun onBookmarkItem(itemId: String) { viewModelScope.tryLaunch { - _uiState.update { + _uiState.update { it.copy( contentCards = it.contentCards.map { itemState -> - if (itemState.id == itemId) { - itemState.copy(bookmarkLoading = true) - } else { - itemState - } + if (itemState.id == itemId) itemState.copy(bookmarkLoading = true) else itemState } ) } - savedContentRepository.toggleLearningLibraryItemIsBookmarked(itemId) + toggleLearnLearningLibraryItemBookmarkUseCase(ToggleLearnLearningLibraryItemBookmarkParams(itemId)) _uiState.update { it.copy( contentCards = it.contentCards.mapNotNull { itemState -> - if (itemState.id == itemId) { - null - } else { - itemState - } + if (itemState.id == itemId) null else itemState } ) } @@ -90,11 +101,7 @@ class LearnMyContentSavedViewModel @Inject constructor( _uiState.update { it.copy( contentCards = it.contentCards.map { itemState -> - if (itemState.id == itemId) { - itemState.copy(bookmarkLoading = false,) - } else { - itemState - } + if (itemState.id == itemId) itemState.copy(bookmarkLoading = false) else itemState }, loadingState = it.loadingState.copy(snackbarMessage = resources.getString(R.string.learnMyContentSavedFailedToBookmarkErrorMessage)) ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt index a552624f78..94f7849620 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModel.kt @@ -18,7 +18,6 @@ package com.instructure.horizon.features.learn.program.details import android.content.Context import android.content.res.Resources import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program @@ -26,6 +25,8 @@ import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequir import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.data.repository.ProgramRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.dashboard.DashboardEvent import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.navigation.LearnRoute @@ -38,8 +39,11 @@ import com.instructure.horizon.features.learn.program.details.components.Program import com.instructure.horizon.features.learn.program.details.components.SequentialProgramProgressProperties import com.instructure.horizon.horizonui.molecules.StatusChipColor import com.instructure.horizon.horizonui.platform.LoadingState +import com.instructure.horizon.offline.HorizonOfflineViewModel import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.formatMonthDayYear import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.sum @@ -57,10 +61,13 @@ import kotlin.time.Duration class ProgramDetailsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val resources: Resources, - private val repository: ProgramDetailsRepository, + private val repository: ProgramRepository, private val dashboardEventHandler: DashboardEventHandler, - savedStateHandle: SavedStateHandle -) : ViewModel() { + savedStateHandle: SavedStateHandle, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val programId = savedStateHandle.get(LearnRoute.LearnProgramDetailsScreen.programIdAttr) ?: "" @@ -305,6 +312,14 @@ class ProgramDetailsViewModel @Inject constructor( } } + override fun onNetworkRestored() { + loadData() + } + + override fun onNetworkLost() { + // Offline banner is handled at the screen level; no action needed here + } + private fun refreshProgram() { viewModelScope.tryLaunch { _uiState.update { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonHtmlParserFileSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonHtmlParserFileSource.kt new file mode 100644 index 0000000000..69db908ec8 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonHtmlParserFileSource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.offline + +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.pandautils.features.offline.sync.HtmlParserFileSource +import javax.inject.Inject + +class HorizonHtmlParserFileSource @Inject constructor( + private val localFileDao: HorizonLocalFileDao, + private val fileFolderDao: HorizonFileFolderDao, + private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, +) : HtmlParserFileSource { + + override suspend fun findLocalFilePath(fileId: Long): String? { + return localFileDao.findById(fileId)?.path + } + + override suspend fun findDisplayName(fileId: Long, courseId: Long): String? { + fileFolderDao.findById(fileId)?.displayName?.takeIf { it.isNotEmpty() }?.let { return it } + return fileFolderApi.getCourseFile( + courseId, fileId, + RestParams(isForceReadFromNetwork = false, shouldLoginOnTokenError = false) + ).dataOrNull?.displayName + } + + // HorizonFileSyncRepository.syncHtmlFiles already skips files that are in alreadyDownloadedIds, + // so all files should be passed through for sync consideration. + override suspend fun isRegisteredForSync(fileId: Long): Boolean = false +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseRepositoryTest.kt new file mode 100644 index 0000000000..c571d1dec0 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseRepositoryTest.kt @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.datasource.CourseDetailsLocalDataSource +import com.instructure.horizon.data.datasource.CourseDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.CourseProgressLocalDataSource +import com.instructure.horizon.data.datasource.CourseProgressNetworkDataSource +import com.instructure.horizon.data.repository.HorizonFileSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.features.offline.sync.HtmlParsingResult +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CourseRepositoryTest { + private val courseDetailsNetworkDataSource: CourseDetailsNetworkDataSource = mockk(relaxed = true) + private val courseDetailsLocalDataSource: CourseDetailsLocalDataSource = mockk(relaxed = true) + private val courseProgressNetworkDataSource: CourseProgressNetworkDataSource = mockk(relaxed = true) + private val courseProgressLocalDataSource: CourseProgressLocalDataSource = mockk(relaxed = true) + private val htmlParser: HtmlParser = mockk(relaxed = true) + private val fileSyncRepository: HorizonFileSyncRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + + private val testCourse = CourseWithProgress( + courseId = 1L, + courseName = "Test Course", + courseImageUrl = "https://example.com/course.png", + progress = 50.0, + courseSyllabus = "This is the course syllabus" + ) + private val testPrograms = listOf( + Program( + id = "prog1", + name = "Program 1", + description = "Program 1 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) + ), + Program( + id = "prog2", + name = "Program 2", + description = "Program 2 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 2L, required = true)) + ) + ) + private val testModules = listOf( + ModuleObject( + id = 1L, + name = "Module 1", + position = 1, + items = listOf( + ModuleItem(id = 101L, title = "Assignment 1", type = "Assignment"), + ModuleItem(id = 102L, title = "Quiz 1", type = "Quiz") + ) + ), + ModuleObject( + id = 2L, + name = "Module 2", + position = 2, + items = listOf( + ModuleItem(id = 201L, title = "Page 1", type = "Page") + ) + ) + ) + + @Before + fun setup() { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult( + htmlWithLocalFileLinks = "parsed html", + internalFileIds = emptySet(), + externalFileUrls = emptySet(), + studioMediaIds = emptySet(), + ) + coEvery { courseDetailsNetworkDataSource.getCourse(any(), any()) } returns testCourse + coEvery { courseDetailsNetworkDataSource.getProgramsForCourse(any(), any()) } returns testPrograms.filter { prog -> + prog.sortedRequirements.any { it.courseId == 1L } + } + coEvery { courseDetailsNetworkDataSource.hasExternalTools(any(), any()) } returns false + coEvery { courseProgressNetworkDataSource.getModuleItems(any(), any()) } returns testModules + } + + @Test + fun `getCourse returns course from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getCourse(1L, false) + + assertEquals(testCourse, result) + coVerify { courseDetailsNetworkDataSource.getCourse(1L, false) } + } + + @Test + fun `getCourse with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getCourse(1L, true) + + coVerify { courseDetailsNetworkDataSource.getCourse(1L, true) } + } + + @Test + fun `getCourse parses syllabus HTML and saves parsed version when syncing`() = runTest { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns true + val parsedHtml = "

parsed

" + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult( + htmlWithLocalFileLinks = parsedHtml, + internalFileIds = emptySet(), + externalFileUrls = emptySet(), + studioMediaIds = emptySet(), + ) + + val repository = getRepository() + repository.getCourse(1L, false) + + coVerify { htmlParser.createHtmlStringWithLocalFiles(testCourse.courseSyllabus, testCourse.courseId) } + coVerify { courseDetailsLocalDataSource.saveCourseDetails(testCourse.copy(courseSyllabus = parsedHtml), any()) } + coVerify { fileSyncRepository.syncHtmlFiles(testCourse.courseId, any()) } + } + + @Test + fun `getCourse returns course from local data source when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { courseDetailsLocalDataSource.getCourse(any()) } returns testCourse + + val repository = getRepository() + val result = repository.getCourse(1L, false) + + assertEquals(testCourse, result) + coVerify { courseDetailsLocalDataSource.getCourse(1L) } + } + + @Test + fun `getProgramsForCourse returns programs from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getProgramsForCourse(1L, false) + + assertEquals(1, result.size) + assertEquals("Program 1", result[0].name) + coVerify { courseDetailsNetworkDataSource.getProgramsForCourse(1L, false) } + } + + @Test + fun `getProgramsForCourse with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getProgramsForCourse(1L, true) + + coVerify { courseDetailsNetworkDataSource.getProgramsForCourse(1L, true) } + } + + @Test + fun `getProgramsForCourse returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { courseDetailsLocalDataSource.getProgramsForCourse(any()) } returns testPrograms + + val repository = getRepository() + val result = repository.getProgramsForCourse(1L, false) + + coVerify { courseDetailsLocalDataSource.getProgramsForCourse(1L) } + assertEquals(2, result.size) + } + + @Test + fun `hasExternalTools returns true when network data source returns true`() = runTest { + coEvery { courseDetailsNetworkDataSource.hasExternalTools(any(), any()) } returns true + val repository = getRepository() + + val result = repository.hasExternalTools(1L, false) + + assertTrue(result) + coVerify { courseDetailsNetworkDataSource.hasExternalTools(1L, false) } + } + + @Test + fun `hasExternalTools returns false when network data source returns false`() = runTest { + coEvery { courseDetailsNetworkDataSource.hasExternalTools(any(), any()) } returns false + val repository = getRepository() + + val result = repository.hasExternalTools(1L, false) + + assertFalse(result) + } + + @Test + fun `hasExternalTools returns false when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + + val repository = getRepository() + val result = repository.hasExternalTools(1L, false) + + assertFalse(result) + } + + @Test + fun `hasExternalTools with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.hasExternalTools(1L, true) + + coVerify { courseDetailsNetworkDataSource.hasExternalTools(1L, true) } + } + + @Test + fun `getModuleItems returns list of modules with items from network when online`() = runTest { + val repository = getRepository() + val result = repository.getModuleItems(1L, false) + + assertEquals(2, result.size) + assertEquals("Module 1", result[0].name) + assertEquals(2, result[0].items.size) + assertEquals("Module 2", result[1].name) + assertEquals(1, result[1].items.size) + coVerify { courseProgressNetworkDataSource.getModuleItems(1L, false) } + } + + @Test + fun `getModuleItems with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getModuleItems(1L, true) + + coVerify { courseProgressNetworkDataSource.getModuleItems(1L, true) } + } + + @Test + fun `getModuleItems returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { courseProgressLocalDataSource.getModuleItems(any()) } returns testModules + + val repository = getRepository() + val result = repository.getModuleItems(1L, false) + + assertEquals(2, result.size) + coVerify { courseProgressLocalDataSource.getModuleItems(1L) } + } + + @Test + fun `getModuleItems returns empty list when no modules`() = runTest { + coEvery { courseProgressNetworkDataSource.getModuleItems(any(), any()) } returns emptyList() + val repository = getRepository() + val result = repository.getModuleItems(1L, false) + + assertEquals(0, result.size) + } + + @Test + fun `Multiple programs can contain the same course`() = runTest { + val multiplePrograms = listOf( + Program( + id = "prog1", + name = "Program 1", + description = "Program 1 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) + ), + Program( + id = "prog2", + name = "Program 2", + description = "Program 2 description", + startDate = null, + endDate = null, + variant = com.instructure.journey.type.ProgramVariantType.LINEAR, + courseCompletionCount = 0, + sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 1L, required = true)) + ) + ) + coEvery { courseDetailsNetworkDataSource.getProgramsForCourse(any(), any()) } returns multiplePrograms + + val repository = getRepository() + val result = repository.getProgramsForCourse(1L, false) + + assertEquals(2, result.size) + } + + private fun getRepository(): CourseRepository { + return CourseRepository( + courseDetailsNetworkDataSource, + courseDetailsLocalDataSource, + courseProgressNetworkDataSource, + courseProgressLocalDataSource, + htmlParser, + fileSyncRepository, + networkStateProvider, + featureFlagProvider + ) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseScoreRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseScoreRepositoryTest.kt new file mode 100644 index 0000000000..f231a53881 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/CourseScoreRepositoryTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AssignmentGroup +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.models.Grades +import com.instructure.horizon.data.datasource.CourseScoreLocalDataSource +import com.instructure.horizon.data.datasource.CourseScoreNetworkDataSource +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CourseScoreRepositoryTest { + private val networkDataSource: CourseScoreNetworkDataSource = mockk(relaxed = true) + private val localDataSource: CourseScoreLocalDataSource = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + + private val testAssignmentGroups = listOf( + AssignmentGroup( + id = 1L, + name = "Homework", + groupWeight = 40.0, + assignments = listOf( + Assignment(id = 101L, name = "Assignment 1"), + Assignment(id = 102L, name = "Assignment 2") + ) + ), + AssignmentGroup( + id = 2L, + name = "Exams", + groupWeight = 60.0, + assignments = listOf( + Assignment(id = 201L, name = "Midterm Exam") + ) + ) + ) + private val testEnrollments = listOf( + Enrollment( + id = 1L, + enrollmentState = EnrollmentAPI.STATE_ACTIVE, + grades = Grades(currentScore = 85.5) + ) + ) + + @Before + fun setup() { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false + coEvery { networkDataSource.getAssignmentGroups(any(), any()) } returns testAssignmentGroups + coEvery { networkDataSource.getEnrollments(any(), any()) } returns testEnrollments + } + + @Test + fun `getAssignmentGroups returns list from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getAssignmentGroups(1L, false) + + assertEquals(2, result.size) + assertEquals("Homework", result[0].name) + assertEquals(2, result[0].assignments.size) + assertEquals("Exams", result[1].name) + assertEquals(1, result[1].assignments.size) + coVerify { networkDataSource.getAssignmentGroups(1L, false) } + } + + @Test + fun `getAssignmentGroups with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getAssignmentGroups(1L, true) + + coVerify { networkDataSource.getAssignmentGroups(1L, true) } + } + + @Test + fun `getAssignmentGroups returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { localDataSource.getAssignmentGroups(any()) } returns testAssignmentGroups + + val repository = getRepository() + val result = repository.getAssignmentGroups(1L, false) + + assertEquals(2, result.size) + coVerify { localDataSource.getAssignmentGroups(1L) } + } + + @Test + fun `getEnrollments returns list from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getEnrollments(1L, false) + + assertEquals(1, result.size) + assertEquals(EnrollmentAPI.STATE_ACTIVE, result[0].enrollmentState) + assertEquals(85.5, result[0].grades?.currentScore) + coVerify { networkDataSource.getEnrollments(1L, false) } + } + + @Test + fun `getEnrollments with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getEnrollments(1L, true) + + coVerify { networkDataSource.getEnrollments(1L, true) } + } + + @Test + fun `getEnrollments returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { localDataSource.getEnrollments(any()) } returns testEnrollments + + val repository = getRepository() + val result = repository.getEnrollments(1L, false) + + assertEquals(1, result.size) + coVerify { localDataSource.getEnrollments(1L) } + } + + @Test + fun `getAssignmentGroups returns empty list when no groups`() = runTest { + coEvery { networkDataSource.getAssignmentGroups(any(), any()) } returns emptyList() + val repository = getRepository() + val result = repository.getAssignmentGroups(1L, false) + + assertEquals(0, result.size) + } + + private fun getRepository(): CourseScoreRepository { + return CourseScoreRepository(networkDataSource, localDataSource, networkStateProvider, featureFlagProvider) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/data/repository/ProgramRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/ProgramRepositoryTest.kt new file mode 100644 index 0000000000..81fbcb9d8e --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/data/repository/ProgramRepositoryTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.ProgramDetailsLocalDataSource +import com.instructure.horizon.data.datasource.ProgramDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.ProgramLocalDataSource +import com.instructure.horizon.data.datasource.ProgramNetworkDataSource +import com.instructure.journey.type.ProgramVariantType +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class ProgramRepositoryTest { + private val networkDataSource: ProgramNetworkDataSource = mockk(relaxed = true) + private val localDataSource: ProgramLocalDataSource = mockk(relaxed = true) + private val programDetailsNetworkDataSource: ProgramDetailsNetworkDataSource = mockk(relaxed = true) + private val programDetailsLocalDataSource: ProgramDetailsLocalDataSource = mockk(relaxed = true) + private val enrollmentRepository: CourseEnrollmentRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + + private val testPrograms = listOf( + createTestProgram(id = "program1", name = "Software Engineering", + requirements = listOf(createTestProgramRequirement(courseId = 1L))), + createTestProgram(id = "program2", name = "Data Science", + requirements = listOf(createTestProgramRequirement(courseId = 2L, progress = 50.0))) + ) + + private val testCourses = listOf( + createTestCourse(courseId = 1L, courseName = "Intro to Programming"), + createTestCourse(courseId = 2L, courseName = "Data Analysis"), + createTestCourse(courseId = 3L, courseName = "Machine Learning") + ) + + @Before + fun setup() { + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false + coEvery { networkDataSource.getPrograms(any()) } returns testPrograms + coEvery { programDetailsNetworkDataSource.getProgramDetails(any(), any()) } returns testPrograms[0] + coEvery { programDetailsNetworkDataSource.getCoursesById(any(), any()) } returns testCourses + } + + @Test + fun `getPrograms returns programs from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getPrograms(false) + + assertEquals(2, result.size) + assertEquals("Software Engineering", result[0].name) + coVerify { networkDataSource.getPrograms(false) } + } + + @Test + fun `getPrograms returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { localDataSource.getPrograms() } returns testPrograms + + val repository = getRepository() + val result = repository.getPrograms(false) + + assertEquals(2, result.size) + coVerify { localDataSource.getPrograms() } + } + + @Test + fun `getProgramDetails returns program from network data source when online`() = runTest { + val repository = getRepository() + val result = repository.getProgramDetails("program1", false) + + assertEquals("program1", result.id) + assertEquals("Software Engineering", result.name) + coVerify { programDetailsNetworkDataSource.getProgramDetails("program1", false) } + } + + @Test + fun `getProgramDetails with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getProgramDetails("program1", true) + + coVerify { programDetailsNetworkDataSource.getProgramDetails("program1", true) } + } + + @Test + fun `getProgramDetails returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { programDetailsLocalDataSource.getProgramDetails(any()) } returns testPrograms[0] + + val repository = getRepository() + val result = repository.getProgramDetails("program1", false) + + assertEquals("program1", result.id) + coVerify { programDetailsLocalDataSource.getProgramDetails("program1") } + } + + @Test + fun `getCoursesById returns courses from network data source when online`() = runTest { + coEvery { programDetailsNetworkDataSource.getCoursesById(listOf(1L, 2L, 3L), false) } returns testCourses + + val repository = getRepository() + val result = repository.getCoursesById(listOf(1L, 2L, 3L), false) + + assertEquals(3, result.size) + assertEquals("Intro to Programming", result[0].courseName) + assertEquals("Data Analysis", result[1].courseName) + assertEquals("Machine Learning", result[2].courseName) + coVerify { programDetailsNetworkDataSource.getCoursesById(listOf(1L, 2L, 3L), false) } + } + + @Test + fun `getCoursesById with forceRefresh true passes it to network data source`() = runTest { + val repository = getRepository() + repository.getCoursesById(listOf(1L, 2L), true) + + coVerify { programDetailsNetworkDataSource.getCoursesById(listOf(1L, 2L), true) } + } + + @Test + fun `getCoursesById returns local data when offline`() = runTest { + every { networkStateProvider.isOnline() } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { programDetailsLocalDataSource.getCoursesById(any()) } returns testCourses + + val repository = getRepository() + val result = repository.getCoursesById(listOf(1L, 2L, 3L), false) + + assertEquals(3, result.size) + coVerify { programDetailsLocalDataSource.getCoursesById(listOf(1L, 2L, 3L)) } + } + + @Test + fun `enrollCourse returns success result`() = runTest { + coEvery { programDetailsNetworkDataSource.enrollCourse(any()) } returns DataResult.Success(Unit) + + val repository = getRepository() + val result = repository.enrollCourse("progress123") + + assertTrue(result.isSuccess) + coVerify { programDetailsNetworkDataSource.enrollCourse("progress123") } + } + + @Test + fun `enrollCourse returns failure result`() = runTest { + coEvery { programDetailsNetworkDataSource.enrollCourse(any()) } returns DataResult.Fail() + + val repository = getRepository() + val result = repository.enrollCourse("progress123") + + assertTrue(result.isFail) + coVerify { programDetailsNetworkDataSource.enrollCourse("progress123") } + } + + private fun getRepository(): ProgramRepository { + return ProgramRepository( + networkDataSource, + localDataSource, + programDetailsNetworkDataSource, + programDetailsLocalDataSource, + enrollmentRepository, + networkStateProvider, + featureFlagProvider + ) + } + + private fun createTestProgram( + id: String = "testProgram", + name: String = "Test Program", + requirements: List = emptyList() + ): Program = Program( + id = id, + name = name, + description = "Test description", + startDate = null, + endDate = null, + variant = ProgramVariantType.LINEAR, + courseCompletionCount = null, + sortedRequirements = requirements + ) + + private fun createTestProgramRequirement( + courseId: Long = 1L, + progress: Double = 0.0 + ): ProgramRequirement = ProgramRequirement( + id = "requirement$courseId", + progressId = "progress$courseId", + courseId = courseId, + required = true, + progress = progress, + enrollmentStatus = null + ) + + private fun createTestCourse( + courseId: Long = 1L, + courseName: String = "Test Course" + ): CourseWithModuleItemDurations = CourseWithModuleItemDurations( + courseId = courseId, + courseName = courseName, + moduleItemsDuration = listOf("PT1H"), + startDate = null, + endDate = null + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt index 97678137be..d44f808e14 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt @@ -46,10 +46,10 @@ class DashboardViewModelTest { private val apiPrefs: ApiPrefs = mockk(relaxed = true) private val themePrefs: ThemePrefs = mockk(relaxed = true) private val localeUtils: LocaleUtils = mockk(relaxed = true) + private val dashboardEventHandler: DashboardEventHandler = DashboardEventHandler() private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) - private val dashboardEventHandler: DashboardEventHandler = DashboardEventHandler() private val testDispatcher = UnconfinedTestDispatcher() private val notificationCounts = listOf( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepositoryTest.kt deleted file mode 100644 index 697312a432..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsRepositoryTest.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details - -import com.instructure.canvasapi2.apis.ExternalToolAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.canvasapi2.models.LTITool -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class CourseDetailsRepositoryTest { - private val getCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) - private val externalToolApi: ExternalToolAPI.ExternalToolInterface = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) - - private val testUser = User(id = 789L) - private val testCourse = CourseWithProgress( - courseId = 1L, - courseName = "Test Course", - courseImageUrl = "https://example.com/course.png", - progress = 50.0, - courseSyllabus = "This is the course syllabus" - ) - private val testPrograms = listOf( - Program( - id = "prog1", - name = "Program 1", - description = "Program 1 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) - ), - Program( - id = "prog2", - name = "Program 2", - description = "Program 2 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 2L, required = true)) - ) - ) - - @Before - fun setup() { - every { apiPrefs.user } returns testUser - coEvery { getCoursesManager.getCourseWithProgressById(any(), any(), any()) } returns DataResult.Success(testCourse) - coEvery { getProgramsManager.getPrograms(any()) } returns testPrograms - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Success(emptyList()) - } - - @Test - fun `getCourse returns course with progress`() = runTest { - val repository = getRepository() - val result = repository.getCourse(1L, false) - - assertEquals(testCourse, result) - coVerify { getCoursesManager.getCourseWithProgressById(1L, 789L, false) } - } - - @Test - fun `getCourse with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getCourse(1L, true) - - coVerify { getCoursesManager.getCourseWithProgressById(1L, 789L, true) } - } - - @Test - fun `getCourse uses -1 when user is null`() = runTest { - every { apiPrefs.user } returns null - val repository = getRepository() - repository.getCourse(1L, false) - - coVerify { getCoursesManager.getCourseWithProgressById(1L, -1L, false) } - } - - @Test - fun `getProgramsForCourse returns programs containing the course`() = runTest { - val repository = getRepository() - val result = repository.getProgramsForCourse(1L, false) - - assertEquals(1, result.size) - assertEquals("Program 1", result[0].name) - coVerify { getProgramsManager.getPrograms(false) } - } - - @Test - fun `getProgramsForCourse returns empty list when no programs contain the course`() = runTest { - val repository = getRepository() - val result = repository.getProgramsForCourse(999L, false) - - assertEquals(0, result.size) - } - - @Test - fun `getProgramsForCourse with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getProgramsForCourse(1L, true) - - coVerify { getProgramsManager.getPrograms(true) } - } - - @Test - fun `hasExternalTools returns true when course has tools`() = runTest { - val tools = listOf(LTITool(url = "https://tool.example.com")) - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Success(tools) - val repository = getRepository() - - val result = repository.hasExternalTools(1L, false) - - assertTrue(result) - coVerify { externalToolApi.getExternalToolsForCourses(listOf("course_1"), any()) } - } - - @Test - fun `hasExternalTools returns false when course has no tools`() = runTest { - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Success(emptyList()) - val repository = getRepository() - - val result = repository.hasExternalTools(1L, false) - - assertFalse(result) - } - - @Test - fun `hasExternalTools returns false when API returns null`() = runTest { - coEvery { externalToolApi.getExternalToolsForCourses(any(), any()) } returns DataResult.Fail() - val repository = getRepository() - - val result = repository.hasExternalTools(1L, false) - - assertFalse(result) - } - - @Test - fun `hasExternalTools with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.hasExternalTools(1L, true) - - coVerify { - externalToolApi.getExternalToolsForCourses( - any(), - match { it.isForceReadFromNetwork } - ) - } - } - - @Test - fun `Multiple programs can contain the same course`() = runTest { - val multiplePrograms = listOf( - Program( - id = "prog1", - name = "Program 1", - description = "Program 1 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req1", progressId = "prog1", courseId = 1L, required = true)) - ), - Program( - id = "prog2", - name = "Program 2", - description = "Program 2 description", - startDate = null, - endDate = null, - variant = com.instructure.journey.type.ProgramVariantType.LINEAR, - courseCompletionCount = 0, - sortedRequirements = listOf(ProgramRequirement(id = "req2", progressId = "prog2", courseId = 1L, required = true)) - ) - ) - coEvery { getProgramsManager.getPrograms(any()) } returns multiplePrograms - val repository = getRepository() - - val result = repository.getProgramsForCourse(1L, false) - - assertEquals(2, result.size) - } - - private fun getRepository(): CourseDetailsRepository { - return CourseDetailsRepository(getCoursesManager, getProgramsManager, externalToolApi, apiPrefs) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt index e8556fb877..48a40c9700 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/CourseDetailsViewModelTest.kt @@ -20,9 +20,14 @@ import androidx.lifecycle.SavedStateHandle import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.navigation.LearnRoute +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse @@ -39,7 +44,10 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class CourseDetailsViewModelTest { - private val repository: CourseDetailsRepository = mockk(relaxed = true) + private val repository: CourseRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val testCourseId = 123L @@ -66,6 +74,8 @@ class CourseDetailsViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false coEvery { repository.getCourse(any(), any()) } returns testCourse coEvery { repository.getProgramsForCourse(any(), any()) } returns testPrograms coEvery { repository.hasExternalTools(any(), any()) } returns false @@ -211,7 +221,7 @@ class CourseDetailsViewModelTest { @Test fun `Invalid course ID defaults to -1`() { val savedStateHandle = SavedStateHandle() - val viewModel = CourseDetailsViewModel(savedStateHandle, repository) + val viewModel = CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) coVerify { repository.getCourse(-1L, any()) } } @@ -240,6 +250,6 @@ class CourseDetailsViewModelTest { val savedStateHandle = SavedStateHandle(mapOf( LearnRoute.LearnCourseDetailsScreen.courseIdAttr to courseId )) - return CourseDetailsViewModel(savedStateHandle, repository) + return CourseDetailsViewModel(savedStateHandle, repository, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepositoryTest.kt deleted file mode 100644 index 16d813dbe6..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/progress/CourseProgressRepositoryTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details.progress - -import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleItem -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.LinkHeaders -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class CourseProgressRepositoryTest { - private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) - - private val testModules = listOf( - ModuleObject( - id = 1L, - name = "Module 1", - position = 1, - items = listOf( - ModuleItem( - id = 101L, - title = "Assignment 1", - type = "Assignment" - ), - ModuleItem( - id = 102L, - title = "Quiz 1", - type = "Quiz" - ) - ) - ), - ModuleObject( - id = 2L, - name = "Module 2", - position = 2, - items = listOf( - ModuleItem( - id = 201L, - title = "Page 1", - type = "Page" - ) - ) - ) - ) - - @Before - fun setup() { - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns DataResult.Success( - testModules, - linkHeaders = LinkHeaders() - ) - } - - @Test - fun `getModuleItems returns list of modules with items`() = runTest { - val repository = getRepository() - val result = repository.getModuleItems(1L, false) - - assertEquals(2, result.size) - assertEquals("Module 1", result[0].name) - assertEquals(2, result[0].items.size) - assertEquals("Module 2", result[1].name) - assertEquals(1, result[1].items.size) - coVerify { - moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - 1L, - any(), - listOf("estimated_durations") - ) - } - } - - @Test - fun `getModuleItems with forceRefresh true calls API with force network`() = runTest { - val repository = getRepository() - repository.getModuleItems(1L, true) - - coVerify { - moduleApi.getFirstPageModulesWithItems( - any(), - any(), - match { it.isForceReadFromNetwork }, - any() - ) - } - } - - @Test - fun `getModuleItems with forceRefresh false calls API without force network`() = runTest { - val repository = getRepository() - repository.getModuleItems(1L, false) - - coVerify { - moduleApi.getFirstPageModulesWithItems( - any(), - any(), - match { !it.isForceReadFromNetwork }, - any() - ) - } - } - - @Test - fun `getModuleItems returns empty list when no modules`() = runTest { - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns DataResult.Success( - emptyList(), - linkHeaders = LinkHeaders() - ) - val repository = getRepository() - val result = repository.getModuleItems(1L, false) - - assertEquals(0, result.size) - } - - private fun getRepository(): CourseProgressRepository { - return CourseProgressRepository(moduleApi) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepositoryTest.kt deleted file mode 100644 index e5f3af1274..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/details/score/CourseScoreRepositoryTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.details.score - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.EnrollmentAPI -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.AssignmentGroup -import com.instructure.canvasapi2.models.Enrollment -import com.instructure.canvasapi2.models.Grades -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.LinkHeaders -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class CourseScoreRepositoryTest { - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) - private val enrollmentApi: EnrollmentAPI.EnrollmentInterface = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) - - private val testUser = User(id = 999L) - private val testAssignmentGroups = listOf( - AssignmentGroup( - id = 1L, - name = "Homework", - groupWeight = 40.0, - assignments = listOf( - Assignment(id = 101L, name = "Assignment 1"), - Assignment(id = 102L, name = "Assignment 2") - ) - ), - AssignmentGroup( - id = 2L, - name = "Exams", - groupWeight = 60.0, - assignments = listOf( - Assignment(id = 201L, name = "Midterm Exam") - ) - ) - ) - private val testEnrollments = listOf( - Enrollment( - id = 1L, - enrollmentState = EnrollmentAPI.STATE_ACTIVE, - grades = Grades(currentScore = 85.5) - ) - ) - - @Before - fun setup() { - every { apiPrefs.user } returns testUser - coEvery { assignmentApi.getFirstPageAssignmentGroupListWithAssignments(any(), any()) } returns DataResult.Success( - testAssignmentGroups, - linkHeaders = LinkHeaders() - ) - coEvery { enrollmentApi.getEnrollmentsForUserInCourse(any(), any(), any()) } returns DataResult.Success( - testEnrollments, - linkHeaders = LinkHeaders() - ) - } - - @Test - fun `getAssignmentGroups returns list of assignment groups with assignments`() = runTest { - val repository = getRepository() - val result = repository.getAssignmentGroups(1L, false) - - assertEquals(2, result.size) - assertEquals("Homework", result[0].name) - assertEquals(2, result[0].assignments.size) - assertEquals("Exams", result[1].name) - assertEquals(1, result[1].assignments.size) - coVerify { assignmentApi.getFirstPageAssignmentGroupListWithAssignments(1L, any()) } - } - - @Test - fun `getAssignmentGroups with forceRefresh true calls API with force network`() = runTest { - val repository = getRepository() - repository.getAssignmentGroups(1L, true) - - coVerify { - assignmentApi.getFirstPageAssignmentGroupListWithAssignments( - any(), - match { it.isForceReadFromNetwork } - ) - } - } - - @Test - fun `getEnrollments returns list of enrollments`() = runTest { - val repository = getRepository() - val result = repository.getEnrollments(1L, false) - - assertEquals(1, result.size) - assertEquals(EnrollmentAPI.STATE_ACTIVE, result[0].enrollmentState) - assertEquals(85.5, result[0].grades?.currentScore) - coVerify { enrollmentApi.getEnrollmentsForUserInCourse(1L, 999L, any()) } - } - - @Test - fun `getEnrollments with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getEnrollments(1L, true) - - coVerify { - enrollmentApi.getEnrollmentsForUserInCourse( - any(), - any(), - match { it.isForceReadFromNetwork } - ) - } - } - - @Test - fun `getEnrollments uses -1 when user is null`() = runTest { - every { apiPrefs.user } returns null - val repository = getRepository() - repository.getEnrollments(1L, false) - - coVerify { enrollmentApi.getEnrollmentsForUserInCourse(1L, -1L, any()) } - } - - @Test - fun `getAssignmentGroups returns empty list when no groups`() = runTest { - coEvery { assignmentApi.getFirstPageAssignmentGroupListWithAssignments(any(), any()) } returns DataResult.Success( - emptyList(), - linkHeaders = LinkHeaders() - ) - val repository = getRepository() - val result = repository.getAssignmentGroups(1L, false) - - assertEquals(0, result.size) - } - - private fun getRepository(): CourseScoreRepository { - return CourseScoreRepository(assignmentApi, enrollmentApi, apiPrefs) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt index 627029aee7..9d87c83cd1 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt @@ -21,10 +21,10 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInf import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection -import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollectionsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.horizon.data.repository.LearnLearningLibraryRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -38,6 +38,7 @@ import java.util.Date class LearnLearningLibraryListRepositoryTest { private val getLearningLibraryManager: GetLearningLibraryManager = mockk(relaxed = true) + private val learningLibraryRepository: LearnLearningLibraryRepository = mockk(relaxed = true) private val testCollections = listOf( EnrolledLearningLibraryCollection( @@ -96,11 +97,8 @@ class LearnLearningLibraryListRepositoryTest { @Before fun setup() { - val response = EnrolledLearningLibraryCollectionsResponse( - collections = testCollections - ) - coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollections(any(), any()) } returns response - coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { learningLibraryRepository.getEnrolledLearningLibraries(any(), any()) } returns testCollections + coEvery { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = emptyList(), pageInfo = emptyPageInfo ) @@ -109,27 +107,27 @@ class LearnLearningLibraryListRepositoryTest { @Test fun `getEnrolledLearningLibraries returns list of collections`() = runTest { val repository = getRepository() - val result = repository.getEnrolledLearningLibraries(false) + val result = repository.getEnrolledLearningLibraries() assertEquals(2, result.size) assertEquals(testCollections, result) - coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, false) } + coVerify { learningLibraryRepository.getEnrolledLearningLibraries(4, false) } } @Test - fun `getEnrolledLearningLibraries with forceNetwork true calls API with force network`() = runTest { + fun `getEnrolledLearningLibraries with forceRefresh passes true to data layer`() = runTest { val repository = getRepository() - repository.getEnrolledLearningLibraries(true) - coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, true) } + repository.getEnrolledLearningLibraries(forceRefresh = true) + + coVerify { learningLibraryRepository.getEnrolledLearningLibraries(4, true) } } @Test fun `getEnrolledLearningLibraries returns empty list when no collections`() = runTest { - val emptyResponse = EnrolledLearningLibraryCollectionsResponse(collections = emptyList()) - coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollections(any(), any()) } returns emptyResponse + coEvery { learningLibraryRepository.getEnrolledLearningLibraries(any(), any()) } returns emptyList() val repository = getRepository() - val result = repository.getEnrolledLearningLibraries(false) + val result = repository.getEnrolledLearningLibraries() assertEquals(0, result.size) } @@ -137,79 +135,70 @@ class LearnLearningLibraryListRepositoryTest { @Test fun `getLearningLibraryItems returns items with no filters`() = runTest { val items = listOf(createTestCollectionItem("item1", "Python", "1", false, CollectionItemType.COURSE)) - coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = items, pageInfo = emptyPageInfo ) val repository = getRepository() - val result = repository.getLearningLibraryItems(forceNetwork = false) + val result = repository.getLearningLibraryItems() assertEquals(1, result.items.size) assertEquals(items, result.items) } @Test - fun `getLearningLibraryItems with cursor passes cursor to manager`() = runTest { + fun `getLearningLibraryItems with cursor passes cursor to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(afterCursor = "cursor123", forceNetwork = false) + repository.getLearningLibraryItems(afterCursor = "cursor123") - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(cursor = "cursor123", any(), any(), any(), any(), any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(cursor = "cursor123", any(), any(), any(), any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with searchQuery passes searchTerm to manager`() = runTest { + fun `getLearningLibraryItems with searchQuery passes searchQuery to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(searchQuery = "python", forceNetwork = false) + repository.getLearningLibraryItems(searchQuery = "python") - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), searchTerm = "python", any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), searchQuery = "python", any(), any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with typeFilter passes types to manager`() = runTest { + fun `getLearningLibraryItems with typeFilter passes typeFilter to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(typeFilter = CollectionItemType.COURSE, forceNetwork = false) + repository.getLearningLibraryItems(typeFilter = CollectionItemType.COURSE) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), types = listOf(CollectionItemType.COURSE), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), typeFilter = CollectionItemType.COURSE, any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with null typeFilter passes null types to manager`() = runTest { + fun `getLearningLibraryItems with null typeFilter passes null to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(typeFilter = null, forceNetwork = false) + repository.getLearningLibraryItems(typeFilter = null) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), types = null, any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), typeFilter = null, any(), any(), any(), any()) } } @Test - fun `getLearningLibraryItems with bookmarkedOnly passes bookmarkedOnly to manager`() = runTest { + fun `getLearningLibraryItems with bookmarkedOnly passes bookmarkedOnly to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(bookmarkedOnly = true, forceNetwork = false) + repository.getLearningLibraryItems(bookmarkedOnly = true) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), bookmarkedOnly = true, any(), any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), bookmarkedOnly = true, any(), any(), any()) } } @Test - fun `getLearningLibraryItems with completedOnly passes completedOnly to manager`() = runTest { + fun `getLearningLibraryItems with completedOnly passes completedOnly to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(completedOnly = true, forceNetwork = false) + repository.getLearningLibraryItems(completedOnly = true) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), completedOnly = true, any()) } - } - - @Test - fun `getLearningLibraryItems with forceNetwork true passes flag to manager`() = runTest { - val repository = getRepository() - - repository.getLearningLibraryItems(forceNetwork = true) - - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), forceNetwork = true) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), completedOnly = true, any(), any()) } } @Test @@ -222,43 +211,52 @@ class LearnLearningLibraryListRepositoryTest { totalCount = 10, pageCursors = null ) - coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = emptyList(), pageInfo = pageInfo ) val repository = getRepository() - val result = repository.getLearningLibraryItems(forceNetwork = false) + val result = repository.getLearningLibraryItems() assertTrue(result.pageInfo.hasNextPage) assertEquals("next_cursor", result.pageInfo.nextCursor) } @Test - fun `getLearningLibraryItems with limit passes limit to manager`() = runTest { + fun `getLearningLibraryItems with limit passes limit to data layer`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(limit = 5) + + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), limit = 5, any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `getLearningLibraryItems with sortBy passes sortBy to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(limit = 5, forceNetwork = false) + repository.getLearningLibraryItems(sortBy = CollectionItemSortOption.NAME_A_Z) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), limit = 5, any(), any(), any(), any(), any(), any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), sortBy = CollectionItemSortOption.NAME_A_Z, any()) } } @Test - fun `getLearningLibraryItems with sortBy passes sortBy to manager`() = runTest { + fun `getLearningLibraryItems with null sortBy passes null sortBy to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(sortBy = CollectionItemSortOption.NAME_A_Z, forceNetwork = false) + repository.getLearningLibraryItems(sortBy = null) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), sortBy = CollectionItemSortOption.NAME_A_Z, any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), sortBy = null, any()) } } @Test - fun `getLearningLibraryItems with null sortBy passes null sortBy to manager`() = runTest { + fun `getLearningLibraryItems with forceRefresh passes forceRefresh true to data layer`() = runTest { val repository = getRepository() - repository.getLearningLibraryItems(sortBy = null, forceNetwork = false) + repository.getLearningLibraryItems(forceRefresh = true) - coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), sortBy = null, any()) } + coVerify { learningLibraryRepository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), forceRefresh = true) } } @Test @@ -281,8 +279,26 @@ class LearnLearningLibraryListRepositoryTest { coVerify { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item2") } } + @Test + fun `getLearningLibraryRecommendedItems passes forceRefresh false to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryRecommendedItems(forceRefresh = false) + + coVerify { getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = false) } + } + + @Test + fun `getLearningLibraryRecommendedItems passes forceRefresh true to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryRecommendedItems(forceRefresh = true) + + coVerify { getLearningLibraryManager.getLearningLibraryRecommendations(forceNetwork = true) } + } + private fun getRepository(): LearnLearningLibraryListRepository { - return LearnLearningLibraryListRepository(getLearningLibraryManager) + return LearnLearningLibraryListRepository(learningLibraryRepository, getLearningLibraryManager) } private fun createTestCollectionItem( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt index 9ac3e76f58..ba068c995c 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt @@ -27,11 +27,14 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.learn.LearnEvent import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryFilterScreenType import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.ThemePrefs import io.mockk.coEvery import io.mockk.coVerify @@ -60,6 +63,9 @@ class LearnLearningLibraryListViewModelTest { private val resources: Resources = mockk(relaxed = true) private val repository: LearnLearningLibraryListRepository = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyItemsResponse = LearningLibraryCollectionItemsResponse( @@ -125,6 +131,7 @@ class LearnLearningLibraryListViewModelTest { coEvery { repository.getEnrolledLearningLibraries(any()) } returns testCollections coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns emptyItemsResponse + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -306,11 +313,12 @@ class LearnLearningLibraryListViewModelTest { } @Test - fun `Refresh calls repository with forceNetwork true`() = runTest { + fun `Refresh re-fetches collections`() = runTest { val viewModel = getViewModel() viewModel.uiState.value.collectionState.loadingState.onRefresh() + coVerify { repository.getEnrolledLearningLibraries(false) } coVerify { repository.getEnrolledLearningLibraries(true) } } @@ -333,7 +341,7 @@ class LearnLearningLibraryListViewModelTest { ) ) ) - coEvery { repository.getEnrolledLearningLibraries(true) } returns updatedCollections + coEvery { repository.getEnrolledLearningLibraries(any()) } returns updatedCollections viewModel.uiState.value.collectionState.loadingState.onRefresh() @@ -346,7 +354,7 @@ class LearnLearningLibraryListViewModelTest { @Test fun `Refresh on error shows snackbar message`() = runTest { val viewModel = getViewModel() - coEvery { repository.getEnrolledLearningLibraries(true) } throws Exception("Network error") + coEvery { repository.getEnrolledLearningLibraries(any()) } throws Exception("Network error") viewModel.uiState.value.collectionState.loadingState.onRefresh() @@ -358,7 +366,7 @@ class LearnLearningLibraryListViewModelTest { @Test fun `Dismiss snackbar clears both collection and item snackbar messages`() = runTest { val viewModel = getViewModel() - coEvery { repository.getEnrolledLearningLibraries(true) } throws Exception("Network error") + coEvery { repository.getEnrolledLearningLibraries(any()) } throws Exception("Network error") viewModel.uiState.value.collectionState.loadingState.onRefresh() viewModel.uiState.value.collectionState.loadingState.onSnackbarDismiss() @@ -676,7 +684,7 @@ class LearnLearningLibraryListViewModelTest { } @Test - fun `Items refresh calls repository with forceNetwork true`() = runTest { + fun `Items refresh re-fetches items`() = runTest { val viewModel = getViewModel() eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( screenType = LearnLearningLibraryFilterScreenType.Browse, @@ -686,6 +694,7 @@ class LearnLearningLibraryListViewModelTest { viewModel.uiState.value.itemState.loadingState.onRefresh() + coVerify { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), false) } coVerify { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } } @@ -695,7 +704,7 @@ class LearnLearningLibraryListViewModelTest { val refreshedItems = listOf( createTestCollectionItem(id = "item1", courseName = "Refreshed Course") ) - coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } returns LearningLibraryCollectionItemsResponse( + coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( items = refreshedItems, pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) ) @@ -711,7 +720,7 @@ class LearnLearningLibraryListViewModelTest { @Test fun `Items refresh on error shows snackbar message`() = runTest { val viewModel = getViewModel() - coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), any()) } throws Exception("Network error") viewModel.uiState.value.itemState.loadingState.onRefresh() @@ -795,7 +804,7 @@ class LearnLearningLibraryListViewModelTest { } private fun getViewModel(): LearnLearningLibraryListViewModel { - return LearnLearningLibraryListViewModel(resources, repository, eventHandler, apiPrefs) + return LearnLearningLibraryListViewModel(resources, repository, eventHandler, apiPrefs, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) } private fun createTestCollection( diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt index af64d41ba6..96fac01b3a 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt @@ -19,14 +19,16 @@ package com.instructure.horizon.features.learn.mycontent.completed import android.content.res.Resources import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo -import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.domain.usecase.GetLearnMyContentCompletedItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -50,7 +52,11 @@ import java.util.Date class LearnMyContentCompletedViewModelTest { private val resources: Resources = mockk(relaxed = true) - private val repository: LearnMyContentRepository = mockk(relaxed = true) + private val getLearnMyContentCompletedItemsUseCase: GetLearnMyContentCompletedItemsUseCase = mockk(relaxed = true) + private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearnItemsResponse( @@ -64,8 +70,8 @@ class LearnMyContentCompletedViewModelTest { every { resources.getString(any()) } returns "" every { resources.getString(any(), *anyVararg()) } returns "" every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse - coEvery { repository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } returns emptyResponse + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -74,38 +80,18 @@ class LearnMyContentCompletedViewModelTest { } @Test - fun `onFiltersChanged triggers load with COMPLETED status only`() = runTest { + fun `onFiltersChanged triggers load`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = null, - forceNetwork = false, - ) - } - } - - @Test - fun `onFiltersChanged does NOT pass IN_PROGRESS or NOT_STARTED status`() = runTest { - val viewModel = getViewModel() - - viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - - coVerify(exactly = 0) { - repository.getLearnItems( - status = match { it.contains(LearnItemStatus.IN_PROGRESS) || it.contains(LearnItemStatus.NOT_STARTED) }, - cursor = any(), - searchQuery = any(), - sortBy = any(), - itemTypes = any(), - forceNetwork = any(), - ) + getLearnMyContentCompletedItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == null + }) } } @@ -116,14 +102,11 @@ class LearnMyContentCompletedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Programs) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = listOf(LearnItemType.PROGRAM), - forceNetwork = false, - ) + getLearnMyContentCompletedItemsUseCase(match { + it.cursor == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.PROGRAM) + }) } } @@ -134,20 +117,17 @@ class LearnMyContentCompletedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = listOf(LearnItemType.COURSE), - forceNetwork = false, - ) + getLearnMyContentCompletedItemsUseCase(match { + it.cursor == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.COURSE) + }) } } @Test fun `Successful load populates contentCards`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "Completed Program")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -161,7 +141,7 @@ class LearnMyContentCompletedViewModelTest { @Test fun `Load error sets isError true`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -171,7 +151,7 @@ class LearnMyContentCompletedViewModelTest { @Test fun `showMoreButton is true when pageInfo has next page`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) ) @@ -183,29 +163,21 @@ class LearnMyContentCompletedViewModelTest { } @Test - fun `Refresh calls repository with forceNetwork true`() = runTest { + fun `Refresh re-fetches items`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = listOf(LearnItemStatus.COMPLETED), - itemTypes = null, - forceNetwork = true, - ) - } + coVerify { getLearnMyContentCompletedItemsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnMyContentCompletedItemsUseCase(match { it.forceRefresh }) } } @Test fun `Refresh error shows snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnMyContentCompletedItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() @@ -223,8 +195,8 @@ class LearnMyContentCompletedViewModelTest { items = listOf(createTestProgramItem(id = "p2", name = "Second")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) ) - coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns firstPage - coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + coEvery { getLearnMyContentCompletedItemsUseCase(match { it.cursor == null }) } returns firstPage + coEvery { getLearnMyContentCompletedItemsUseCase(match { it.cursor == "cursor1" }) } returns secondPage val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -235,7 +207,9 @@ class LearnMyContentCompletedViewModelTest { assertEquals("Second", viewModel.uiState.value.contentCards[1].name) } - private fun getViewModel() = LearnMyContentCompletedViewModel(resources, repository) + private fun getViewModel() = LearnMyContentCompletedViewModel( + resources, getLearnMyContentCompletedItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase + ) private fun createTestProgramItem( id: String = "program1", @@ -254,4 +228,4 @@ class LearnMyContentCompletedViewModelTest { estimatedDurationMinutes = null, courseCount = 2, ) -} +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt index 16a561479a..81a36e2c26 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt @@ -17,19 +17,19 @@ package com.instructure.horizon.features.learn.mycontent.inprogress import android.content.res.Resources -import com.instructure.canvasapi2.models.ModuleItem -import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem -import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem -import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.domain.usecase.GetLearnMyContentInProgressItemsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -55,7 +55,11 @@ import java.util.Date class LearnMyContentInProgressViewModelTest { private val resources: Resources = mockk(relaxed = true) - private val repository: LearnMyContentRepository = mockk(relaxed = true) + private val getLearnMyContentInProgressItemsUseCase: GetLearnMyContentInProgressItemsUseCase = mockk(relaxed = true) + private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearnItemsResponse( @@ -69,8 +73,8 @@ class LearnMyContentInProgressViewModelTest { every { resources.getString(any()) } returns "" every { resources.getString(any(), *anyVararg()) } returns "" every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse - coEvery { repository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns emptyResponse + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -114,20 +118,18 @@ class LearnMyContentInProgressViewModelTest { } @Test - fun `onFiltersChanged triggers load with IN_PROGRESS and NOT_STARTED status`() = runTest { + fun `onFiltersChanged triggers load`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = null, - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == null + }) } } @@ -138,14 +140,12 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Programs) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = listOf(LearnItemType.PROGRAM), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.PROGRAM) + }) } } @@ -156,14 +156,12 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), - itemTypes = listOf(LearnItemType.COURSE), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.itemTypes == listOf(LearnItemType.COURSE) + }) } } @@ -174,14 +172,7 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = any(), - itemTypes = null, - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { it.itemTypes == null }) } } @@ -192,21 +183,18 @@ class LearnMyContentInProgressViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.NameAscending, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.NAME_A_Z, - status = any(), - itemTypes = null, - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { + it.cursor == null && + it.sortBy == CollectionItemSortOption.NAME_A_Z && + it.itemTypes == null + }) } } @Test fun `Successful load populates contentCards`() = runTest { val programs = listOf(createTestProgramItem(id = "p1", name = "Program A")) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = programs, pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -220,7 +208,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `Successful load sets totalItemCount from pageInfo`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo(null, null, false, false, 42, null) ) @@ -233,7 +221,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `showMoreButton is true when pageInfo has next page`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) ) @@ -246,7 +234,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `showMoreButton is false when pageInfo has no next page`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -259,7 +247,7 @@ class LearnMyContentInProgressViewModelTest { @Test fun `Load error sets isError true`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -270,14 +258,14 @@ class LearnMyContentInProgressViewModelTest { @Test fun `Filter change replaces existing items`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "First")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "Second")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -288,31 +276,23 @@ class LearnMyContentInProgressViewModelTest { } @Test - fun `Refresh calls repository with forceNetwork true`() = runTest { + fun `Refresh re-fetches items`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = any(), - itemTypes = null, - forceNetwork = true, - ) - } + coVerify { getLearnMyContentInProgressItemsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnMyContentInProgressItemsUseCase(match { it.forceRefresh }) } } @Test fun `Refresh success clears error and updates content`() = runTest { - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), false) } throws Exception("Error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } returns LearnItemsResponse( items = listOf(createTestProgramItem(name = "Refreshed")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -327,7 +307,7 @@ class LearnMyContentInProgressViewModelTest { fun `Refresh error shows snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() @@ -339,7 +319,7 @@ class LearnMyContentInProgressViewModelTest { fun `Dismiss snackbar clears snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() viewModel.uiState.value.loadingState.onSnackbarDismiss() @@ -357,8 +337,8 @@ class LearnMyContentInProgressViewModelTest { items = listOf(createTestProgramItem(id = "p2", name = "Second")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) ) - coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns firstPage - coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == null }) } returns firstPage + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == "cursor1" }) } returns secondPage val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -371,13 +351,13 @@ class LearnMyContentInProgressViewModelTest { @Test fun `loadMore error shows snackbar and clears isMoreLoading`() = runTest { - coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns LearnItemsResponse( + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == null }) } returns LearnItemsResponse( items = listOf(createTestProgramItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 2, null) ) val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnMyContentInProgressItemsUseCase(match { it.cursor == "cursor1" }) } throws Exception("Network error") viewModel.uiState.value.increaseTotalItemCount() @@ -393,36 +373,24 @@ class LearnMyContentInProgressViewModelTest { advanceTimeBy(350) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = "kotlin", - sortBy = any(), - status = any(), - itemTypes = any(), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { it.cursor == null && it.searchQuery == "kotlin" }) } } @Test - fun `Empty search query passes null searchQuery to repository`() = runTest { + fun `Empty search query passes null searchQuery to use case`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - repository.getLearnItems( - cursor = null, - searchQuery = null, - sortBy = any(), - status = any(), - itemTypes = any(), - forceNetwork = false, - ) + getLearnMyContentInProgressItemsUseCase(match { it.cursor == null && it.searchQuery == null }) } } - private fun getViewModel() = LearnMyContentInProgressViewModel(resources, repository) + private fun getViewModel() = LearnMyContentInProgressViewModel( + resources, getLearnMyContentInProgressItemsUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase + ) private fun createTestProgramItem( id: String = "program1", @@ -462,4 +430,4 @@ class LearnMyContentInProgressViewModelTest { workflowState = "available", lastActivityAt = null, ) -} +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt index 4c620cdaca..ec6332b05b 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt @@ -24,9 +24,15 @@ import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibrary import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryItemsUseCase +import com.instructure.horizon.domain.usecase.GetLearnLearningLibraryRecommendationsUseCase +import com.instructure.horizon.domain.usecase.GetNextModuleItemUseCase +import com.instructure.horizon.domain.usecase.ToggleLearnLearningLibraryItemBookmarkUseCase import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter -import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -51,8 +57,13 @@ import java.util.Date class LearnMyContentSavedViewModelTest { private val resources: Resources = mockk(relaxed = true) - private val myContentRepository: LearnMyContentRepository = mockk(relaxed = true) - private val savedContentRepository: LearnMyContentSavedRepository = mockk(relaxed = true) + private val getLearnLearningLibraryItemsUseCase: GetLearnLearningLibraryItemsUseCase = mockk(relaxed = true) + private val getLearnLearningLibraryRecommendationsUseCase: GetLearnLearningLibraryRecommendationsUseCase = mockk(relaxed = true) + private val toggleLearnLearningLibraryItemBookmarkUseCase: ToggleLearnLearningLibraryItemBookmarkUseCase = mockk(relaxed = true) + private val getNextModuleItemUseCase: GetNextModuleItemUseCase = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val emptyResponse = LearningLibraryCollectionItemsResponse( @@ -66,10 +77,10 @@ class LearnMyContentSavedViewModelTest { every { resources.getString(any()) } returns "" every { resources.getString(any(), *anyVararg()) } returns "" every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse - coEvery { myContentRepository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() - coEvery { savedContentRepository.getLearningLibraryRecommendedItems(any()) } returns emptyList() - coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked(any()) } returns false + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns emptyResponse + coEvery { getLearnLearningLibraryRecommendationsUseCase(any()) } returns emptyList() + coEvery { toggleLearnLearningLibraryItemBookmarkUseCase(any()) } returns false + coEvery { featureFlagProvider.offlineEnabled() } returns false } @After @@ -85,19 +96,19 @@ class LearnMyContentSavedViewModelTest { } @Test - fun `onFiltersChanged calls getBookmarkedLearningLibraryItems`() = runTest { + fun `onFiltersChanged calls use case with bookmarkedOnly true`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = CollectionItemSortOption.MOST_RECENT, - types = null, - forceNetwork = false, - ) + getLearnLearningLibraryItemsUseCase(match { + it.cursor == null && + it.searchQuery == null && + it.sortBy == CollectionItemSortOption.MOST_RECENT && + it.typeFilter == null && + it.bookmarkedOnly == true + }) } } @@ -107,7 +118,7 @@ class LearnMyContentSavedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coVerify { savedContentRepository.getLearningLibraryRecommendedItems(false) } + coVerify { getLearnLearningLibraryRecommendationsUseCase(match { !it.forceRefresh }) } } @Test @@ -117,13 +128,11 @@ class LearnMyContentSavedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = any(), - types = listOf(CollectionItemType.COURSE), - forceNetwork = false, - ) + getLearnLearningLibraryItemsUseCase(match { + it.cursor == null && + it.typeFilter == CollectionItemType.COURSE && + it.bookmarkedOnly == true + }) } } @@ -134,19 +143,13 @@ class LearnMyContentSavedViewModelTest { viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = any(), - types = null, - forceNetwork = false, - ) + getLearnLearningLibraryItemsUseCase(match { it.typeFilter == null && it.bookmarkedOnly == true }) } } @Test fun `Successful load populates contentCards`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1", name = "Saved Course")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -160,7 +163,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `Load error sets isError true`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + coEvery { getLearnLearningLibraryItemsUseCase(any()) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -170,7 +173,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `showMoreButton is true when pageInfo has next page`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem()), pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) ) @@ -182,31 +185,25 @@ class LearnMyContentSavedViewModelTest { } @Test - fun `Refresh fetches recommendations with forceNetwork true`() = runTest { + fun `Refresh re-fetches recommendations`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { savedContentRepository.getLearningLibraryRecommendedItems(true) } + coVerify { getLearnLearningLibraryRecommendationsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnLearningLibraryRecommendationsUseCase(match { it.forceRefresh }) } } @Test - fun `Refresh calls getBookmarkedLearningLibraryItems with forceNetwork true`() = runTest { + fun `Refresh re-fetches bookmarked items`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) viewModel.uiState.value.loadingState.onRefresh() - coVerify { - myContentRepository.getBookmarkedLearningLibraryItems( - afterCursor = null, - searchQuery = null, - sortBy = any(), - types = null, - forceNetwork = true, - ) - } + coVerify { getLearnLearningLibraryItemsUseCase(match { !it.forceRefresh }) } + coVerify { getLearnLearningLibraryItemsUseCase(match { it.forceRefresh }) } } @Test @@ -219,8 +216,8 @@ class LearnMyContentSavedViewModelTest { items = listOf(createTestCollectionItem(id = "item2")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) ) - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(null, any(), any(), any(), any(), any()) } returns firstPage - coEvery { myContentRepository.getBookmarkedLearningLibraryItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + coEvery { getLearnLearningLibraryItemsUseCase(match { it.cursor == null }) } returns firstPage + coEvery { getLearnLearningLibraryItemsUseCase(match { it.cursor == "cursor1" }) } returns secondPage val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -231,7 +228,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `onBookmarkItem sets bookmarkLoading true then removes item on success`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -245,7 +242,7 @@ class LearnMyContentSavedViewModelTest { @Test fun `onBookmarkItem calls toggleLearningLibraryItemIsBookmarked`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) @@ -254,16 +251,16 @@ class LearnMyContentSavedViewModelTest { viewModel.onBookmarkItem("item1") - coVerify { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } + coVerify { toggleLearnLearningLibraryItemBookmarkUseCase(match { it.itemId == "item1" }) } } @Test fun `onBookmarkItem error keeps item in list with bookmarkLoading false`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) - coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + coEvery { toggleLearnLearningLibraryItemBookmarkUseCase(match { it.itemId == "item1" }) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -276,12 +273,12 @@ class LearnMyContentSavedViewModelTest { @Test fun `onBookmarkItem error shows snackbar message`() = runTest { - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + coEvery { getLearnLearningLibraryItemsUseCase(any()) } returns LearningLibraryCollectionItemsResponse( items = listOf(createTestCollectionItem(id = "item1")), pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) ) every { resources.getString(R.string.learnMyContentSavedFailedToBookmarkErrorMessage) } returns "Failed to save" - coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + coEvery { toggleLearnLearningLibraryItemBookmarkUseCase(match { it.itemId == "item1" }) } throws Exception("Network error") val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) @@ -294,7 +291,7 @@ class LearnMyContentSavedViewModelTest { fun `Refresh error shows snackbar`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + coEvery { getLearnLearningLibraryItemsUseCase(any()) } throws Exception("Network error") viewModel.uiState.value.loadingState.onRefresh() @@ -306,7 +303,7 @@ class LearnMyContentSavedViewModelTest { fun `Dismiss snackbar clears snackbar message`() = runTest { val viewModel = getViewModel() viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) - coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), true) } throws Exception("Error") + coEvery { getLearnLearningLibraryItemsUseCase(any()) } throws Exception("Error") viewModel.uiState.value.loadingState.onRefresh() viewModel.uiState.value.loadingState.onSnackbarDismiss() @@ -314,7 +311,10 @@ class LearnMyContentSavedViewModelTest { assertNull(viewModel.uiState.value.loadingState.snackbarMessage) } - private fun getViewModel() = LearnMyContentSavedViewModel(resources, myContentRepository, savedContentRepository) + private fun getViewModel() = LearnMyContentSavedViewModel( + resources, getLearnLearningLibraryItemsUseCase, getLearnLearningLibraryRecommendationsUseCase, + toggleLearnLearningLibraryItemBookmarkUseCase, getNextModuleItemUseCase, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase + ) private fun createTestCollectionItem( id: String = "testItem", diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepositoryTest.kt deleted file mode 100644 index 93076ef747..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsRepositoryTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.features.learn.program.details - -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.journey.type.ProgramVariantType -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class ProgramDetailsRepositoryTest { - private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) - private val getCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - - private val testPrograms = listOf( - createTestProgram( - id = "program1", - name = "Software Engineering", - requirements = listOf( - createTestProgramRequirement(courseId = 1L, progress = 0.0) - ) - ), - createTestProgram( - id = "program2", - name = "Data Science", - requirements = listOf( - createTestProgramRequirement(courseId = 2L, progress = 50.0) - ) - ) - ) - - private val testCourses = listOf( - createTestCourse(courseId = 1L, courseName = "Intro to Programming"), - createTestCourse(courseId = 2L, courseName = "Data Analysis"), - createTestCourse(courseId = 3L, courseName = "Machine Learning") - ) - - @Before - fun setup() { - coEvery { getProgramsManager.getPrograms(any()) } returns testPrograms - coEvery { getCoursesManager.getProgramCourses(any(), any()) } returns DataResult.Success(testCourses[0]) - } - - @Test - fun `getProgramDetails returns program for valid ID`() = runTest { - val repository = getRepository() - val result = repository.getProgramDetails("program1", false) - - assertEquals("program1", result.id) - assertEquals("Software Engineering", result.name) - coVerify { getProgramsManager.getPrograms(false) } - } - - @Test - fun `getProgramDetails throws exception for invalid ID`() = runTest { - val repository = getRepository() - - try { - repository.getProgramDetails("invalidId", false) - throw AssertionError("Expected IllegalArgumentException to be thrown") - } catch (e: IllegalArgumentException) { - assertTrue(e.message?.contains("Program with id invalidId not found") == true) - } - } - - @Test - fun `getProgramDetails with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getProgramDetails("program1", true) - - coVerify { getProgramsManager.getPrograms(true) } - } - - @Test - fun `getCoursesById fetches all courses in parallel`() = runTest { - coEvery { getCoursesManager.getProgramCourses(1L, false) } returns DataResult.Success(testCourses[0]) - coEvery { getCoursesManager.getProgramCourses(2L, false) } returns DataResult.Success(testCourses[1]) - coEvery { getCoursesManager.getProgramCourses(3L, false) } returns DataResult.Success(testCourses[2]) - - val repository = getRepository() - val result = repository.getCoursesById(listOf(1L, 2L, 3L), false) - - assertEquals(3, result.size) - assertEquals("Intro to Programming", result[0].courseName) - assertEquals("Data Analysis", result[1].courseName) - assertEquals("Machine Learning", result[2].courseName) - coVerify { getCoursesManager.getProgramCourses(1L, false) } - coVerify { getCoursesManager.getProgramCourses(2L, false) } - coVerify { getCoursesManager.getProgramCourses(3L, false) } - } - - @Test - fun `enrollCourse returns success result`() = runTest { - coEvery { getProgramsManager.enrollCourse(any()) } returns DataResult.Success(Unit) - - val repository = getRepository() - val result = repository.enrollCourse("progress123") - - assertTrue(result.isSuccess) - coVerify { getProgramsManager.enrollCourse("progress123") } - } - - @Test - fun `enrollCourse returns failure result`() = runTest { - coEvery { getProgramsManager.enrollCourse(any()) } returns DataResult.Fail() - - val repository = getRepository() - val result = repository.enrollCourse("progress123") - - assertTrue(result.isFail) - coVerify { getProgramsManager.enrollCourse("progress123") } - } - - private fun getRepository(): ProgramDetailsRepository { - return ProgramDetailsRepository(getProgramsManager, getCoursesManager) - } - - private fun createTestProgram( - id: String = "testProgram", - name: String = "Test Program", - requirements: List = emptyList() - ): Program = Program( - id = id, - name = name, - description = "Test description", - startDate = null, - endDate = null, - variant = ProgramVariantType.LINEAR, - courseCompletionCount = null, - sortedRequirements = requirements - ) - - private fun createTestProgramRequirement( - courseId: Long = 1L, - progress: Double = 0.0 - ): ProgramRequirement = ProgramRequirement( - id = "requirement$courseId", - progressId = "progress$courseId", - courseId = courseId, - required = true, - progress = progress, - enrollmentStatus = null - ) - - private fun createTestCourse( - courseId: Long = 1L, - courseName: String = "Test Course" - ): CourseWithModuleItemDurations = CourseWithModuleItemDurations( - courseId = courseId, - courseName = courseName, - moduleItemsDuration = listOf("PT1H"), - startDate = null, - endDate = null - ) -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt index 9c70fd1f4c..6fd28a18cb 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/details/ProgramDetailsViewModelTest.kt @@ -25,11 +25,15 @@ import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequir import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.R +import com.instructure.horizon.data.repository.ProgramRepository +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.navigation.LearnRoute import com.instructure.horizon.features.learn.program.details.components.CourseCardStatus import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -53,9 +57,12 @@ import java.util.Locale class ProgramDetailsViewModelTest { private val context: Context = mockk(relaxed = true) private val resources: Resources = mockk(relaxed = true) - private val repository: ProgramDetailsRepository = mockk(relaxed = true) + private val repository: ProgramRepository = mockk(relaxed = true) private val dashboardEventHandler: DashboardEventHandler = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() private val testProgramId = "program123" @@ -68,6 +75,8 @@ class ProgramDetailsViewModelTest { every { context.getSharedPreferences(any(), any()) } returns sharedPrefs every { sharedPrefs.getInt(any(), any()) } returns 0 + every { networkStateProvider.isOnline() } returns true + coEvery { featureFlagProvider.offlineEnabled() } returns false ContextKeeper.appContext = context every { savedStateHandle.get(LearnRoute.LearnProgramDetailsScreen.programIdAttr) } returns testProgramId every { context.getString(any()) } returns "" @@ -649,7 +658,7 @@ class ProgramDetailsViewModelTest { } private fun getViewModel(): ProgramDetailsViewModel { - val viewModel = ProgramDetailsViewModel(context, resources, repository, dashboardEventHandler, savedStateHandle) + val viewModel = ProgramDetailsViewModel(context, resources, repository, dashboardEventHandler, savedStateHandle, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) testDispatcher.scheduler.advanceUntilIdle() return viewModel } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt index 21ed42e399..3c05173b09 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt @@ -33,6 +33,7 @@ import com.instructure.canvasapi2.utils.RemoteConfigPrefs import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.pandautils.dialogs.RatingDialog import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.features.offline.sync.OfflineHtmlParserFileSource import com.instructure.pandautils.room.offline.daos.FileFolderDao import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao import com.instructure.pandautils.room.offline.daos.LocalFileDao @@ -137,7 +138,8 @@ class ApplicationModule { fileSyncSettingsDao: FileSyncSettingsDao, fileFolderApi: FileFolderAPI.FilesFoldersInterface ): HtmlParser { - return HtmlParser(localFileDao, apiPrefs, fileFolderDao, context, fileSyncSettingsDao, fileFolderApi) + val fileSource = OfflineHtmlParserFileSource(localFileDao, fileFolderDao, fileSyncSettingsDao, fileFolderApi) + return HtmlParser(fileSource, apiPrefs, context) } @Provides diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt index 21ee877f56..e61f86e686 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt @@ -17,24 +17,16 @@ package com.instructure.pandautils.features.offline.sync import android.content.Context -import android.net.Uri -import com.instructure.canvasapi2.apis.FileFolderAPI -import com.instructure.canvasapi2.builders.RestParams +import androidx.core.net.toUri import com.instructure.canvasapi2.models.StudioMediaMetadata import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.pandautils.room.offline.daos.FileFolderDao -import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao -import com.instructure.pandautils.room.offline.daos.LocalFileDao import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File class HtmlParser( - private var localFileDao: LocalFileDao, + private val fileSource: HtmlParserFileSource, private val apiPrefs: ApiPrefs, - private val fileFolderDao: FileFolderDao, @ApplicationContext private val context: Context, - private val fileSyncSettingsDao: FileSyncSettingsDao, - private val fileFolderApi: FileFolderAPI.FilesFoldersInterface ) { private val imageRegex = Regex("]*src=\"([^\"]*)\"[^>]*>") @@ -78,8 +70,21 @@ class HtmlParser( val (newHtml, shouldSyncFile) = replaceInternalFileUrl(resultHtml, courseId, fileId, imageUrl) resultHtml = newHtml if (shouldSyncFile) internalFileIds.add(fileId) - } else { - val fileUri = Uri.parse(imageUrl) + } else if (imageUrl.toUri().isRelative) { + val relativeInternalFileRegex = Regex(".*files/(\\d+)") + val fileId = relativeInternalFileRegex.find(imageUrl)?.groupValues?.get(1)?.toLongOrNull() + if (fileId != null) { + val (newHtml, shouldSyncFile) = replaceInternalFileUrl( + resultHtml, + courseId, + fileId, + imageUrl + ) + resultHtml = newHtml + if (shouldSyncFile) internalFileIds.add(fileId) + } + } else { + val fileUri = imageUrl.toUri() val fileName = fileUri.lastPathSegment if (fileName != null && fileUri.scheme == "https") { // We don't allow cleartext traffic in the app. resultHtml = resultHtml.replace(imageUrl, "file://${createLocalFilePathForExternalFile(fileName, courseId)}") @@ -95,12 +100,12 @@ class HtmlParser( var resultHtml = html var shouldSyncFile = false - val filePath = localFileDao.findById(fileId)?.path + val filePath = fileSource.findLocalFilePath(fileId) if (!filePath.isNullOrEmpty()) { resultHtml = resultHtml.replace(imageUrl, "file://$filePath") } else { resultHtml = resultHtml.replace(imageUrl, "file://${createLocalFilePath(fileId, courseId)}") - if (fileSyncSettingsDao.findById(fileId) == null) { + if (!fileSource.isRegisteredForSync(fileId)) { shouldSyncFile = true } } @@ -109,16 +114,10 @@ class HtmlParser( } private suspend fun createLocalFilePath(fileId: Long, courseId: Long): String { - var fileName = fileFolderDao.findById(fileId)?.displayName.orEmpty() - if (fileName.isEmpty()) { - val file = fileFolderApi.getCourseFile(courseId, fileId, RestParams(isForceReadFromNetwork = false, shouldLoginOnTokenError = false)).dataOrNull - fileName = file?.displayName.orEmpty() - } + val fileName = fileSource.findDisplayName(fileId, courseId).orEmpty() val fileNameWithId = if (fileName.isNotEmpty()) "${fileId}_$fileName" else "$fileId" val dir = File(context.filesDir, apiPrefs.user?.id.toString()) - - val downloadedFile = File(dir, fileNameWithId) - return downloadedFile.absolutePath + return File(dir, fileNameWithId).absolutePath } private fun createLocalFilePathForExternalFile(fileName: String, courseId: Long): String { @@ -136,7 +135,7 @@ class HtmlParser( val fileUrl = match.groupValues[1] val fileId = internalFileRegex.find(fileUrl)?.groupValues?.get(1)?.toLongOrNull() if (fileId != null) { - if (fileSyncSettingsDao.findById(fileId) == null) { + if (!fileSource.isRegisteredForSync(fileId)) { internalFileIds.add(fileId) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParserFileSource.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParserFileSource.kt new file mode 100644 index 0000000000..95ac537778 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParserFileSource.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.pandautils.features.offline.sync + +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.pandautils.room.offline.daos.FileFolderDao +import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao +import com.instructure.pandautils.room.offline.daos.LocalFileDao + +interface HtmlParserFileSource { + suspend fun findLocalFilePath(fileId: Long): String? + suspend fun findDisplayName(fileId: Long, courseId: Long): String? + suspend fun isRegisteredForSync(fileId: Long): Boolean +} + +class OfflineHtmlParserFileSource( + private val localFileDao: LocalFileDao, + private val fileFolderDao: FileFolderDao, + private val fileSyncSettingsDao: FileSyncSettingsDao, + private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, +) : HtmlParserFileSource { + + override suspend fun findLocalFilePath(fileId: Long): String? { + return localFileDao.findById(fileId)?.path + } + + override suspend fun findDisplayName(fileId: Long, courseId: Long): String? { + fileFolderDao.findById(fileId)?.displayName?.takeIf { it.isNotEmpty() }?.let { return it } + return fileFolderApi.getCourseFile( + courseId, fileId, + RestParams(isForceReadFromNetwork = false, shouldLoginOnTokenError = false) + ).dataOrNull?.displayName + } + + override suspend fun isRegisteredForSync(fileId: Long): Boolean { + return fileSyncSettingsDao.findById(fileId) != null + } +} diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt index 1aec9d2768..b9e62324cc 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt @@ -18,17 +18,9 @@ package com.instructure.pandautils.features.offline.sync import android.content.Context import android.net.Uri -import com.instructure.canvasapi2.apis.FileFolderAPI -import com.instructure.canvasapi2.models.FileFolder import com.instructure.canvasapi2.models.StudioCaption import com.instructure.canvasapi2.models.StudioMediaMetadata import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.pandautils.room.offline.daos.FileFolderDao -import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao -import com.instructure.pandautils.room.offline.daos.LocalFileDao -import com.instructure.pandautils.room.offline.entities.FileSyncSettingsEntity -import com.instructure.pandautils.room.offline.entities.LocalFileEntity import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -42,18 +34,14 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.File -import java.util.Date class HtmlParserTest { - private var localFileDao: LocalFileDao = mockk(relaxed = true) + private val fileSource: HtmlParserFileSource = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) - private val fileFolderDao: FileFolderDao = mockk(relaxed = true) private val context: Context = mockk(relaxed = true) - private val fileSyncSettingsDao: FileSyncSettingsDao = mockk(relaxed = true) - private val fileFolderApi: FileFolderAPI.FilesFoldersInterface = mockk(relaxed = true) - private val htmlParser = HtmlParser(localFileDao, apiPrefs, fileFolderDao, context, fileSyncSettingsDao, fileFolderApi) + private val htmlParser = HtmlParser(fileSource, apiPrefs, context) @Before fun setup() { @@ -67,6 +55,7 @@ class HtmlParserTest { mockk() { every { lastPathSegment } returns url.split("/").last() every { scheme } returns "https" + every { isRelative } returns false } } } @@ -102,7 +91,7 @@ class HtmlParserTest { "
" + "\"\"" - coEvery { localFileDao.findById(123456) } returns LocalFileEntity(123456, 1L, Date(), "/files/1/123456_filename.jpg") + coEvery { fileSource.findLocalFilePath(123456) } returns "/files/1/123456_filename.jpg" val result = htmlParser.createHtmlStringWithLocalFiles(html, 1L) val expectedHtml = "

This is an Assignment. You can tell when you look at it from the Calendar or from the Modules page because it has the Canvas Assignments Icon displayed next to it:   

\n" + @@ -120,11 +109,7 @@ class HtmlParserTest { "
" + "\"\"" - coEvery { fileFolderApi.getCourseFile(1L, 123456, any()) } returns DataResult.Success( - FileFolder(id = 123456, displayName = "filenameFromNetwork.jpg") - ) - - coEvery { fileSyncSettingsDao.findById(123456) } returns null + coEvery { fileSource.findDisplayName(123456, 1L) } returns "filenameFromNetwork.jpg" val result = htmlParser.createHtmlStringWithLocalFiles(html, 1L) val expectedHtml = "

This is an Assignment. You can tell when you look at it from the Calendar or from the Modules page because it has the Canvas Assignments Icon displayed next to it:   

\n" + @@ -165,11 +150,7 @@ class HtmlParserTest { "

Internal public:

\n" + "

\"image2.png\"

" - coEvery { fileFolderApi.getCourseFile(1L, 123456, any()) } returns DataResult.Success( - FileFolder(id = 123456) - ) - coEvery { fileSyncSettingsDao.findById(123456) } returns null - coEvery { localFileDao.findById(789) } returns LocalFileEntity(789, 1L, Date(), "/files/1/789_image2.png") + coEvery { fileSource.findLocalFilePath(789) } returns "/files/1/789_image2.png" val result = htmlParser.createHtmlStringWithLocalFiles(html, 1L) @@ -200,8 +181,7 @@ class HtmlParserTest { "

File not synced:

\n" + "

file.pdf

" - coEvery { fileSyncSettingsDao.findById(1234) } returns FileSyncSettingsEntity(1234, "name", 1L, "") - coEvery { fileSyncSettingsDao.findById(678) } returns null + coEvery { fileSource.isRegisteredForSync(1234) } returns true val result = htmlParser.createHtmlStringWithLocalFiles(html, 1L) @@ -406,8 +386,7 @@ class HtmlParserTest { """.trimIndent() - coEvery { localFileDao.findById(123456) } returns LocalFileEntity(123456, 1L, Date(), "/files/1/123456_internal.jpg") - coEvery { fileSyncSettingsDao.findById(789) } returns null + coEvery { fileSource.findLocalFilePath(123456) } returns "/files/1/123456_internal.jpg" val studioMetaData = listOf( StudioMediaMetadata(1, "video-old", "Old Video", "video/mp4", 1000, emptyList(), "https://studio/media/video-old"),