Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ interface HorizonGetCoursesManager {

suspend fun getEnrollments(userId: Long, forceNetwork: Boolean = false): DataResult<List<GetCoursesQuery.Enrollment>>

suspend fun getDashboardEnrollments(userId: Long, forceNetwork: Boolean = false): DataResult<List<DashboardEnrollment>>

suspend fun getProgramCourses(courseId: Long, forceNetwork: Boolean = false): DataResult<CourseWithModuleItemDurations>
}

Expand Down Expand Up @@ -101,6 +103,33 @@ class HorizonGetCoursesManagerImpl(private val apolloClient: ApolloClient): Hori
}
}

override suspend fun getDashboardEnrollments(userId: Long, forceNetwork: Boolean): DataResult<List<DashboardEnrollment>> {
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<CourseWithModuleItemDurations> {
var hasNextPage = true
var nextCursor: String? = null
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions libs/horizon/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DashboardEnrollment> {
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<DashboardEnrollment>) {
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<Long> = enrollmentDao.getAllCourseIds()
}
Original file line number Diff line number Diff line change
@@ -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<DashboardEnrollment> {
return horizonGetCoursesManager.getDashboardEnrollments(
userId = apiPrefs.user?.id ?: -1,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apiPrefs.user?.id ?: -1 will silently pass -1 as a user ID to the GraphQL query when the user is not logged in, likely producing an empty or error response that is hard to debug. Consider throwing instead so callers can handle the unauthenticated case explicitly:

userId = apiPrefs.user?.id ?: error("User must be logged in to fetch enrollments"),

forceNetwork = true,
).dataOrThrow
}

suspend fun acceptInvite(courseId: Long, enrollmentId: Long) {
enrollmentApi.acceptInvite(courseId, enrollmentId, RestParams()).dataOrThrow
}
}
Original file line number Diff line number Diff line change
@@ -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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LearningObjectType.valueOf(entity.moduleItemType) will throw IllegalArgumentException if the stored string doesn't match an enum constant — for example after a future enum rename or if the database already contains a value from a schema that predates this one. ProgramLocalDataSource uses safeValueOf for the same pattern; please apply the same defensive approach here:

type = LearningObjectType.safeValueOf(entity.moduleItemType),

(or filter out rows with unknown types before mapping them)

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)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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<Program> {
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<Program>, enrolledCourseIds: Set<Long>) {
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)
}
}
Loading
Loading