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 f55d2272bf..d98dda0d48 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 @@ -20,6 +20,7 @@ import com.instructure.canvas.espresso.mockcanvas.MockCanvas import com.instructure.canvasapi2.GetCoursesQuery import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.DashboardEnrollment import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager import com.instructure.canvasapi2.type.EnrollmentWorkflowState import com.instructure.canvasapi2.utils.DataResult @@ -89,6 +90,37 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager { ) } + override suspend fun getDashboardEnrollments( + userId: Long, + forceNetwork: Boolean + ): DataResult> { + val activeCourse = getCourses()[0] + val completedCourse = getCourses()[1] + + return DataResult.Success(listOf( + DashboardEnrollment( + enrollmentId = MockCanvas.data.enrollments.values.toList()[0].id, + enrollmentState = MockCanvas.data.enrollments.values.toList()[0].enrollmentState.orEmpty(), + courseId = activeCourse.courseId, + courseName = activeCourse.courseName, + courseImageUrl = activeCourse.courseImageUrl, + courseSyllabus = activeCourse.courseSyllabus, + institutionName = null, + completionPercentage = activeCourse.progress + ), + DashboardEnrollment( + enrollmentId = MockCanvas.data.enrollments.values.toList()[1].id, + enrollmentState = MockCanvas.data.enrollments.values.toList()[1].enrollmentState.orEmpty(), + courseId = completedCourse.courseId, + courseName = completedCourse.courseName, + courseImageUrl = completedCourse.courseImageUrl, + courseSyllabus = completedCourse.courseSyllabus, + institutionName = null, + completionPercentage = completedCourse.progress + ) + )) + } + override suspend fun getProgramCourses( courseId: Long, forceNetwork: Boolean diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/HorizonGetCoursesManager.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/HorizonGetCoursesManager.kt index 1ab943957e..8269558899 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/HorizonGetCoursesManager.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/HorizonGetCoursesManager.kt @@ -34,6 +34,8 @@ interface HorizonGetCoursesManager { suspend fun getEnrollments(userId: Long, forceNetwork: Boolean = false): DataResult> + suspend fun getDashboardEnrollments(userId: Long, forceNetwork: Boolean = false): DataResult> + suspend fun getProgramCourses(courseId: Long, forceNetwork: Boolean = false): DataResult } @@ -101,6 +103,33 @@ class HorizonGetCoursesManagerImpl(private val apolloClient: ApolloClient): Hori } } + override suspend fun getDashboardEnrollments(userId: Long, forceNetwork: Boolean): DataResult> { + return try { + val query = GetCoursesQuery(userId.toString()) + val result = apolloClient.enqueueQuery(query, forceNetwork).dataAssertNoErrors + val enrollments = result.legacyNode?.onUser?.enrollments.orEmpty().mapNotNull { enrollment -> + val course = enrollment.course ?: return@mapNotNull null + val courseId = course.id.toLongOrNull() ?: return@mapNotNull null + val enrollmentId = enrollment.id?.toLongOrNull() ?: return@mapNotNull null + val progress = course.usersConnection?.nodes?.firstOrNull() + ?.courseProgression?.requirements?.completionPercentage ?: 0.0 + DashboardEnrollment( + enrollmentId = enrollmentId, + enrollmentState = enrollment.state.rawValue, + courseId = courseId, + courseName = course.name, + courseImageUrl = course.image_download_url, + courseSyllabus = course.syllabus_body, + institutionName = course.account?.name, + completionPercentage = progress, + ) + } + DataResult.Success(enrollments) + } catch (e: Exception) { + DataResult.Fail(Failure.Exception(e)) + } + } + override suspend fun getProgramCourses(courseId: Long, forceNetwork: Boolean): DataResult { var hasNextPage = true var nextCursor: String? = null @@ -137,6 +166,23 @@ class HorizonGetCoursesManagerImpl(private val apolloClient: ApolloClient): Hori } } +data class DashboardEnrollment( + val enrollmentId: Long, + val enrollmentState: String, + val courseId: Long, + val courseName: String, + val courseImageUrl: String?, + val courseSyllabus: String?, + val institutionName: String?, + val completionPercentage: Double, +) { + companion object { + const val STATE_ACTIVE = "active" + const val STATE_INVITED = "invited" + const val STATE_COMPLETED = "completed" + } +} + data class CourseWithProgress( val courseId: Long, val courseName: String, diff --git a/libs/horizon/build.gradle.kts b/libs/horizon/build.gradle.kts index 41d8a6056d..bdbb052a7f 100644 --- a/libs/horizon/build.gradle.kts +++ b/libs/horizon/build.gradle.kts @@ -90,6 +90,12 @@ dependencies { isTransitive = true } + /* Room */ + implementation(Libs.ROOM) + implementation(Libs.ROOM_COROUTINES) + ksp(Libs.ROOM_COMPILER) + testImplementation(Libs.ROOM_TEST) + /* Android Test Dependencies */ androidTestImplementation(project(":espresso")) androidTestImplementation(project(":dataseedingapi")) 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 ab583dc9d8..f641e452af 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 @@ -1,6 +1,8 @@ package com.instructure.horizon.espresso +import android.content.Context import android.content.Intent +import androidx.room.Room import com.instructure.canvasapi2.LoginRouter import com.instructure.canvasapi2.utils.pageview.PandataInfo import com.instructure.pandautils.features.about.AboutRepository @@ -68,6 +70,7 @@ 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 @Module @@ -98,8 +101,10 @@ object HorizonTestModule { } @Provides - fun provideAppDatabase(): AppDatabase { - throw NotImplementedError("This is a test module. Implementation not required.") + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() } @Provides diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt new file mode 100644 index 0000000000..9cf84388b9 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentLocalDataSource.kt @@ -0,0 +1,69 @@ +/* + * 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.DashboardEnrollment +import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao +import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonDashboardEnrollmentEntity +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType +import javax.inject.Inject + +class CourseEnrollmentLocalDataSource @Inject constructor( + private val enrollmentDao: HorizonDashboardEnrollmentDao, + private val syncMetadataDao: HorizonSyncMetadataDao, +) { + + suspend fun getEnrollments(): List { + return enrollmentDao.getAll().map { entity -> + DashboardEnrollment( + enrollmentId = entity.enrollmentId, + enrollmentState = entity.enrollmentState, + courseId = entity.courseId, + courseName = entity.courseName, + courseImageUrl = entity.courseImageUrl, + courseSyllabus = entity.courseSyllabus, + institutionName = entity.institutionName, + completionPercentage = entity.completionPercentage, + ) + } + } + + suspend fun saveEnrollments(enrollments: List) { + val entities = enrollments.map { enrollment -> + HorizonDashboardEnrollmentEntity( + enrollmentId = enrollment.enrollmentId, + enrollmentState = enrollment.enrollmentState, + courseId = enrollment.courseId, + courseName = enrollment.courseName, + courseImageUrl = enrollment.courseImageUrl, + courseSyllabus = enrollment.courseSyllabus, + institutionName = enrollment.institutionName, + completionPercentage = enrollment.completionPercentage, + ) + } + enrollmentDao.replaceAll(entities) + syncMetadataDao.upsert( + HorizonSyncMetadataEntity( + dataType = SyncDataType.DASHBOARD_ENROLLMENTS, + lastSyncedAtMs = System.currentTimeMillis(), + ) + ) + } + + suspend fun getAllCourseIds(): List = enrollmentDao.getAllCourseIds() +} 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 new file mode 100644 index 0000000000..67ac235dea --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseEnrollmentNetworkDataSource.kt @@ -0,0 +1,41 @@ +/* + * 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.builders.RestParams +import com.instructure.canvasapi2.managers.graphql.horizon.DashboardEnrollment +import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager +import com.instructure.canvasapi2.utils.ApiPrefs +import javax.inject.Inject + +class CourseEnrollmentNetworkDataSource @Inject constructor( + private val horizonGetCoursesManager: HorizonGetCoursesManager, + private val apiPrefs: ApiPrefs, + private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, +) { + + suspend fun getEnrollments(): List { + return horizonGetCoursesManager.getDashboardEnrollments( + userId = apiPrefs.user?.id ?: -1, + forceNetwork = true, + ).dataOrThrow + } + + suspend fun acceptInvite(courseId: Long, enrollmentId: Long) { + enrollmentApi.acceptInvite(courseId, enrollmentId, RestParams()).dataOrThrow + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemLocalDataSource.kt new file mode 100644 index 0000000000..273cfb5b62 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemLocalDataSource.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.horizon.database.dao.HorizonDashboardModuleItemDao +import com.instructure.horizon.database.entity.HorizonDashboardModuleItemEntity +import com.instructure.horizon.model.DashboardNextModuleItem +import com.instructure.horizon.model.LearningObjectType +import java.util.Date +import javax.inject.Inject + +class ModuleItemLocalDataSource @Inject constructor( + private val moduleItemDao: HorizonDashboardModuleItemDao, +) { + + suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { + val entity = moduleItemDao.getFirstForCourse(courseId) ?: return null + return DashboardNextModuleItem( + moduleItemId = entity.moduleItemId, + courseId = entity.courseId, + title = entity.moduleItemTitle, + type = LearningObjectType.valueOf(entity.moduleItemType), + dueDate = entity.dueDateMs?.let { Date(it) }, + estimatedDuration = entity.estimatedDuration, + isQuizLti = entity.isQuizLti, + ) + } + + suspend fun saveNextModuleItem(item: DashboardNextModuleItem) { + val entity = HorizonDashboardModuleItemEntity( + moduleItemId = item.moduleItemId, + courseId = item.courseId, + moduleItemTitle = item.title, + moduleItemType = item.type.name, + dueDateMs = item.dueDate?.time, + estimatedDuration = item.estimatedDuration, + isQuizLti = item.isQuizLti, + ) + moduleItemDao.replaceForCourse(entity) + } +} 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 new file mode 100644 index 0000000000..baa01de2f3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ModuleItemNetworkDataSource.kt @@ -0,0 +1,49 @@ +/* + * 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.horizon.model.DashboardNextModuleItem +import com.instructure.horizon.model.LearningObjectType +import javax.inject.Inject + +class ModuleItemNetworkDataSource @Inject constructor( + private val moduleApi: ModuleAPI.ModuleInterface, +) { + + suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { + val params = RestParams(isForceReadFromNetwork = true) + val modules = moduleApi.getFirstPageModulesWithItems( + CanvasContext.Type.COURSE.apiString, + courseId, + params, + includes = listOf("estimated_durations"), + ).dataOrThrow + val item = modules.flatMap { it.items }.firstOrNull() ?: return null + return DashboardNextModuleItem( + moduleItemId = item.id, + courseId = courseId, + title = item.title.orEmpty(), + type = if (item.quizLti) LearningObjectType.ASSESSMENT + else LearningObjectType.fromApiString(item.type.orEmpty()), + dueDate = item.moduleDetails?.dueDate, + estimatedDuration = item.estimatedDuration, + isQuizLti = item.quizLti, + ) + } +} 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 new file mode 100644 index 0000000000..20c4ba6cb4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramLocalDataSource.kt @@ -0,0 +1,89 @@ +/* + * 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.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.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, +) { + + suspend fun getPrograms(): List { + return programDao.getAll().map { programEntity -> + val refs = programDao.getRefsForProgram(programEntity.programId) + Program( + id = programEntity.programId, + name = programEntity.programName, + description = programEntity.description, + startDate = programEntity.startDateMs?.let { Date(it) }, + endDate = programEntity.endDateMs?.let { Date(it) }, + variant = ProgramVariantType.safeValueOf(programEntity.variant), + courseCompletionCount = programEntity.courseCompletionCount, + sortedRequirements = 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 { + ProgramProgressCourseEnrollmentStatus.safeValueOf(it) + }, + ) + }, + ) + } + } + + suspend fun savePrograms(programs: List, enrolledCourseIds: Set) { + val programEntities = programs.map { program -> + HorizonDashboardProgramEntity( + programId = program.id, + programName = program.name, + description = program.description, + startDateMs = program.startDate?.time, + endDateMs = program.endDate?.time, + variant = program.variant.rawValue, + courseCompletionCount = program.courseCompletionCount, + ) + } + val refs = programs.flatMap { program -> + program.sortedRequirements + .filter { it.courseId in enrolledCourseIds } + .mapIndexed { index, req -> + HorizonDashboardProgramCourseRef( + 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.replaceAll(programEntities, refs) + } +} 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 new file mode 100644 index 0000000000..aaea7ad793 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/ProgramNetworkDataSource.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.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import javax.inject.Inject + +class ProgramNetworkDataSource @Inject constructor( + private val getProgramsManager: GetProgramsManager, +) { + + suspend fun getPrograms(): List { + return getProgramsManager.getPrograms(forceNetwork = true) + } +} 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 new file mode 100644 index 0000000000..f68048afbd --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/CourseEnrollmentRepository.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.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.DashboardEnrollment +import com.instructure.horizon.data.datasource.CourseEnrollmentLocalDataSource +import com.instructure.horizon.data.datasource.CourseEnrollmentNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class CourseEnrollmentRepository @Inject constructor( + private val networkDataSource: CourseEnrollmentNetworkDataSource, + private val localDataSource: CourseEnrollmentLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getEnrollments(): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getEnrollments() + .also { if (shouldSync()) localDataSource.saveEnrollments(it) } + } else { + localDataSource.getEnrollments() + } + } + + suspend fun getEnrolledCourseIds(): List = localDataSource.getAllCourseIds() + + suspend fun acceptInvite(courseId: Long, enrollmentId: Long) { + networkDataSource.acceptInvite(courseId, enrollmentId) + } + + 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 new file mode 100644 index 0000000000..e7127d6caf --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ModuleItemRepository.kt @@ -0,0 +1,45 @@ +/* + * 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.horizon.data.datasource.ModuleItemLocalDataSource +import com.instructure.horizon.data.datasource.ModuleItemNetworkDataSource +import com.instructure.horizon.model.DashboardNextModuleItem +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class ModuleItemRepository @Inject constructor( + private val networkDataSource: ModuleItemNetworkDataSource, + private val localDataSource: ModuleItemLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getNextModuleItemForCourse(courseId: Long): DashboardNextModuleItem? { + return if (shouldFetchFromNetwork()) { + networkDataSource.getNextModuleItemForCourse(courseId) + .also { item -> if (shouldSync() && item != null) localDataSource.saveNextModuleItem(item) } + } else { + localDataSource.getNextModuleItemForCourse(courseId) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} 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 new file mode 100644 index 0000000000..5ac37c4f6e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/ProgramRepository.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.data.repository + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.horizon.data.datasource.ProgramLocalDataSource +import com.instructure.horizon.data.datasource.ProgramNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class ProgramRepository @Inject constructor( + private val networkDataSource: ProgramNetworkDataSource, + private val localDataSource: ProgramLocalDataSource, + private val enrollmentRepository: CourseEnrollmentRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getPrograms(): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getPrograms() + .also { programs -> + if (shouldSync()) { + val enrolledCourseIds = enrollmentRepository.getEnrolledCourseIds().toSet() + localDataSource.savePrograms(programs, enrolledCourseIds) + } + } + } else { + localDataSource.getPrograms() + } + } + + 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 new file mode 100644 index 0000000000..fca3d4a40e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -0,0 +1,47 @@ +/* + * 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 + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +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.HorizonSyncMetadataDao +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.HorizonSyncMetadataEntity + +@TypeConverters(HorizonTypeConverters::class) +@Database( + entities = [ + HorizonDashboardEnrollmentEntity::class, + HorizonDashboardProgramEntity::class, + HorizonDashboardProgramCourseRef::class, + HorizonDashboardModuleItemEntity::class, + HorizonSyncMetadataEntity::class, + ], + version = 2, +) +abstract class HorizonDatabase : RoomDatabase() { + abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao + abstract fun dashboardProgramDao(): HorizonDashboardProgramDao + abstract fun dashboardModuleItemDao(): HorizonDashboardModuleItemDao + abstract fun syncMetadataDao(): HorizonSyncMetadataDao +} 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 new file mode 100644 index 0000000000..aef8525cb3 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabaseProvider.kt @@ -0,0 +1,37 @@ +/* + * 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 + +import android.content.Context +import androidx.room.Room +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HorizonDatabaseProvider @Inject constructor( + @ApplicationContext private val context: Context +) { + private val dbMap = mutableMapOf() + + fun getDatabase(userId: Long): HorizonDatabase { + return dbMap.getOrPut(userId) { + Room.databaseBuilder(context, HorizonDatabase::class.java, "horizon-db-$userId") + .fallbackToDestructiveMigration() + .build() + } + } +} 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 new file mode 100644 index 0000000000..c079e84003 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonTypeConverters.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import androidx.room.TypeConverter +import com.instructure.horizon.database.entity.SyncDataType + +class HorizonTypeConverters { + + @TypeConverter + fun fromSyncDataType(value: SyncDataType): String = value.name + + @TypeConverter + fun toSyncDataType(value: String): SyncDataType = SyncDataType.valueOf(value) +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardEnrollmentDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardEnrollmentDao.kt new file mode 100644 index 0000000000..3e62613267 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardEnrollmentDao.kt @@ -0,0 +1,45 @@ +/* + * 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.HorizonDashboardEnrollmentEntity + +@Dao +interface HorizonDashboardEnrollmentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("SELECT * FROM horizon_dashboard_enrollments") + suspend fun getAll(): List + + @Query("SELECT courseId FROM horizon_dashboard_enrollments") + suspend fun getAllCourseIds(): List + + @Query("DELETE FROM horizon_dashboard_enrollments") + 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/HorizonDashboardModuleItemDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardModuleItemDao.kt new file mode 100644 index 0000000000..a567ece498 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardModuleItemDao.kt @@ -0,0 +1,45 @@ +/* + * 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.HorizonDashboardModuleItemEntity + +@Dao +interface HorizonDashboardModuleItemDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(items: List) + + @Query("SELECT * FROM horizon_dashboard_module_items WHERE courseId = :courseId LIMIT 1") + suspend fun getFirstForCourse(courseId: Long): HorizonDashboardModuleItemEntity? + + @Query("DELETE FROM horizon_dashboard_module_items WHERE courseId = :courseId") + suspend fun deleteForCourse(courseId: Long) + + @Query("DELETE FROM horizon_dashboard_module_items") + suspend fun deleteAll() + + @Transaction + suspend fun replaceForCourse(entity: HorizonDashboardModuleItemEntity) { + deleteForCourse(entity.courseId) + insertAll(listOf(entity)) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.kt new file mode 100644 index 0000000000..701b10e33a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonDashboardProgramDao.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.HorizonDashboardProgramCourseRef +import com.instructure.horizon.database.entity.HorizonDashboardProgramEntity + +@Dao +interface HorizonDashboardProgramDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(programs: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllRefs(refs: List) + + @Query("SELECT * FROM horizon_dashboard_programs") + suspend fun getAll(): List + + @Query("SELECT * FROM horizon_dashboard_program_course_refs WHERE programId = :programId") + suspend fun getRefsForProgram(programId: String): List + + @Query("DELETE FROM horizon_dashboard_programs") + suspend fun deleteAll() + + @Query("DELETE FROM horizon_dashboard_program_course_refs") + suspend fun deleteAllRefs() + + @Transaction + suspend fun replaceAll( + programs: List, + refs: List, + ) { + deleteAllRefs() + deleteAll() + insertAll(programs) + insertAllRefs(refs) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSyncMetadataDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSyncMetadataDao.kt new file mode 100644 index 0000000000..029413ea98 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSyncMetadataDao.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.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity +import com.instructure.horizon.database.entity.SyncDataType + +@Dao +interface HorizonSyncMetadataDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: HorizonSyncMetadataEntity) + + @Query("SELECT lastSyncedAtMs FROM horizon_sync_metadata WHERE dataType = :dataType") + suspend fun getLastSyncedAt(dataType: SyncDataType): Long? +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardEnrollmentEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardEnrollmentEntity.kt new file mode 100644 index 0000000000..a5f4673b76 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardEnrollmentEntity.kt @@ -0,0 +1,31 @@ +/* + * 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_dashboard_enrollments") +data class HorizonDashboardEnrollmentEntity( + @PrimaryKey val enrollmentId: Long, + val enrollmentState: String, + val courseId: Long, + val courseName: String, + val courseImageUrl: String?, + val courseSyllabus: String?, + val institutionName: String?, + val completionPercentage: Double, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardModuleItemEntity.kt new file mode 100644 index 0000000000..8093762344 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardModuleItemEntity.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 + +@Entity( + tableName = "horizon_dashboard_module_items", + indices = [Index("courseId")] +) +data class HorizonDashboardModuleItemEntity( + @PrimaryKey val moduleItemId: Long, + val courseId: Long, + val moduleItemTitle: String, + val moduleItemType: String, + val dueDateMs: Long?, + val estimatedDuration: String?, + val isQuizLti: Boolean, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.kt new file mode 100644 index 0000000000..d2da522403 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramCourseRef.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_dashboard_program_course_refs", + primaryKeys = ["programId", "courseId"] +) +data class HorizonDashboardProgramCourseRef( + 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/HorizonDashboardProgramEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramEntity.kt new file mode 100644 index 0000000000..e6fa5d7d35 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonDashboardProgramEntity.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_dashboard_programs") +data class HorizonDashboardProgramEntity( + @PrimaryKey val programId: String, + val programName: String, + val description: String?, + val startDateMs: Long?, + val endDateMs: Long?, + val variant: String, + val courseCompletionCount: Int?, +) 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 new file mode 100644 index 0000000000..852461480c --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -0,0 +1,31 @@ +/* + * 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 + +enum class SyncDataType { + DASHBOARD_ENROLLMENTS, + DASHBOARD_PROGRAMS, + DASHBOARD_MODULE_ITEMS, +} + +@Entity(tableName = "horizon_sync_metadata") +data class HorizonSyncMetadataEntity( + @PrimaryKey val dataType: SyncDataType, + val lastSyncedAtMs: Long, +) \ No newline at end of file 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 new file mode 100644 index 0000000000..99124f89d8 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -0,0 +1,62 @@ +/* + * 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 com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.database.HorizonDatabase +import com.instructure.horizon.database.HorizonDatabaseProvider +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.HorizonSyncMetadataDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class HorizonOfflineModule { + + @Provides + fun provideHorizonDatabase( + provider: HorizonDatabaseProvider, + apiPrefs: ApiPrefs, + ): HorizonDatabase { + val userId = apiPrefs.user?.id ?: -1L + return provider.getDatabase(userId) + } + + @Provides + fun provideHorizonDashboardEnrollmentDao(db: HorizonDatabase): HorizonDashboardEnrollmentDao { + return db.dashboardEnrollmentDao() + } + + @Provides + fun provideHorizonDashboardProgramDao(db: HorizonDatabase): HorizonDashboardProgramDao { + return db.dashboardProgramDao() + } + + @Provides + fun provideHorizonDashboardModuleItemDao(db: HorizonDatabase): HorizonDashboardModuleItemDao { + return db.dashboardModuleItemDao() + } + + @Provides + fun provideHorizonSyncMetadataDao(db: HorizonDatabase): HorizonSyncMetadataDao { + return db.syncMetadataDao() + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/AcceptCourseInviteUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/AcceptCourseInviteUseCase.kt new file mode 100644 index 0000000000..643c799a1c --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/AcceptCourseInviteUseCase.kt @@ -0,0 +1,31 @@ +/* + * 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.horizon.data.repository.CourseEnrollmentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class AcceptCourseInviteUseCase @Inject constructor( + private val repository: CourseEnrollmentRepository, +) : BaseUseCase() { + + data class Params(val courseId: Long, val enrollmentId: Long) + + override suspend fun execute(params: Params) { + repository.acceptInvite(params.courseId, params.enrollmentId) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetDashboardCoursesUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetDashboardCoursesUseCase.kt new file mode 100644 index 0000000000..dbb1e5cd48 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetDashboardCoursesUseCase.kt @@ -0,0 +1,75 @@ +/* + * 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.DashboardEnrollment +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.horizon.model.DashboardNextModuleItem +import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class DashboardCoursesData( + val enrollments: List, + val programs: List, + val unenrolledPrograms: List, + val nextModuleItemByCourseId: Map, +) + +class GetDashboardCoursesUseCase @Inject constructor( + private val getEnrollmentsUseCase: GetEnrollmentsUseCase, + private val getProgramsUseCase: GetProgramsUseCase, + private val getNextModuleItemUseCase: GetNextModuleItemUseCase, + private val acceptCourseInviteUseCase: AcceptCourseInviteUseCase, +) : BaseUseCase() { + + suspend operator fun invoke() = invoke(Unit) + + override suspend fun execute(params: Unit): DashboardCoursesData { + var enrollments = getEnrollmentsUseCase() + val programs = getProgramsUseCase() + + val invitations = enrollments.filter { it.enrollmentState == DashboardEnrollment.STATE_INVITED } + if (invitations.isNotEmpty()) { + invitations.forEach { enrollment -> + acceptCourseInviteUseCase( + AcceptCourseInviteUseCase.Params( + courseId = enrollment.courseId, + enrollmentId = enrollment.enrollmentId, + ) + ) + } + enrollments = getEnrollmentsUseCase() + } + + val nextModuleItemByCourseId = enrollments + .filter { it.enrollmentState == DashboardEnrollment.STATE_ACTIVE } + .associate { enrollment -> + enrollment.courseId to getNextModuleItemUseCase(GetNextModuleItemUseCase.Params(enrollment.courseId)) + } + + val unenrolledPrograms = programs.filter { program -> + program.sortedRequirements.none { it.enrollmentStatus == ProgramProgressCourseEnrollmentStatus.ENROLLED } + } + + return DashboardCoursesData( + enrollments = enrollments, + programs = programs, + unenrolledPrograms = unenrolledPrograms, + nextModuleItemByCourseId = nextModuleItemByCourseId, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetEnrollmentsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetEnrollmentsUseCase.kt new file mode 100644 index 0000000000..17f2ecca02 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetEnrollmentsUseCase.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.managers.graphql.horizon.DashboardEnrollment +import com.instructure.horizon.data.repository.CourseEnrollmentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetEnrollmentsUseCase @Inject constructor( + private val repository: CourseEnrollmentRepository, +) : BaseUseCase>() { + + suspend operator fun invoke() = invoke(Unit) + + override suspend fun execute(params: Unit) = repository.getEnrollments() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLastSyncedAtUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLastSyncedAtUseCase.kt new file mode 100644 index 0000000000..393c81959a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetLastSyncedAtUseCase.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.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.SyncDataType +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetLastSyncedAtUseCase @Inject constructor( + private val syncMetadataDao: HorizonSyncMetadataDao, +) : BaseUseCase() { + + data class Params(val syncDataType: SyncDataType) + + override suspend fun execute(params: Params): Long? = syncMetadataDao.getLastSyncedAt(params.syncDataType) +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetNextModuleItemUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetNextModuleItemUseCase.kt new file mode 100644 index 0000000000..320a808de2 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetNextModuleItemUseCase.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.horizon.data.repository.ModuleItemRepository +import com.instructure.horizon.model.DashboardNextModuleItem +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetNextModuleItemUseCase @Inject constructor( + private val repository: ModuleItemRepository, +) : BaseUseCase() { + + data class Params(val courseId: Long) + + override suspend fun execute(params: Params) = repository.getNextModuleItemForCourse(params.courseId) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetProgramsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetProgramsUseCase.kt new file mode 100644 index 0000000000..e59c058be9 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetProgramsUseCase.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.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.horizon.data.repository.ProgramRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetProgramsUseCase @Inject constructor( + private val repository: ProgramRepository, +) : BaseUseCase>() { + suspend operator fun invoke() = invoke(Unit) + + override suspend fun execute(params: Unit) = repository.getPrograms() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt index dba5012b9b..91f8999f7d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt @@ -193,6 +193,9 @@ fun DashboardScreen(uiState: DashboardUiState, navController: NavHostController) .padding(contentPadding) .verticalScroll(scrollState) ) { + if (uiState.isOffline) { + OfflineBanner(lastSyncedAtMs = uiState.lastSyncedAtMs) + } HorizonSpace(SpaceSize.SPACE_12) DashboardAnnouncementBannerWidget( navController, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt index 1397bb1af4..85fb0e30d6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt @@ -25,6 +25,8 @@ data class DashboardUiState( val unreadCountState: DashboardUnreadState = DashboardUnreadState(), val snackbarMessage: String? = null, val onSnackbarDismiss: () -> Unit = {}, + val isOffline: Boolean = false, + val lastSyncedAtMs: Long? = null, ) data class DashboardUnreadState( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt index ab947a05e7..e995debe2b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt @@ -16,13 +16,17 @@ package com.instructure.horizon.features.dashboard import android.content.Context -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.database.entity.SyncDataType +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.LocaleUtils +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.poll import dagger.hilt.android.lifecycle.HiltViewModel @@ -40,8 +44,11 @@ class DashboardViewModel @Inject constructor( private val apiPrefs: ApiPrefs, private val themePrefs: ThemePrefs, private val localeUtils: LocaleUtils, - private val dashboardEventHandler: DashboardEventHandler -) : ViewModel() { + private val dashboardEventHandler: DashboardEventHandler, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { private val _uiState = MutableStateFlow(DashboardUiState(onSnackbarDismiss = ::dismissSnackbar, updateExternalShouldRefresh = ::updateExternalShouldRefresh)) val uiState = _uiState.asStateFlow() @@ -130,4 +137,26 @@ class DashboardViewModel @Inject constructor( it.copy(externalShouldRefresh = value) } } + + override fun onNetworkRestored() { + _uiState.update { it.copy(isOffline = false, lastSyncedAtMs = null) } + } + + override fun onNetworkLost() { + viewModelScope.tryLaunch { + _uiState.update { + it.copy( + isOffline = true, + lastSyncedAtMs = getLastSyncTime(SyncDataType.DASHBOARD_ENROLLMENTS) + ) + } + } catch { + _uiState.update { + it.copy( + isOffline = true, + lastSyncedAtMs = null + ) + } + } + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/OfflineBanner.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/OfflineBanner.kt new file mode 100644 index 0000000000..366a22bfd4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/OfflineBanner.kt @@ -0,0 +1,80 @@ +/* + * 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.dashboard + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.instructure.horizon.R +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import java.text.DateFormat +import java.util.Date + +@Composable +fun OfflineBanner(lastSyncedAtMs: Long?, modifier: Modifier = Modifier) { + val text = if (lastSyncedAtMs != null) { + val formattedDate = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + .format(Date(lastSyncedAtMs)) + stringResource(R.string.offlineBannerWithDate, formattedDate) + } else { + stringResource(R.string.offlineBannerNoDate) + } + + Row( + modifier = modifier + .fillMaxWidth() + .background(HorizonColors.Surface.attention()) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_no_wifi), + contentDescription = null, + tint = Color.White, + ) + Text( + text = text, + style = HorizonTypography.p2, + color = Color.White, + ) + } +} + +@Preview +@Composable +private fun OfflineBannerWithDatePreview() { + OfflineBanner(lastSyncedAtMs = System.currentTimeMillis() - 3_600_000L) +} + +@Preview +@Composable +private fun OfflineBannerNoDDatePreview() { + OfflineBanner(lastSyncedAtMs = null) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseRepository.kt deleted file mode 100644 index fa2bae79c2..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseRepository.kt +++ /dev/null @@ -1,59 +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.dashboard.widget.course - -import com.instructure.canvasapi2.GetCoursesQuery -import com.instructure.canvasapi2.apis.EnrollmentAPI -import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.builders.RestParams -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.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.utils.ApiPrefs -import javax.inject.Inject - -class DashboardCourseRepository @Inject constructor( - private val horizonGetCoursesManager: HorizonGetCoursesManager, - private val moduleApi: ModuleAPI.ModuleInterface, - private val apiPrefs: ApiPrefs, - private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, - private val getProgramsManager: GetProgramsManager, -) { - suspend fun getEnrollments(forceNetwork: Boolean): List { - return horizonGetCoursesManager.getEnrollments(apiPrefs.user?.id ?: -1, forceNetwork).dataOrThrow - } - - suspend fun acceptInvite(courseId: Long, enrollmentId: Long) { - return enrollmentApi.acceptInvite(courseId, enrollmentId, RestParams()).dataOrThrow - } - - suspend fun getPrograms(forceNetwork: Boolean = false): List { - return getProgramsManager.getPrograms(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 - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt index 5a839f4e05..ca4c4f917f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt @@ -1,35 +1,36 @@ /* * 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. + * 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.dashboard.widget.course import android.content.Context -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.instructure.canvasapi2.type.EnrollmentWorkflowState import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetDashboardCoursesUseCase +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.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardState import com.instructure.horizon.features.dashboard.widget.course.card.CardClickAction import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardModuleItemState -import com.instructure.horizon.model.LearningObjectType -import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus +import com.instructure.horizon.model.DashboardNextModuleItem +import com.instructure.horizon.offline.HorizonOfflineViewModel +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.formatIsoDuration import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -42,9 +43,13 @@ import javax.inject.Inject @HiltViewModel class DashboardCourseViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: DashboardCourseRepository, - private val dashboardEventHandler: DashboardEventHandler -): ViewModel() { + private val getDashboardCoursesUseCase: GetDashboardCoursesUseCase, + private val dashboardEventHandler: DashboardEventHandler, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, + getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : HorizonOfflineViewModel(networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) { + private val _uiState = MutableStateFlow(DashboardCourseUiState(onRefresh = ::onRefresh)) val uiState = _uiState.asStateFlow() @@ -54,20 +59,25 @@ class DashboardCourseViewModel @Inject constructor( viewModelScope.launch { dashboardEventHandler.events.collect { event -> when (event) { - is DashboardEvent.ProgressRefresh -> { - onRefresh() - } + is DashboardEvent.ProgressRefresh -> onRefresh() else -> { /* No-op */ } } } } } + override fun onNetworkRestored() { + loadData() + } + + override fun onNetworkLost() { + // Offline banner is handled by DashboardViewModel; no action needed here + } + private fun loadData() { _uiState.update { it.copy(state = DashboardItemState.LOADING) } - viewModelScope.tryLaunch { - fetchData(forceNetwork = false) + fetchData() _uiState.update { it.copy(state = DashboardItemState.SUCCESS) } } catch { _uiState.update { it.copy(state = DashboardItemState.ERROR) } @@ -77,7 +87,7 @@ class DashboardCourseViewModel @Inject constructor( private fun onRefresh(onFinished: () -> Unit = {}) { viewModelScope.tryLaunch { _uiState.update { it.copy(state = DashboardItemState.LOADING) } - fetchData(forceNetwork = true) + fetchData() _uiState.update { it.copy(state = DashboardItemState.SUCCESS) } onFinished() } catch { @@ -86,58 +96,34 @@ class DashboardCourseViewModel @Inject constructor( } } - private suspend fun fetchData(forceNetwork: Boolean) { - var enrollments = repository.getEnrollments(forceNetwork) - val programs = repository.getPrograms(forceNetwork) - val invitations = enrollments.filter { it.state == EnrollmentWorkflowState.invited } - - // Accept invitations automatically - if (invitations.isNotEmpty()) { - invitations.forEach { enrollment -> - repository.acceptInvite( - enrollment.course?.id?.toLongOrNull() ?: return@forEach, - enrollment.id?.toLongOrNull() ?: return@forEach - ) - } - enrollments = repository.getEnrollments(true) - } + private suspend fun fetchData() { + val data = getDashboardCoursesUseCase() - - val courseCardStates = enrollments.mapToDashboardCourseCardState( + val courseCardStates = data.enrollments.mapToDashboardCourseCardState( context, - programs = programs, + programs = data.programs, nextModuleForCourse = { courseId -> - fetchNextModuleState(courseId, forceNetwork) + data.nextModuleItemByCourseId[courseId]?.let { mapToModuleItemState(it) } }, ) - val programCardStates = programs - .filter { program -> program.sortedRequirements.none { it.enrollmentStatus == ProgramProgressCourseEnrollmentStatus.ENROLLED } } - .mapToDashboardCourseCardState(context) + val programCardStates = data.unenrolledPrograms.mapToDashboardCourseCardState(context) _uiState.update { it.copy( programs = DashboardPaginatedWidgetCardState(programCardStates), - courses = courseCardStates + courses = courseCardStates, ) } } - private suspend fun fetchNextModuleState(courseId: Long?, forceNetwork: Boolean): DashboardCourseCardModuleItemState? { - if (courseId == null) return null - val modules = repository.getFirstPageModulesWithItems(courseId, forceNetwork = forceNetwork) - val nextModuleItem = modules.flatMap { module -> module.items }.firstOrNull() - val nextModule = modules.find { module -> module.id == nextModuleItem?.moduleId } - if (nextModuleItem == null) { - return null - } - + private fun mapToModuleItemState(moduleItem: DashboardNextModuleItem): DashboardCourseCardModuleItemState { return DashboardCourseCardModuleItemState( - moduleItemTitle = nextModuleItem.title.orEmpty(), - moduleItemType = if (nextModuleItem.quizLti) LearningObjectType.ASSESSMENT else LearningObjectType.fromApiString(nextModuleItem.type.orEmpty()), - dueDate = nextModuleItem.moduleDetails?.dueDate, - estimatedDuration = nextModuleItem.estimatedDuration?.formatIsoDuration(context), - onClickAction = CardClickAction.NavigateToModuleItem(courseId, nextModuleItem.id) + moduleItemTitle = moduleItem.title, + moduleItemType = moduleItem.type, + dueDate = moduleItem.dueDate, + estimatedDuration = moduleItem.estimatedDuration?.formatIsoDuration(context), + onClickAction = CardClickAction.NavigateToModuleItem(moduleItem.courseId, moduleItem.moduleItemId), ) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt index 98af5b63d9..720f5b3a39 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt @@ -17,9 +17,8 @@ package com.instructure.horizon.features.dashboard.widget.course import android.content.Context -import com.instructure.canvasapi2.GetCoursesQuery +import com.instructure.canvasapi2.managers.graphql.horizon.DashboardEnrollment import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.type.EnrollmentWorkflowState import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardHeaderState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardItemState @@ -33,10 +32,10 @@ import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCo import com.instructure.horizon.features.learn.navigation.LearnRoute import com.instructure.horizon.horizonui.foundation.HorizonColors -internal suspend fun List.mapToDashboardCourseCardState( +internal suspend fun List.mapToDashboardCourseCardState( context: Context, programs: List, - nextModuleForCourse: suspend (Long?) -> DashboardCourseCardModuleItemState? + nextModuleForCourse: suspend (Long) -> DashboardCourseCardModuleItemState? ): List { val completed = this.filter { it.isCompleted() }.mapCompleted(context, programs) val active = this.filter { it.isActive() }.mapActive(programs, nextModuleForCourse) @@ -60,23 +59,19 @@ internal fun List.mapToDashboardCourseCardState(context: Context): List } } -private fun GetCoursesQuery.Enrollment.isCompleted(): Boolean { - return this.state == EnrollmentWorkflowState.completed || this.isMaxProgress() +private fun DashboardEnrollment.isCompleted(): Boolean { + return enrollmentState == DashboardEnrollment.STATE_COMPLETED || completionPercentage == 100.0 } -private fun GetCoursesQuery.Enrollment.isActive(): Boolean { - return this.state == EnrollmentWorkflowState.active && !this.isMaxProgress() +private fun DashboardEnrollment.isActive(): Boolean { + return enrollmentState == DashboardEnrollment.STATE_ACTIVE && completionPercentage != 100.0 } -private fun GetCoursesQuery.Enrollment.isMaxProgress(): Boolean { - return this.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage == 100.0 -} - -private fun List.mapCompleted(context: Context, programs: List): List { - return map { item -> +private fun List.mapCompleted(context: Context, programs: List): List { + return map { enrollment -> DashboardCourseCardState( parentPrograms = programs - .filter { it.sortedRequirements.any { it.courseId == item.course?.id?.toLongOrNull() } } + .filter { it.sortedRequirements.any { req -> req.courseId == enrollment.courseId } } .map { program -> DashboardCourseCardParentProgramState( programName = program.name, @@ -85,30 +80,29 @@ private fun List.mapCompleted(context: Context, prog ) }, imageState = DashboardCourseCardImageState( - imageUrl = item.course?.image_download_url, + imageUrl = enrollment.courseImageUrl, showPlaceholder = true ), - title = item.course?.name.orEmpty(), + title = enrollment.courseName, descriptionState = DashboardCourseCardDescriptionState( descriptionTitle = context.getString(R.string.dashboardCompletedCourseTitle), description = context.getString(R.string.dashboardCompletedCourseMessage), ), - progress = item.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage - ?: 0.0, + progress = enrollment.completionPercentage, moduleItem = null, - onClickAction = CardClickAction.NavigateToCourse(item.course?.id?.toLongOrNull() ?: -1L) + onClickAction = CardClickAction.NavigateToCourse(enrollment.courseId) ) } } -private suspend fun List.mapActive( +private suspend fun List.mapActive( programs: List, - nextModuleForCourse: suspend (Long?) -> DashboardCourseCardModuleItemState? + nextModuleForCourse: suspend (Long) -> DashboardCourseCardModuleItemState? ): List { - return map { item -> + return map { enrollment -> DashboardCourseCardState( parentPrograms = programs - .filter { it.sortedRequirements.any { it.courseId == item.course?.id?.toLongOrNull() } } + .filter { it.sortedRequirements.any { req -> req.courseId == enrollment.courseId } } .map { program -> DashboardCourseCardParentProgramState( programName = program.name, @@ -117,24 +111,21 @@ private suspend fun List.mapActive( ) }, imageState = DashboardCourseCardImageState( - imageUrl = item.course?.image_download_url, + imageUrl = enrollment.courseImageUrl, showPlaceholder = true ), - title = item.course?.name.orEmpty(), + title = enrollment.courseName, descriptionState = null, - progress = item.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage - ?: 0.0, - moduleItem = nextModuleForCourse(item.course?.id?.toLongOrNull()), - onClickAction = CardClickAction.NavigateToCourse( - item.course?.id?.toLongOrNull() ?: -1L - ), + progress = enrollment.completionPercentage, + moduleItem = nextModuleForCourse(enrollment.courseId), + onClickAction = CardClickAction.NavigateToCourse(enrollment.courseId), ) } } private fun List.adjustAndSortCourseCardValues(): List { return sortedByDescending { course -> - course.progress.run { if (this == 100.0) -1.0 else this } // Active courses first, then completed courses + course.progress.run { if (this == 100.0) -1.0 else this } ?: 0.0 }.mapIndexed { index, item -> item.copy( @@ -144,4 +135,4 @@ private fun List.adjustAndSortCourseCardValues(): List ) ) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/model/DashboardNextModuleItem.kt b/libs/horizon/src/main/java/com/instructure/horizon/model/DashboardNextModuleItem.kt new file mode 100644 index 0000000000..55f804bcb4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/model/DashboardNextModuleItem.kt @@ -0,0 +1,28 @@ +/* + * 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.model + +import java.util.Date + +data class DashboardNextModuleItem( + val moduleItemId: Long, + val courseId: Long, + val title: String, + val type: LearningObjectType, + val dueDate: Date?, + val estimatedDuration: String?, + val isQuizLti: Boolean, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineViewModel.kt new file mode 100644 index 0000000000..ebd4f2a219 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/HorizonOfflineViewModel.kt @@ -0,0 +1,57 @@ +/* + * 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.offline + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.utils.weave.catch +import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.database.entity.SyncDataType +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop + +abstract class HorizonOfflineViewModel( + private val networkStateProvider: NetworkStateProvider, + private val featureFlagProvider: FeatureFlagProvider, + private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase +) : ViewModel() { + + abstract fun onNetworkRestored() + + abstract fun onNetworkLost() + + suspend fun getLastSyncTime(syncType: SyncDataType): Long? { + return getLastSyncedAtUseCase(GetLastSyncedAtUseCase.Params(syncType)) + } + + init { + viewModelScope.tryLaunch { + networkStateProvider.isOnlineLiveData.asFlow() + .distinctUntilChanged() + .drop(1) + .collect { isOnline -> + if (featureFlagProvider.offlineEnabled()) { + if (isOnline) onNetworkRestored() else onNetworkLost() + } + } + } catch { } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt new file mode 100644 index 0000000000..eccd1754a6 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/OfflineSyncRepository.kt @@ -0,0 +1,31 @@ +/* + * 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.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider + +abstract class OfflineSyncRepository( + private val networkStateProvider: NetworkStateProvider, + private val featureFlagProvider: FeatureFlagProvider, +) { + fun isOnline() = networkStateProvider.isOnline() + suspend fun offlineEnabled() = featureFlagProvider.offlineEnabled() + suspend fun shouldFetchFromNetwork() = isOnline() || !offlineEnabled() + suspend fun shouldSync() = isOnline() && offlineEnabled() + + abstract suspend fun sync() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/offline/SyncPolicy.kt b/libs/horizon/src/main/java/com/instructure/horizon/offline/SyncPolicy.kt new file mode 100644 index 0000000000..0495edce88 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/offline/SyncPolicy.kt @@ -0,0 +1,21 @@ +/* + * 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 + +enum class SyncPolicy { + ALWAYS_REPLACE, + SKIP_IF_PRESENT, +} diff --git a/libs/horizon/src/main/res/drawable/ic_no_wifi.xml b/libs/horizon/src/main/res/drawable/ic_no_wifi.xml new file mode 100644 index 0000000000..bcd527c6b1 --- /dev/null +++ b/libs/horizon/src/main/res/drawable/ic_no_wifi.xml @@ -0,0 +1,9 @@ + + + diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 870f4f606d..1cce7d4e76 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -658,4 +658,6 @@ %1$d min No results found. Try adjusting your search terms. Failed to update + You\'re offline. + You\'re offline. Last synced %1$s. \ No newline at end of file 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 1f76f45be8..97678137be 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 @@ -19,7 +19,10 @@ package com.instructure.horizon.features.dashboard import android.content.Context import com.instructure.canvasapi2.models.UnreadNotificationCount import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.domain.usecase.GetLastSyncedAtUseCase +import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.LocaleUtils +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.ThemePrefs import io.mockk.coEvery import io.mockk.coVerify @@ -43,6 +46,9 @@ 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 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() @@ -156,6 +162,6 @@ class DashboardViewModelTest { } private fun getViewModel(): DashboardViewModel { - return DashboardViewModel(context, repository, apiPrefs, themePrefs, localeUtils, dashboardEventHandler) + return DashboardViewModel(context, repository, apiPrefs, themePrefs, localeUtils, dashboardEventHandler, networkStateProvider, featureFlagProvider, getLastSyncedAtUseCase) } } \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseRepositoryTest.kt deleted file mode 100644 index 108ceadccb..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseRepositoryTest.kt +++ /dev/null @@ -1,191 +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.dashboard.course - -import com.instructure.canvasapi2.GetCoursesQuery -import com.instructure.canvasapi2.apis.EnrollmentAPI -import com.instructure.canvasapi2.apis.ModuleAPI -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.models.CanvasContext -import com.instructure.canvasapi2.models.ModuleItem -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.type.EnrollmentWorkflowState -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.horizon.features.dashboard.widget.course.DashboardCourseRepository -import com.instructure.journey.type.ProgramVariantType -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 DashboardCourseRepositoryTest { - private val horizonGetCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - private val moduleApi: ModuleAPI. ModuleInterface = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) - private val enrollmentApi: EnrollmentAPI. EnrollmentInterface = mockk(relaxed = true) - private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) - - private val userId = 1L - @Before - fun setup() { - every { apiPrefs.user?.id } returns userId - } - - @Test - fun `Test successful getEnrollments call`() = runTest { - val enrollments = listOf( - GetCoursesQuery.Enrollment( - "1", - EnrollmentWorkflowState.active, - null, - null - ) - ) - coEvery { horizonGetCoursesManager.getEnrollments(any(), any()) } returns DataResult.Success(enrollments) - val repository = getRepository() - - - val result = repository.getEnrollments(forceNetwork = true) - coVerify { horizonGetCoursesManager.getEnrollments(userId, true) } - assertEquals(enrollments, result) - } - - @Test(expected = IllegalStateException::class) - fun `Test failed getEnrollments call`() = runTest { - coEvery { horizonGetCoursesManager.getEnrollments(any(), any()) } returns DataResult.Fail() - val repository = getRepository() - - repository.getEnrollments(forceNetwork = true) - coVerify { horizonGetCoursesManager.getEnrollments(userId, true) } - } - - @Test - fun `Test successful acceptInvite call`() = runTest { - val repository = getRepository() - coEvery { enrollmentApi.acceptInvite(any(), any(), any()) } returns DataResult.Success(Unit) - repository.acceptInvite(1, 1) - coVerify { enrollmentApi.acceptInvite(1, 1, any()) } - } - - @Test(expected = IllegalStateException::class) - fun `Test failed acceptInvite call`() = runTest { - val repository = getRepository() - coEvery { enrollmentApi.acceptInvite(any(), any(), any()) } returns DataResult.Fail() - repository.acceptInvite(1, 1) - coVerify { enrollmentApi.acceptInvite(1, 1, any()) } - } - - @Test - fun `Test successful getPrograms call`() = runTest { - val programs = listOf( - Program( - "1", - "Program 1", - null, - null, - null, - ProgramVariantType.LINEAR, - null, - emptyList() - ), - Program( - "2", - "Program 2", - null, - null, - null, - ProgramVariantType.NON_LINEAR, - null, - emptyList() - ), - ) - coEvery { getProgramsManager.getPrograms(any()) } returns programs - val repository = getRepository() - - val result = repository.getPrograms() - coVerify { getProgramsManager.getPrograms(any()) } - assertEquals(programs, result) - } - - @Test - fun `Test successful getFirstPageModulesWithItems call`() = runTest { - val courseId = 1L - val modules = listOf( - ModuleObject( - id = 1, - name = "Module 1", - items = listOf( - ModuleItem( - id = 1, - title = "Module Item 1", - moduleId = 1, - contentId = 1, - type = "Page", - estimatedDuration = "PT10M" - ) - ) - ), - ModuleObject( - id = 2, - name = "Module 2", - items = listOf( - ModuleItem( - id = 2, - title = "Module Item 2", - moduleId = 2, - contentId = 2, - type = "Assignment", - estimatedDuration = "PT10M" - ) - ) - ), - ) - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns DataResult.Success(modules) - val repository = getRepository() - - val result = repository.getFirstPageModulesWithItems(courseId, forceNetwork = true) - coVerify { moduleApi.getFirstPageModulesWithItems(CanvasContext.Type.COURSE.apiString, courseId, any(), listOf("estimated_durations")) } - assertEquals(modules, result) - } - - @Test(expected = IllegalStateException::class) - fun `Test failed getFirstPageModulesWithItems call`() = runTest { - val courseId = 1L - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns DataResult.Fail() - val repository = getRepository() - - repository.getFirstPageModulesWithItems(courseId, forceNetwork = true) - coVerify { moduleApi.getFirstPageModulesWithItems(CanvasContext.Type.COURSE.apiString, courseId, any(), listOf("estimated_durations")) } - } - - private fun getRepository(): DashboardCourseRepository { - return DashboardCourseRepository( - horizonGetCoursesManager, - moduleApi, - apiPrefs, - enrollmentApi, - getProgramsManager - ) - } -} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt deleted file mode 100644 index b383942e32..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt +++ /dev/null @@ -1,211 +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.dashboard.course - -import android.content.Context -import com.instructure.canvasapi2.GetCoursesQuery -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.canvasapi2.type.EnrollmentWorkflowState -import com.instructure.horizon.features.dashboard.DashboardEventHandler -import com.instructure.horizon.features.dashboard.widget.course.DashboardCourseRepository -import com.instructure.horizon.features.dashboard.widget.course.DashboardCourseViewModel -import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus -import com.instructure.journey.type.ProgramVariantType -import com.instructure.pandautils.utils.ThemePrefs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.runs -import io.mockk.unmockkAll -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import java.util.Date - -@OptIn(ExperimentalCoroutinesApi::class) -class DashboardCourseViewModelTest { - private val context: Context = mockk(relaxed = true) - private var repository: DashboardCourseRepository = mockk(relaxed = true) - private val testDispatcher = UnconfinedTestDispatcher() - private val dashboardEventHandler = DashboardEventHandler() - - private val courses = listOf( - GetCoursesQuery.Course( - id = "1", - name = "Course 1", - image_download_url = "url_1", - syllabus_body = "syllabus 1", - account = GetCoursesQuery.Account("Account 1"), - usersConnection = null - ), - GetCoursesQuery.Course( - id = "2", - name = "Course 2", - image_download_url = null, - syllabus_body = null, - account = null, - usersConnection = null - ), - GetCoursesQuery.Course( - id = "3", - name = "Course 3", - image_download_url = null, - syllabus_body = null, - account = null, - usersConnection = null - ), - GetCoursesQuery.Course( - id = "4", - name = "Course 4", - image_download_url = null, - syllabus_body = null, - account = null, - usersConnection = null - ), - ) - private val activeEnrollments = listOf( - GetCoursesQuery.Enrollment( - id = "1", - state = EnrollmentWorkflowState.active, - lastActivityAt = Date(), - course = courses[0] - ), - GetCoursesQuery.Enrollment( - id = "2", - state = EnrollmentWorkflowState.active, - lastActivityAt = Date(), - course = courses[1] - ), - ) - private val invitedEnrollments = listOf( - GetCoursesQuery.Enrollment( - id = "3", - state = EnrollmentWorkflowState.invited, - lastActivityAt = Date(), - course = courses[2] - ) - ) - private val completedEnrollments = listOf( - GetCoursesQuery.Enrollment( - id = "4", - state = EnrollmentWorkflowState.completed, - lastActivityAt = Date(), - course = courses[3] - ) - ) - private val programs = listOf( - Program( // Not started Program - id = "1", - name = "Program 1", - description = "Program 1 description", - startDate = null, - endDate = null, - variant = ProgramVariantType.LINEAR, - sortedRequirements = emptyList() - ), - Program( // Program with Course 2 - id = "2", - name = "Program 2", - description = "Program 2 description", - startDate = null, - endDate = null, - variant = ProgramVariantType.LINEAR, - sortedRequirements = listOf( - ProgramRequirement( - id = "1", - progressId = "1", - courseId = 2, - required = true, - progress = 5.0, - enrollmentStatus = ProgramProgressCourseEnrollmentStatus.ENROLLED - ) - ) - ) - ) - private val modules = listOf( - ModuleObject( - id = 1, - name = "Module 1", - items = listOf( - ModuleItem( - id = 1, - title = " Module Item 1", - moduleId = 1, - contentId = 1, - type = "Page", - estimatedDuration = "PT11M" - ) - ) - ) - ) - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - - mockkObject(ThemePrefs) - every { ThemePrefs.brandColor } returns 1 - - coEvery { repository.getEnrollments(any()) } returns activeEnrollments + invitedEnrollments + completedEnrollments - coEvery { repository.getPrograms(any()) } returns programs - coEvery { repository.acceptInvite(any(), any()) } just runs - coEvery { repository.getFirstPageModulesWithItems(any(), any()) } returns modules - } - - @After - fun tearDown() { - Dispatchers.resetMain() - unmockkAll() - } - - @Test - fun `Test course and empty programs are in the state list`() { - coEvery { repository.getEnrollments(any()) } returns activeEnrollments + completedEnrollments - val viewModel = getViewModel() - val state = viewModel.uiState.value - assertEquals(3, state.courses.size) - assertTrue(state.courses.any { it.title == "Course 1" }) - assertTrue(state.courses.any { it.title == "Course 2" }) - assertTrue(state.courses.any { it.title == "Course 4" }) - assertTrue(state.courses.none { it.title == "Course 3" }) - - assertEquals(1, state.programs.items.size) - } - - @Test - fun `Test course invitations are automatically accepted`() { - val viewModel = getViewModel() - coVerify { repository.acceptInvite(3, 3) } - } - - private fun getViewModel(): DashboardCourseViewModel { - return DashboardCourseViewModel(context, repository, dashboardEventHandler) - } -} \ No newline at end of file