Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5375c00
Implement course widget basic architecture
domonkosadam Mar 30, 2026
fb2b3f9
Migrate to usecase pattern
domonkosadam Mar 30, 2026
1951eea
Refactor architecture
domonkosadam Mar 31, 2026
1072fe3
Improve architecture
domonkosadam Mar 31, 2026
df03097
Add sync functionality
domonkosadam Apr 1, 2026
544244c
Refactor daos
domonkosadam Apr 1, 2026
bdce3e9
Improve entity handling
domonkosadam Apr 2, 2026
2144299
Refactor feature keys
domonkosadam Apr 2, 2026
63c70e2
Implement sync date usecase
domonkosadam Apr 2, 2026
9d6bf2d
Update FeatureFlagProvider.kt
domonkosadam Apr 2, 2026
c9451c1
Implement offline architecture for Learn screen
domonkosadam Apr 7, 2026
1b88ec5
Implement learn details screen offline mode
domonkosadam Apr 8, 2026
8a63d09
Fix network handling
domonkosadam Apr 8, 2026
f35e607
Remove mocked feature flag
domonkosadam Apr 8, 2026
8a189ce
Improve offline handling
domonkosadam Apr 8, 2026
4133a02
Fix tests
domonkosadam Apr 8, 2026
02a5fb7
Unify list/detail models
domonkosadam Apr 9, 2026
d6756a6
Improvements
domonkosadam Apr 10, 2026
144f16d
Implement improvements
domonkosadam Apr 10, 2026
9ad5fae
Improvements
domonkosadam Apr 10, 2026
e0d76be
Trigger CI
domonkosadam Apr 10, 2026
594e6d0
Fix tests
domonkosadam Apr 13, 2026
98e843a
Refactor architecture
domonkosadam Apr 13, 2026
3b4ee86
Fix page handling
domonkosadam Apr 14, 2026
6a18f52
Sync improvement
domonkosadam Apr 14, 2026
63e32e2
Fix tests
domonkosadam Apr 15, 2026
57f788a
Fix feature flag
domonkosadam Apr 15, 2026
3a76019
Merge branch 'feature/horizon-offline' into CLXR-475-Learn-offline-mode
domonkosadam Apr 22, 2026
3a4dce1
Fix merge issues
domonkosadam Apr 22, 2026
8b723eb
Fix tests
domonkosadam Apr 22, 2026
c3830d3
Merge branch 'CLXR-475-Learn-offline-mode' into CLXR-479-ModuleItems-…
domonkosadam Apr 22, 2026
166eb6e
Fix tests
domonkosadam Apr 22, 2026
a1537aa
Merge branch 'feature/horizon-offline' into CLXR-479-ModuleItems-Offl…
domonkosadam Apr 23, 2026
983d11d
Merge fixes
domonkosadam Apr 23, 2026
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 @@ -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
Expand Down Expand Up @@ -89,6 +90,33 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
)
}

override suspend fun getDashboardEnrollments(
userId: Long,
forceNetwork: Boolean
): DataResult<List<DashboardEnrollment>> {
val enrollments = MockCanvas.data.enrollments.values.toList()
val courses = getCourses()
val dashboardEnrollments = courses.mapIndexedNotNull { index, course ->
val enrollmentId = enrollments.getOrNull(index)?.id ?: return@mapIndexedNotNull null
val state = when (index) {
1 -> DashboardEnrollment.STATE_COMPLETED
2 -> DashboardEnrollment.STATE_INVITED
else -> DashboardEnrollment.STATE_ACTIVE
}
DashboardEnrollment(
enrollmentId = enrollmentId,
enrollmentState = state,
courseId = course.courseId,
courseName = course.courseName,
courseImageUrl = course.courseImageUrl,
courseSyllabus = course.courseSyllabus,
institutionName = null,
completionPercentage = course.progress,
)
}
return DataResult.Success(dashboardEnrollments)
}

override suspend fun getProgramCourses(
courseId: Long,
forceNetwork: Boolean
Expand All @@ -112,7 +140,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
courseName = courses[0].name,
courseImageUrl = null,
courseSyllabus = "Syllabus for Course 1",
progress = 0.25
progress = 25.0
)
} else { null }
val completedCourse = if (courses.size > 1) {
Expand All @@ -121,7 +149,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
courseName = courses[1].name,
courseImageUrl = null,
courseSyllabus = "Syllabus for Course 2",
progress = 1.0
progress = 100.0
)
} else { null }
val invitedCourse = if (courses.size > 2) {
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ enum class CollectionItemType {
EXTERNAL_URL,
EXTERNAL_TOOL,
FILE,
PROGRAM
PROGRAM;

companion object {
fun safeValueOf(name: String): CollectionItemType? = entries.find { it.name == name }
}
}

fun ApolloCollectionItemType.toModel(): CollectionItemType {
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,116 @@
/*
* Copyright (C) 2026 - present Instructure, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.instructure.horizon.espresso

import android.content.Context
import androidx.room.Room
import com.instructure.canvasapi2.utils.ApiPrefs
import com.instructure.horizon.database.HorizonDatabase
import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao
import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao
import com.instructure.horizon.database.dao.HorizonCourseDao
import com.instructure.horizon.database.dao.HorizonCourseModuleDao
import com.instructure.horizon.database.dao.HorizonCourseScoreDao
import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao
import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao
import com.instructure.horizon.database.dao.HorizonFileFolderDao
import com.instructure.horizon.database.dao.HorizonLearnCollectionDao
import com.instructure.horizon.database.dao.HorizonLearnItemDao
import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao
import com.instructure.horizon.database.dao.HorizonLocalFileDao
import com.instructure.horizon.database.dao.HorizonPageDao
import com.instructure.horizon.database.dao.HorizonProgramDao
import com.instructure.horizon.database.dao.HorizonSubmissionDao
import com.instructure.horizon.database.dao.HorizonSyncMetadataDao
import com.instructure.horizon.di.HorizonHtmlParserQualifier
import com.instructure.horizon.di.HorizonOfflineModule
import com.instructure.horizon.offline.HorizonHtmlParserFileSource
import com.instructure.pandautils.features.offline.sync.HtmlParser
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [HorizonOfflineModule::class])
object HorizonOfflineTestModule {

@Provides
@Singleton
fun provideHorizonDatabase(@ApplicationContext context: Context): HorizonDatabase {
return Room.inMemoryDatabaseBuilder(context, HorizonDatabase::class.java)
.allowMainThreadQueries()
.build()
}

@Provides
fun provideHorizonDashboardEnrollmentDao(db: HorizonDatabase): HorizonDashboardEnrollmentDao = db.dashboardEnrollmentDao()

@Provides
fun provideHorizonProgramDao(db: HorizonDatabase): HorizonProgramDao = db.programDao()

@Provides
fun provideHorizonDashboardModuleItemDao(db: HorizonDatabase): HorizonDashboardModuleItemDao = db.dashboardModuleItemDao()

@Provides
fun provideHorizonSyncMetadataDao(db: HorizonDatabase): HorizonSyncMetadataDao = db.syncMetadataDao()

@Provides
fun provideHorizonLearnItemDao(db: HorizonDatabase): HorizonLearnItemDao = db.learnItemDao()

@Provides
fun provideHorizonLearnCollectionDao(db: HorizonDatabase): HorizonLearnCollectionDao = db.learnCollectionDao()

@Provides
fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao = db.learnSavedItemDao()

@Provides
fun provideHorizonCourseDao(db: HorizonDatabase): HorizonCourseDao = db.courseDao()

@Provides
fun provideHorizonCourseModuleDao(db: HorizonDatabase): HorizonCourseModuleDao = db.courseModuleDao()

@Provides
fun provideHorizonCourseScoreDao(db: HorizonDatabase): HorizonCourseScoreDao = db.courseScoreDao()

@Provides
fun provideHorizonLocalFileDao(db: HorizonDatabase): HorizonLocalFileDao = db.localFileDao()

@Provides
fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao = db.fileFolderDao()

@Provides
fun provideHorizonPageDao(db: HorizonDatabase): HorizonPageDao = db.pageDao()

@Provides
fun provideHorizonAssignmentDetailsDao(db: HorizonDatabase): HorizonAssignmentDetailsDao = db.assignmentDetailsDao()

@Provides
fun provideHorizonAssignmentCommentDao(db: HorizonDatabase): HorizonAssignmentCommentDao = db.assignmentCommentDao()

@Provides
fun provideHorizonSubmissionDao(db: HorizonDatabase): HorizonSubmissionDao = db.submissionDao()

@Provides
@HorizonHtmlParserQualifier
fun provideHorizonHtmlParser(
fileSource: HorizonHtmlParserFileSource,
apiPrefs: ApiPrefs,
@ApplicationContext context: Context,
): HtmlParser = HtmlParser(fileSource, apiPrefs, context)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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 dagger.hilt.android.qualifiers.ApplicationContext
import com.instructure.pandautils.features.about.AboutRepository
import com.instructure.pandautils.features.assignments.details.AssignmentDetailsBehaviour
import com.instructure.pandautils.features.assignments.details.AssignmentDetailsColorProvider
Expand Down Expand Up @@ -98,8 +101,11 @@ object HorizonTestModule {
}

@Provides
fun provideAppDatabase(): AppDatabase {
throw NotImplementedError("This is a test module. Implementation not required.")
@javax.inject.Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
}

@Provides
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2026 - present Instructure, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.instructure.horizon.data.datasource

import com.instructure.canvasapi2.managers.graphql.horizon.Comment
import com.instructure.canvasapi2.managers.graphql.horizon.CommentAttachment
import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData
import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao
import com.instructure.horizon.database.entity.HorizonAssignmentCommentAttachmentEntity
import com.instructure.horizon.database.entity.HorizonAssignmentCommentEntity
import java.util.Date
import javax.inject.Inject

class AssignmentCommentsLocalDataSource @Inject constructor(
private val commentDao: HorizonAssignmentCommentDao,
) {

suspend fun getComments(assignmentId: Long, attempt: Int): CommentsData {
val commentEntities = commentDao.getComments(assignmentId, attempt)
val commentIds = commentEntities.map { it.id }
val attachmentEntities = if (commentIds.isNotEmpty()) {
commentDao.getAttachments(commentIds)
} else {
emptyList()
}
val attachmentsByCommentId = attachmentEntities.groupBy { it.commentId }
val comments = commentEntities.map { entity ->
entity.toComment(attachmentsByCommentId[entity.id] ?: emptyList())
}
return CommentsData(
comments = comments,
hasNextPage = false,
hasPreviousPage = false,
)
}

suspend fun saveComments(assignmentId: Long, attempt: Int, commentsData: CommentsData) {
val commentWithAttachments = commentsData.comments.map { comment ->
val commentEntity = HorizonAssignmentCommentEntity(
assignmentId = assignmentId,
attempt = attempt,
authorId = comment.authorId,
authorName = comment.authorName,
commentText = comment.commentText,
createdAtMs = comment.createdAt.time,
read = comment.read,
)
val attachmentEntities = comment.attachments.map { attachment ->
HorizonAssignmentCommentAttachmentEntity(
attachmentId = attachment.attachmentId,
commentId = 0,
fileName = attachment.fileName,
fileUrl = attachment.fileUrl,
fileType = attachment.fileType,
)
}
commentEntity to attachmentEntities
}
commentDao.replaceCommentsForAttempt(assignmentId, attempt, commentWithAttachments)
}

suspend fun getUnreadCommentCount(assignmentId: Long): Int {
return commentDao.getUnreadCommentCount(assignmentId)
}

private fun HorizonAssignmentCommentEntity.toComment(
attachments: List<HorizonAssignmentCommentAttachmentEntity>,
): Comment {
return Comment(
authorId = authorId,
authorName = authorName,
commentText = commentText,
createdAt = Date(createdAtMs),
attachments = attachments.map { it.toCommentAttachment() },
read = read,
)
}

private fun HorizonAssignmentCommentAttachmentEntity.toCommentAttachment(): CommentAttachment {
return CommentAttachment(
attachmentId = attachmentId,
fileName = fileName,
fileUrl = fileUrl,
fileType = fileType,
)
}
}
Loading
Loading