Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -19,6 +19,8 @@ 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
Expand All @@ -29,7 +31,9 @@ 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
Expand Down Expand Up @@ -90,6 +94,18 @@ object HorizonOfflineTestModule {
@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(
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,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 - present Instructure, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.instructure.horizon.data.datasource

import com.instructure.canvasapi2.models.Assignment
import com.instructure.canvasapi2.models.ExternalToolAttributes
import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao
import com.instructure.horizon.database.entity.HorizonAssignmentDetailsEntity
import javax.inject.Inject

class AssignmentDetailsLocalDataSource @Inject constructor(
private val assignmentDetailsDao: HorizonAssignmentDetailsDao,
) {

suspend fun getAssignment(assignmentId: Long): Assignment? {
return assignmentDetailsDao.getAssignment(assignmentId)?.toAssignment()
}

suspend fun saveAssignment(assignment: Assignment, courseId: Long, parsedDescription: String?) {
assignmentDetailsDao.saveAssignment(
HorizonAssignmentDetailsEntity(
assignmentId = assignment.id,
courseId = courseId,
name = assignment.name,
description = parsedDescription,
pointsPossible = assignment.pointsPossible,
allowedAttempts = assignment.allowedAttempts,
dueAt = assignment.dueAt,
submissionTypes = assignment.submissionTypesRaw.joinToString(","),
gradingType = assignment.gradingType,
lockedForUser = assignment.lockedForUser,
lockExplanation = assignment.lockExplanation,
quizId = assignment.quizId,
url = assignment.url,
ltiToolUrl = assignment.externalToolAttributes?.url,
)
)
}

private fun HorizonAssignmentDetailsEntity.toAssignment(): Assignment {
return Assignment(
id = assignmentId,
name = name,
description = description,
pointsPossible = pointsPossible,
allowedAttempts = allowedAttempts,
dueAt = dueAt,
submissionTypesRaw = submissionTypes.split(",").filter { it.isNotEmpty() },
gradingType = gradingType,
lockedForUser = lockedForUser,
lockExplanation = lockExplanation,
quizId = quizId,
url = url,
externalToolAttributes = ltiToolUrl?.let { ExternalToolAttributes(url = it) },
)
}
}
Original file line number Diff line number Diff line change
@@ -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.data.datasource

import com.instructure.canvasapi2.apis.AssignmentAPI
import com.instructure.canvasapi2.builders.RestParams
import com.instructure.canvasapi2.models.Assignment
import javax.inject.Inject

class AssignmentDetailsNetworkDataSource @Inject constructor(
private val assignmentApi: AssignmentAPI.AssignmentInterface,
) {

suspend fun getAssignment(courseId: Long, assignmentId: Long, forceRefresh: Boolean): Assignment {
val params = RestParams(isForceReadFromNetwork = forceRefresh)
return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,35 @@ class CourseProgressLocalDataSource @Inject constructor(
completed = completionRequirementCompleted,
)
}
val moduleDetails = if (pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null) {
val moduleDetails = if (
pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null ||
hidden != null || locked != null
) {
ModuleContentDetails(
pointsPossible = pointsPossible,
dueAt = dueAt,
lockedForUser = lockedForUser,
lockExplanation = lockExplanation,
lockAt = lockAt,
unlockAt = unlockAt,
hidden = hidden,
locked = locked,
)
} else null
return ModuleItem(
id = itemId,
moduleId = moduleId,
position = position,
indent = indent,
title = title,
type = type,
htmlUrl = htmlUrl,
url = url,
contentId = contentId,
externalUrl = externalUrl,
pageUrl = pageUrl,
published = published,
unpublishable = unpublishable,
completionRequirement = completionRequirement,
moduleDetails = moduleDetails,
quizLti = quizLti,
Expand All @@ -120,9 +131,15 @@ class CourseProgressLocalDataSource @Inject constructor(
courseId = courseId,
title = title,
position = position,
indent = indent,
type = type,
htmlUrl = htmlUrl,
url = url,
contentId = contentId,
externalUrl = externalUrl,
pageUrl = pageUrl,
published = published,
unpublishable = unpublishable,
completionRequirementType = completionRequirement?.type,
completionRequirementMinScore = completionRequirement?.minScore ?: 0.0,
completionRequirementCompleted = completionRequirement?.completed ?: false,
Expand All @@ -132,6 +149,8 @@ class CourseProgressLocalDataSource @Inject constructor(
lockExplanation = moduleDetails?.lockExplanation,
lockAt = moduleDetails?.lockAt,
unlockAt = moduleDetails?.unlockAt,
hidden = moduleDetails?.hidden,
locked = moduleDetails?.locked,
quizLti = quizLti,
estimatedDuration = estimatedDuration,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.instructure.horizon.data.datasource
import com.instructure.canvasapi2.apis.ModuleAPI
import com.instructure.canvasapi2.builders.RestParams
import com.instructure.canvasapi2.models.CanvasContext
import com.instructure.canvasapi2.models.ModuleItem
import com.instructure.canvasapi2.models.ModuleObject
import com.instructure.canvasapi2.utils.depaginate
import javax.inject.Inject
Expand All @@ -28,13 +29,32 @@ class CourseProgressNetworkDataSource @Inject constructor(

suspend fun getModuleItems(courseId: Long, forceRefresh: Boolean): List<ModuleObject> {
val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh)
return moduleApi.getFirstPageModulesWithItems(
val modules = moduleApi.getFirstPageModulesWithItems(
CanvasContext.Type.COURSE.apiString,
courseId,
params,
includes = listOf("estimated_durations")
)
.depaginate { moduleApi.getNextPageModuleObjectList(it, params) }
.dataOrThrow

return modules.map { module ->
if (module.itemCount != module.items.size) {
module.copy(items = getAllModuleItems(courseId, module.id))
} else {
module
}
}
}

private suspend fun getAllModuleItems(courseId: Long, moduleId: Long): List<ModuleItem> {
val params = RestParams()
return moduleApi.getFirstPageModuleItems(
CanvasContext.Type.COURSE.apiString,
courseId,
moduleId,
params,
includes = listOf("estimated_durations")
).depaginate { moduleApi.getNextPageModuleItemList(it, params) }.dataOrThrow
}
}
Loading
Loading